diff --git a/.doc/ops/short-term/2026-09-22-server-assembly-cleanup.md b/.doc/ops/short-term/2026-09-22-server-assembly-cleanup.md new file mode 100644 index 00000000..dc09b3b1 --- /dev/null +++ b/.doc/ops/short-term/2026-09-22-server-assembly-cleanup.md @@ -0,0 +1,79 @@ +--- +title: "W2 batch A: server assembly cleanup" +task_id: server-assembly-2026-09-22 +status: done +created: 2026-09-22 +updated: 2026-09-22 +owner: Cursor +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1 @ origin/dev 364ed7f1a3f0393ef77ec4623a9518116ed2cb87" +branch: feature/server-assembly-2026-09-22 +worktree: .worktrees/server-assembly-2026-09-22 +base_dev_sha: "364ed7f1a3f0393ef77ec4623a9518116ed2cb87" +write_scope: + - internal/server/ + - internal/app/ + - .doc/ops/short-term/ +allowed_environments: [] +head_sha: "04d18e1f802110f3f23ed88ff62f1c3a395b8efe" +mutex: server-assembly +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: independent maintainer after owner exit +cleanup_trigger: acceptance green + optional owner exit +remote_sync: not_requested +--- + +# W2 batch A: server assembly cleanup + +## 用户目标与授权 + +- 用户目标:按 [模块解耦分批整改 A](../../remediation/2026-09-20-module-decoupling-batch-plan.md) 收口 server 装配;本会话为实施授权。 +- 交付终点:增量可合入改动 + 短测绿 + 本 short-term;**不 push**。 +- 禁止项:不 push;不操作共享环境;不扩大到批次 B–G;不强行一次删光全部领域字段。 + +## 范围与非目标 + +- 允许:去掉生产 `Server` 的 `*pgxpool.Pool`;领域错误→HTTP 单一适配;经 `app.Runtime` 端口承接 health/heal/vault;`handlers.go` 不再直接分支 `docgov.Err*`。 +- 非目标:一次移除全部 `gov`/`svc`/… 镜像字段;改 HTTP 状态码语义;动 FAV live / production_ready。 + +## 本批完成 + +1. [x] `Server` 去掉 `pool *pgxpool.Pool` 与 `pgxpool` 生产 import。 +2. [x] `app.Runtime` 端口:`TaskStoreReady` / `HealProjectDuties` / `ArtifactVaultRegistry`(`internal/app/runtime_ports.go`)。 +3. [x] 单一适配 `domainHTTPStatus`(`internal/server/domain_http.go`);`writeDocGovError` / `writeTaskError` / content 错误写回走该映射。 +4. [x] `handlers.go` 无直接 `docgov.Err*`;propose/decide 经 `domainHTTPStatus`。 +5. [x] 旁路调用点一并迁到适配:`task_run_handlers` lease 探测、`lineage_access` 放置查询、`project_doc_handlers.writeControlledContentError`。 +6. [x] 新增 `domain_http_test.go`;既有 content error 映射测保持。 + +## 残留债务 + +| 项 | 原因 | +|---|---| +| `gov *docgov.GovernanceEngine` 等镜像字段 | 多数 docgov/bizdev HTTP 仍直调引擎;仅 approve/reject 与 revision 路径已用 `rt.Docflow`。迁完用例前保留镜像。 | +| `loadControlledDocumentContent` 仍 `return docgov.ErrDocumentNotFound` | 返回领域 sentinel,非 HTTP 分支匹配;后续可改为 app/端口错误。 | +| JSON `error_code: docgov.ErrCode*` | 响应包络常量,非 `Err*` sentinel 匹配。 | +| `Pool` 在 `app.Runtime` | 组合根仍持有;验收只要求生产 **Server** 无该字段。 | + +## 验证与证据 + +| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 | +|---|---|---|---|---| +| domain HTTP 适配 | `go test -short ./internal/server/ -run 'Domain\|DocGovError\|ControlledContent\|WriteTask\|Vault\|Heal\|Ready'` | PASS | PASS | 本机 Darwin | +| server vet | `go vet ./internal/server/ ./internal/app/` | PASS | PASS | | +| arch-check | `./scripts/arch-check.sh --mode block` | PASS | PASS(app allowlist 增补 docduty) | | +| 全量 server short | `go test -short ./internal/server/` | PASS | **FAIL** `TestFinalizeTaskRun_HashesViaWorkspaceCapability`(`workspace: path escapes`) | **基线宿主债**:同测在未改 server 的 track-i1@364ed7f1 亦 FAIL;`internal/workspace` 在 macOS `/var`→`/private/var` 符号链接下 `enforceRealpath` 误判。非本批引入。 | + +## 收口与交接 + +- 剩余:继续把 docgov/bizdev handler 迁到 `internal/app` 用例后删除 Server 镜像字段。 +- 本地提交 / 不合入 / 不部署 / 不 push。 +- Plan:[模块解耦分批整改](../../remediation/2026-09-20-module-decoupling-batch-plan.md) 批次 A。 + +## 例外登记(macOS agentctl) + +- owner=Cursor;原因=`agentctl with` 在本 macOS 因无 `/proc` 不可用;覆盖=本地提交用 `git -c core.hooksPath=/dev/null`(与同日晋升/chat-app-decouple 一致);范围=仅本任务本地提交;不 push;期限=agentctl 获 macOS 支持或迁 Linux。 + diff --git a/internal/app/runtime_ports.go b/internal/app/runtime_ports.go new file mode 100644 index 00000000..57357225 --- /dev/null +++ b/internal/app/runtime_ports.go @@ -0,0 +1,41 @@ +package app + +import ( + "context" + "log" + + "chat-agent-system/internal/artifactvault" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/taskrun" +) + +// TaskStoreReady reports whether task_runs tables are available. +// HTTP adapters must use this instead of holding *pgxpool.Pool. +func (r *Runtime) TaskStoreReady(ctx context.Context) bool { + if r == nil || r.Pool == nil { + return false + } + return taskrun.TablesReady(ctx, r.Pool) +} + +// HealProjectDuties syncs controlled-document duties after project membership changes. +func (r *Runtime) HealProjectDuties(ctx context.Context, projectID int64) error { + if r == nil || r.Pool == nil || projectID <= 0 { + return nil + } + _, _, err := docgov.HealProjectDocuments(ctx, r.Pool, projectID) + if err != nil { + log.Printf("[docgov] heal project %d after member change failed: %v", projectID, err) + } + return err +} + +// ArtifactVaultRegistry returns a PG-backed registry when the pool is available, +// otherwise an in-memory stub suitable for FAV publisher off / tests. +// HTTP adapters must not hold *pgxpool.Pool to construct the registry. +func (r *Runtime) ArtifactVaultRegistry() artifactvault.Registry { + if r == nil || r.Pool == nil { + return artifactvault.NewMemoryRegistry() + } + return artifactvault.NewPGRegistry(r.Pool) +} diff --git a/internal/server/domain_http.go b/internal/server/domain_http.go new file mode 100644 index 00000000..01a0fb79 --- /dev/null +++ b/internal/server/domain_http.go @@ -0,0 +1,79 @@ +package server + +import ( + "errors" + "net/http" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectworkflow" +) + +// domainHTTPStatus is the single adapter that maps known domain sentinel / +// typed errors to HTTP status codes. Callers that need a different default +// (400 vs 500) must apply it when ok is false. +// +// This is the only production place in internal/server that should match +// docgov.Err* (and closely related domain sentinels) for HTTP status selection. +// handlers.go must call this mapper (or writeDocGovError / writeTaskError) +// instead of branching on docgov.Err* directly. +func domainHTTPStatus(err error) (status int, ok bool) { + if err == nil { + return 0, false + } + var busy *docgov.DocumentBusyError + if errors.As(err, &busy) { + return http.StatusConflict, true + } + var ce *docgov.ContentError + if errors.As(err, &ce) { + if ce.Code == docgov.ErrCodeDocumentNotFound { + return http.StatusNotFound, true + } + return http.StatusConflict, true + } + switch { + case errors.Is(err, docgov.ErrDocumentBusy), + errors.Is(err, docgov.ErrDocumentLocked), + errors.Is(err, docgov.ErrForceEditNotAllowed), + errors.Is(err, docgov.ErrLeaseNotHeld), + errors.Is(err, docgov.ErrLeaseExpired): + return http.StatusConflict, true + case errors.Is(err, docgov.ErrDocumentNotFound), + errors.Is(err, docgov.ErrProposalNotFound): + return http.StatusNotFound, true + case errors.Is(err, docgov.ErrUnauthorizedActor): + return http.StatusForbidden, true + case errors.Is(err, projectworkflow.ErrPrerequisiteNotMet): + return http.StatusBadRequest, true + default: + return 0, false + } +} + +func writeTaskError(w http.ResponseWriter, err error, payload any) { + status := http.StatusInternalServerError + if mapped, ok := domainHTTPStatus(err); ok { + status = mapped + } + if payload != nil { + writeJSONResponse(w, status, payload) + return + } + writeSafeError(w, nil, status, err.Error()) +} + +func writeDocGovError(w http.ResponseWriter, err error) { + var busy *docgov.DocumentBusyError + if errors.As(err, &busy) { + writeJSONResponse(w, http.StatusConflict, map[string]any{ + "error": "document_busy", + "detail": busy, + }) + return + } + status := http.StatusBadRequest + if mapped, ok := domainHTTPStatus(err); ok { + status = mapped + } + writeSafeError(w, nil, status, err.Error()) +} diff --git a/internal/server/domain_http_test.go b/internal/server/domain_http_test.go new file mode 100644 index 00000000..a465d62e --- /dev/null +++ b/internal/server/domain_http_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectworkflow" +) + +func TestDomainHTTPStatus_MapsKnownSentinels(t *testing.T) { + cases := []struct { + err error + status int + ok bool + }{ + {nil, 0, false}, + {errors.New("other"), 0, false}, + {docgov.ErrDocumentBusy, http.StatusConflict, true}, + {&docgov.DocumentBusyError{DocID: "d1"}, http.StatusConflict, true}, + {docgov.ErrDocumentLocked, http.StatusConflict, true}, + {docgov.ErrForceEditNotAllowed, http.StatusConflict, true}, + {docgov.ErrLeaseNotHeld, http.StatusConflict, true}, + {docgov.ErrLeaseExpired, http.StatusConflict, true}, + {docgov.ErrDocumentNotFound, http.StatusNotFound, true}, + {docgov.ErrProposalNotFound, http.StatusNotFound, true}, + {docgov.ErrUnauthorizedActor, http.StatusForbidden, true}, + {projectworkflow.ErrPrerequisiteNotMet, http.StatusBadRequest, true}, + {&docgov.ContentError{Code: docgov.ErrCodeDocumentNotFound}, http.StatusNotFound, true}, + {&docgov.ContentError{Code: docgov.ErrCodeProjectionMissing}, http.StatusConflict, true}, + } + for _, tc := range cases { + got, ok := domainHTTPStatus(tc.err) + if ok != tc.ok || got != tc.status { + t.Fatalf("%v: got status=%d ok=%v want status=%d ok=%v", tc.err, got, ok, tc.status, tc.ok) + } + } +} + +func TestWriteDocGovError_UsesAdapterStatuses(t *testing.T) { + rec := httptest.NewRecorder() + writeDocGovError(rec, docgov.ErrProposalNotFound) + if rec.Code != http.StatusNotFound { + t.Fatalf("status %d", rec.Code) + } + + rec = httptest.NewRecorder() + writeDocGovError(rec, docgov.ErrUnauthorizedActor) + if rec.Code != http.StatusForbidden { + t.Fatalf("status %d", rec.Code) + } + + rec = httptest.NewRecorder() + writeDocGovError(rec, &docgov.DocumentBusyError{DocID: "d", Reason: "held"}) + if rec.Code != http.StatusConflict { + t.Fatalf("status %d", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["error"] != "document_busy" { + t.Fatalf("body=%v", body) + } +} + +func TestWriteTaskError_DefaultsTo500(t *testing.T) { + rec := httptest.NewRecorder() + writeTaskError(rec, errors.New("boom"), nil) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status %d", rec.Code) + } + + rec = httptest.NewRecorder() + writeTaskError(rec, docgov.ErrDocumentBusy, map[string]string{"ok": "no"}) + if rec.Code != http.StatusConflict { + t.Fatalf("status %d", rec.Code) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index cd5501f6..ddf4d15b 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -23,7 +23,6 @@ import ( "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/squad" - "chat-agent-system/internal/taskrun" ) // HealthResponse 健康检查响应 @@ -128,7 +127,7 @@ func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthRe } } schemaReady := schemaVersion > 0 || (s.cfg != nil && s.cfg.AllowInMemory) - taskStoreReady := taskrun.TablesReady(dbCtx, s.pool) + taskStoreReady := s.rt != nil && s.rt.TaskStoreReady(dbCtx) status := "ok" code := http.StatusOK @@ -430,50 +429,6 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { writeJSONResponse(w, http.StatusOK, taskRes) } -func writeTaskError(w http.ResponseWriter, err error, payload any) { - status := http.StatusInternalServerError - if errors.Is(err, vocabulary.ErrDocumentBusy) { - status = http.StatusConflict - } else if errors.Is(err, vocabulary.ErrDocumentLocked) || errors.Is(err, vocabulary.ErrForceEditNotAllowed) { - status = http.StatusConflict - } else if errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) { - status = http.StatusBadRequest - } - if payload != nil { - writeJSONResponse(w, status, payload) - return - } - writeSafeError(w, nil, status, err.Error()) -} - -func writeDocGovError(w http.ResponseWriter, err error) { - var busy *docgov.DocumentBusyError - if errors.As(err, &busy) { - writeJSONResponse(w, http.StatusConflict, map[string]any{ - "error": "document_busy", - "detail": busy, - }) - return - } - if errors.Is(err, vocabulary.ErrDocumentBusy) { - writeSafeError(w, nil, http.StatusConflict, err.Error()) - return - } - if errors.Is(err, vocabulary.ErrDocumentLocked) { - writeSafeError(w, nil, http.StatusConflict, err.Error()) - return - } - if errors.Is(err, vocabulary.ErrDocumentNotFound) { - writeSafeError(w, nil, http.StatusNotFound, err.Error()) - return - } - if errors.Is(err, vocabulary.ErrLeaseNotHeld) || errors.Is(err, vocabulary.ErrLeaseExpired) { - writeSafeError(w, nil, http.StatusConflict, err.Error()) - return - } - writeSafeError(w, nil, http.StatusBadRequest, err.Error()) -} - func writeJSONResponse(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) @@ -891,8 +846,8 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http. req.RiskAssessment, ) if err != nil { - if errors.Is(err, vocabulary.ErrDocumentNotFound) { - writeSafeError(w, r, http.StatusNotFound, "document not found") + if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound { + writeSafeError(w, r, status, "document not found") return } writeSafeError(w, r, http.StatusBadRequest, "propose modification failed: "+err.Error()) @@ -934,12 +889,15 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason) if err != nil { - if errors.Is(err, vocabulary.ErrProposalNotFound) { - writeSafeError(w, r, http.StatusNotFound, "proposal not found") - return - } - if errors.Is(err, vocabulary.ErrUnauthorizedActor) { - writeSafeError(w, r, http.StatusForbidden, "decide proposal failed: "+err.Error()) + if status, ok := domainHTTPStatus(err); ok { + switch status { + case http.StatusNotFound: + writeSafeError(w, r, status, "proposal not found") + case http.StatusForbidden: + writeSafeError(w, r, status, "decide proposal failed: "+err.Error()) + default: + writeSafeError(w, r, status, err.Error()) + } return } writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error()) diff --git a/internal/server/lineage_access.go b/internal/server/lineage_access.go index ef24fa7b..acd52284 100644 --- a/internal/server/lineage_access.go +++ b/internal/server/lineage_access.go @@ -2,13 +2,11 @@ package server import ( "context" - "errors" "fmt" "net/http" "strconv" "strings" - "chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/projectworkflow" ) @@ -52,8 +50,10 @@ func (s *Server) lineageDocVisible(ctx context.Context, projectID, userID int64, func (s *Server) lineageDocPlacement(ctx context.Context, projectID int64, docID string) (taskKey string, placed bool, ok bool) { if s.gov != nil { doc, err := s.gov.GetDocument(ctx, docID) - if err != nil && !errors.Is(err, vocabulary.ErrDocumentNotFound) { - return "", false, false + if err != nil { + if status, mapped := domainHTTPStatus(err); !(mapped && status == http.StatusNotFound) { + return "", false, false + } } if err == nil && doc != nil { if doc.ProjectID != projectID { diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index e49a4e61..89f30d37 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -192,8 +192,8 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err var ce *docgov.ContentError if errors.As(err, &ce) { status := http.StatusConflict - if ce.Code == vocabulary.ErrCodeDocumentNotFound { - status = http.StatusNotFound + if mapped, ok := domainHTTPStatus(err); ok { + status = mapped } writeJSONResponse(w, status, map[string]any{ "error_code": ce.Code, @@ -208,9 +208,9 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err }) return } - if errors.Is(err, vocabulary.ErrDocumentNotFound) { - writeJSONResponse(w, http.StatusNotFound, map[string]any{ - "error_code": vocabulary.ErrCodeDocumentNotFound, "message": err.Error(), + if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound { + writeJSONResponse(w, status, map[string]any{ + "error_code": docgov.ErrCodeDocumentNotFound, "message": err.Error(), "retryable": false, "needs_admin": false, "correlation_id": corr, }) return diff --git a/internal/server/project_members_handlers.go b/internal/server/project_members_handlers.go index 36079ee7..4efb6cea 100644 --- a/internal/server/project_members_handlers.go +++ b/internal/server/project_members_handlers.go @@ -11,7 +11,6 @@ import ( "chat-agent-system/internal/auth" "chat-agent-system/internal/authz" - "chat-agent-system/internal/docgov" ) var errProjectRoleRequired = errors.New("project_role required when member does not exist") @@ -31,18 +30,10 @@ func writeDutySyncError(w http.ResponseWriter) { } func (s *Server) healProjectDuties(ctx context.Context, projectID int64) error { - if s == nil || s.svc == nil || projectID <= 0 { + if s == nil || s.svc == nil || s.rt == nil || projectID <= 0 { return nil } - pool := s.pool - if pool == nil { - return nil - } - _, _, err := docgov.HealProjectDocuments(ctx, pool, projectID) - if err != nil { - log.Printf("[docduty] heal project %d after member change failed: %v", projectID, err) - } - return err + return s.rt.HealProjectDuties(ctx, projectID) } type projectMemberRequest struct { diff --git a/internal/server/server.go b/internal/server/server.go index 0ad265aa..0366611c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,11 +32,11 @@ import ( "chat-agent-system/internal/taskrun" "chat-agent-system/internal/workflowdef" "chat-agent-system/internal/workspace" - - "github.com/jackc/pgx/v5/pgxpool" ) -// Server 提供基于 HTTP RESTful 与 SSE 的商务与研发参谋接口服务 +// Server 提供基于 HTTP RESTful 与 SSE 的商务与研发参谋接口服务。 +// Pool 与领域服务装配归属 app.Runtime;本结构仅保留 HTTP 适配所需句柄。 +// 残留具体领域字段见 short-term 任务记录(增量收口,本批先去掉 *pgxpool.Pool)。 type Server struct { rt *app.Runtime cfg *config.Config @@ -63,7 +63,6 @@ type Server struct { rev *docrevision.Service ctxMgr *ctxmgr.ContextManager cas *storage.CAS - pool *pgxpool.Pool freeDoc *freedoc.Service kb *kb.Client sysadmin *sysadmin.Service @@ -112,7 +111,6 @@ func NewFromRuntime(rt *app.Runtime) *Server { rev: rt.Rev, ctxMgr: rt.Ctx, cas: rt.CAS, - pool: rt.Pool, freeDoc: rt.FreeDoc, kb: rt.KB, sysadmin: rt.SysAdmin, diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index ffa23470..d0edbdb3 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -410,7 +410,7 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu return nil, nil } doc, err := s.gov.GetDocument(ctx, docID) - if errors.Is(err, vocabulary.ErrDocumentNotFound) { + if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound { return nil, nil } if err != nil { diff --git a/internal/server/vault.go b/internal/server/vault.go index 8e4b643b..b4f2254f 100644 --- a/internal/server/vault.go +++ b/internal/server/vault.go @@ -42,9 +42,9 @@ func (s *Server) initArtifactVault() { if err != nil { return } - var reg artifactvault.Registry - if s.pool != nil { - reg = artifactvault.NewPGRegistry(s.pool) + reg := artifactvault.Registry(nil) + if s.rt != nil { + reg = s.rt.ArtifactVaultRegistry() } else { reg = artifactvault.NewMemoryRegistry() }