refactor(server): drop Server pool; route health/heal/vault via Runtime

Remove production *pgxpool.Pool from Server, centralize domain→HTTP
status mapping, and expose Runtime ports for task-store readiness,
duty heal, and artifact vault registry. Allow app→docduty for heal wiring.
This commit is contained in:
WeiCHEN
2026-09-22 21:44:56 +08:00
parent 364ed7f1a3
commit 9071c272bf
12 changed files with 311 additions and 82 deletions
@@ -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: ""
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。
@@ -80,6 +80,7 @@ imports:
- config
- coordinator
- ctxmgr
- docduty
- docgov
- docrevision
- events
+41
View File
@@ -0,0 +1,41 @@
package app
import (
"context"
"log"
"chat-agent-system/internal/artifactvault"
"chat-agent-system/internal/docduty"
"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 := docduty.HealProjectDocuments(ctx, r.Pool, projectID)
if err != nil {
log.Printf("[docduty] 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)
}
+79
View File
@@ -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())
}
+82
View File
@@ -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)
}
}
+12 -54
View File
@@ -22,7 +22,6 @@ import (
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/taskrun"
)
// HealthResponse 健康检查响应
@@ -127,7 +126,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
@@ -429,50 +428,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, docgov.ErrDocumentBusy) {
status = http.StatusConflict
} else if errors.Is(err, docgov.ErrDocumentLocked) || errors.Is(err, docgov.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, docgov.ErrDocumentBusy) {
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
if errors.Is(err, docgov.ErrDocumentLocked) {
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
if errors.Is(err, docgov.ErrDocumentNotFound) {
writeSafeError(w, nil, http.StatusNotFound, err.Error())
return
}
if errors.Is(err, docgov.ErrLeaseNotHeld) || errors.Is(err, docgov.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)
@@ -890,8 +845,8 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.
req.RiskAssessment,
)
if err != nil {
if errors.Is(err, docgov.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())
@@ -933,12 +888,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, docgov.ErrProposalNotFound) {
writeSafeError(w, r, http.StatusNotFound, "proposal not found")
return
}
if errors.Is(err, docgov.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())
+4 -4
View File
@@ -2,13 +2,11 @@ package server
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/docgov"
"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, docgov.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 {
+4 -4
View File
@@ -191,8 +191,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 == docgov.ErrCodeDocumentNotFound {
status = http.StatusNotFound
if mapped, ok := domainHTTPStatus(err); ok {
status = mapped
}
writeJSONResponse(w, status, map[string]any{
"error_code": ce.Code,
@@ -207,8 +207,8 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err
})
return
}
if errors.Is(err, docgov.ErrDocumentNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{
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,
})
+2 -11
View File
@@ -11,7 +11,6 @@ import (
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/docduty"
)
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 := docduty.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 {
+3 -5
View File
@@ -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,
+1 -1
View File
@@ -409,7 +409,7 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu
return nil, nil
}
doc, err := s.gov.GetDocument(ctx, docID)
if errors.Is(err, docgov.ErrDocumentNotFound) {
if status, ok := domainHTTPStatus(err); ok && status == http.StatusNotFound {
return nil, nil
}
if err != nil {
+3 -3
View File
@@ -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()
}