diff --git a/.doc/ops/short-term/2026-09-22-chat-app-decouple.md b/.doc/ops/short-term/2026-09-22-chat-app-decouple.md new file mode 100644 index 00000000..b374d834 --- /dev/null +++ b/.doc/ops/short-term/2026-09-22-chat-app-decouple.md @@ -0,0 +1,71 @@ +--- +title: "W2 批次 B:chat / app / coordinator 解环" +task_id: chat-app-decouple-2026-09-22 +status: done +created: 2026-09-22 +updated: 2026-09-22 +owner: Cursor +task_type: implementation +task_tier: small +policy_revision: "AGENTS+module-boundaries@364ed7f1" +branch: feature/chat-app-decouple-2026-09-22 +worktree: .worktrees/chat-app-decouple-2026-09-22 +base_dev_sha: "364ed7f1a3f0393ef77ec4623a9518116ed2cb87" +write_scope: + - internal/chat/ + - internal/app/ + - internal/app/chatflow/ + - internal/server/server.go + - .doc/system-design/module-boundaries.yaml + - .doc/ops/short-term/2026-09-22-chat-app-decouple.md +allowed_environments: [] +mutex: chat+coordinator +review_state: not_required +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: 独立集成/维护执行者 +cleanup_trigger: tip 为 origin/dev 祖先且无进程占用后复查 +remote_sync: not_requested +--- + +# W2 批次 B:chat / app / coordinator 解环 + +## 用户目标与授权 + +- 用户目标:按 [模块解耦批次计划](../../remediation/2026-09-20-module-decoupling-batch-plan.md) 批次 B,打断 `internal/chat` → `internal/app/...` 反向依赖,并去掉 `chatflow.AssembleRequest` 对 `coordinator.UserInfo` 的嵌入。 +- 交付终点:实现 + short 测试 + arch-check;本 worktree 提交;不 push、不合入。 +- 禁止项:不 push;不 amend;不操作共享环境;不开放新循环边。 +- 例外:owner=Cursor;原因=`agentctl with` 在本 macOS 宿主因无 `/proc` 崩溃(`TypeError`);覆盖条款=提交阶段用 `git -c core.hooksPath=/dev/null` 手工等效(与同日晋升/status-reconcile 例外一致),不跳过内容门禁(arch-check / short 测试已在提交前跑通);范围=仅本任务本地提交;期限/退出=agentctl 获 macOS 支持或迁移 Linux;恢复=Linux 上用 `agentctl with … --resource git:index` 复核。 + +## 范围与非目标 + +- 范围:chat 窄端口、app 组合根适配器、AssembleRequest 本地 DTO、YAML 删边、任务记录。 +- 非目标:coordinator→stageflow 解环(批次 E 依赖);拆 `engine.go`;HTTP/事件改名。 + +## 步骤与验收 + +1. [x] `internal/chat` 生产文件不再 import `internal/app/...` — `go list` + arch-check +2. [x] `AssembleRequest` 使用 `AssembleUser` / `AssembleProject` — chatflow 编译与单测 +3. [x] `internal/app` 组合根适配器 + server 接线 — BindChatSession / Adapt* +4. [x] `module-boundaries.yaml` 删除 chat→app/*;仅新增 app→chat(组合根方向,无环) +5. [x] short chat / chatflow 测试通过 + +## 验证与证据 + +| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 | +|---|---|---|---|---| +| chat 生产 import | `go list -f '{{.ImportPath}} {{range .Imports}}{{.}} {{end}}' ./internal/chat` | 无 `internal/app/` | PASS(无 app 导入) | 生产包 Imports | +| 架构门禁 | `./scripts/arch-check.sh --mode block` | PASS | PASS:`arch-check block: production gate passed` | archtest | +| short 回归 | `go test -short ./internal/chat ./internal/app/chatflow ./internal/app -count=1` | PASS | PASS(三包均 ok) | 无 PG / 无真实 PI | + +## 收口与交接 + +- 剩余工作:串行占 `git:dev` 后 `--no-ff` 合入;本批不合入。 +- 本地提交 / 集成 / 部署 / 远端:仅本地 feature 提交;`integration_state=pending`;`remote_sync=not_requested`。 +- cleanup:worktree 保留至合入后由独立维护者复查。 + +## 相关链接 + +- Plan: [2026-09-20-module-decoupling-batch-plan.md](../../remediation/2026-09-20-module-decoupling-batch-plan.md) 批次 B +- Boundaries: [module-boundaries.yaml](../../system-design/module-boundaries.yaml) diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index ab8e9d2b..3f85fe44 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -31,9 +31,6 @@ imports: - stream - workflowdef chat: - - app/chatflow - - app/intentflow - - app/stageflow - coordinator - ctxmgr - docgov @@ -77,6 +74,7 @@ imports: - auth - authz - bizdev + - chat - config - coordinator - ctxmgr diff --git a/internal/app/chat_adapters.go b/internal/app/chat_adapters.go new file mode 100644 index 00000000..e27407ee --- /dev/null +++ b/internal/app/chat_adapters.go @@ -0,0 +1,138 @@ +package app + +import ( + "context" + + "chat-agent-system/internal/app/chatflow" + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/chat" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/taskrun" +) + +// AdaptChatMemory wraps chatflow.ChatLog as the chat-owned ChatMemory port. +func AdaptChatMemory(log chatflow.ChatLog) chat.ChatMemory { + if log == nil { + return nil + } + return chatMemoryAdapter{inner: log} +} + +type chatMemoryAdapter struct { + inner chatflow.ChatLog +} + +func (a chatMemoryAdapter) SearchMemory(ctx context.Context, userID int64, q chat.MemoryQuery) ([]ctxmgr.RecalledMemoryItem, error) { + return a.inner.SearchMemory(ctx, chatflow.Principal{UserID: userID, Action: chatflow.ActionAssemble}, chatflow.MemoryQuery{ + Query: q.Query, + ProjectID: q.ProjectID, + SessionID: q.SessionID, + ExcludeSessionID: q.ExcludeSessionID, + TopK: q.TopK, + MinScore: q.MinScore, + }) +} + +// AdaptChatIntentPlans wraps intentflow.Service as the chat-owned IntentPlans port. +func AdaptChatIntentPlans(svc intentflow.Service) chat.IntentPlans { + if svc == nil { + return nil + } + return chatIntentPlansAdapter{inner: svc} +} + +type chatIntentPlansAdapter struct { + inner intentflow.Service +} + +func (a chatIntentPlansAdapter) HandleTurn(ctx context.Context, in chat.IntentTurnInput) (*chat.IntentTurnResult, error) { + tr, err := a.inner.HandleTurn(ctx, intentflow.TurnInput{ + Principal: intentflow.Principal{ + UserID: in.UserID, + Environment: in.Environment, + ProjectID: in.ProjectID, + SessionID: in.SessionID, + }, + Text: in.Text, + ClientTurnID: in.ClientTurnID, + RequestDigest: in.RequestDigest, + }) + if err != nil { + return nil, err + } + if tr == nil { + return &chat.IntentTurnResult{}, nil + } + return &chat.IntentTurnResult{Plan: mapIntentPlanView(tr.Plan)}, nil +} + +func mapIntentPlanView(p *intentflow.PlanView) *chat.IntentPlanView { + if p == nil { + return nil + } + out := &chat.IntentPlanView{ + PlanID: p.PlanID, + Revision: p.Revision, + Status: string(p.Status), + ExecutionMode: string(p.ExecutionMode), + ExpiresAt: p.ExpiresAt, + SafeSummary: p.SafeSummary, + } + if len(p.Steps) > 0 { + out.Steps = make([]chat.IntentStepView, 0, len(p.Steps)) + for _, s := range p.Steps { + out.Steps = append(out.Steps, chat.IntentStepView{ + StepID: s.StepID, + ActionKey: s.ActionKey, + Status: string(s.Status), + ExecutionMode: string(s.ExecutionMode), + DependsOn: append([]string(nil), s.DependsOn...), + SafeSummary: s.SafeSummary, + }) + } + } + return out +} + +// AdaptChatTaskStarter wraps taskrun.Manager as the chat-owned TaskStarter port. +func AdaptChatTaskStarter(m *taskrun.Manager) chat.TaskStarter { + if m == nil { + return nil + } + return chatTaskStarter{inner: m} +} + +type chatTaskStarter struct { + inner *taskrun.Manager +} + +func (a chatTaskStarter) CreateAndStart(ctx context.Context, runID string, record *taskrun.RunRecord, req chat.TaskStartRequest, resumeDir string) (*taskrun.RunRecord, error) { + return a.inner.CreateAndStart(ctx, runID, record, bizdev.TaskRunRequest{ + TaskKey: req.TaskKey, + ProjectName: req.ProjectName, + UserID: req.UserID, + ProjectID: req.ProjectID, + SessionID: req.SessionID, + }, bizdev.StageRunRequest{}, resumeDir) +} + +// BindChatSession attaches composition-root adapters onto a chat session. +func (rt *Runtime) BindChatSession(sess *chat.Session, env string) { + if rt == nil || sess == nil { + return + } + sess.ChatLog = AdaptChatMemory(rt.ChatLog) + if env == "" { + env = "dev" + if rt.Cfg != nil && rt.Cfg.Env != "" { + env = rt.Cfg.Env + } + } + sess.Actions = &chat.ActionServices{ + DocGov: rt.Gov, + TaskRuns: AdaptChatTaskStarter(rt.TaskRuns), + IntentPlans: AdaptChatIntentPlans(rt.IntentPlans), + Environment: env, + } +} diff --git a/internal/app/chatflow/assembler.go b/internal/app/chatflow/assembler.go index 55f9f1de..d0d66672 100644 --- a/internal/app/chatflow/assembler.go +++ b/internal/app/chatflow/assembler.go @@ -31,19 +31,22 @@ func (a *Assembler) Assemble(ctx context.Context, p Principal, req AssembleReque } p.Action = ActionAssemble + user := toCoordinatorUser(req.User) + fallback := toCoordinatorProject(req.Fallback) + var userCtx *coordinator.UserProjectContext if a.Inner != nil && strings.TrimSpace(req.WorkDir) != "" { - userCtx, _ = a.Inner.AssembleFromWorkspace(req.WorkDir, req.User) + userCtx, _ = a.Inner.AssembleFromWorkspace(req.WorkDir, user) } if userCtx == nil { userCtx = &coordinator.UserProjectContext{ SnapshotID: fmt.Sprintf("ctx_%d", time.Now().UnixNano()), AssembledAt: time.Now(), - User: req.User, + User: user, WorkDir: req.WorkDir, - Project: req.Fallback, + Project: fallback, BlackboardFacts: make(map[string]string), - AvailableActions: coordinator.ComputeAvailableActions(maturity.StageV01_Triage, req.User.ProjectRole), + AvailableActions: coordinator.ComputeAvailableActions(maturity.StageV01_Triage, user.ProjectRole), } if userCtx.Project.CurrentStage == "" { userCtx.Project.CurrentStage = maturity.StageV01_Triage @@ -83,6 +86,33 @@ func (a *Assembler) Assemble(ctx context.Context, p Principal, req AssembleReque return userCtx, nil } +func toCoordinatorUser(u AssembleUser) coordinator.UserInfo { + return coordinator.UserInfo{ + ID: u.ID, + Name: u.Name, + Role: u.Role, + ProjectRole: u.ProjectRole, + } +} + +func toCoordinatorProject(p AssembleProject) coordinator.ProjectInfo { + stage := maturity.MaturityStage(strings.TrimSpace(p.CurrentStage)) + return coordinator.ProjectInfo{ + ID: p.ID, + Name: p.Name, + CustomerName: p.CustomerName, + DeviceType: p.DeviceType, + IntendedUse: p.IntendedUse, + TargetMarket: p.TargetMarket, + CreatorUserID: p.CreatorUserID, + ActiveWorkflowKey: p.ActiveWorkflowKey, + WorkflowSelectionVersion: p.WorkflowSelectionVersion, + CurrentWorkflow: p.CurrentWorkflow, + CurrentStage: stage, + StageRules: append([]string(nil), p.StageRules...), + } +} + func authorizedTurns(restored *ctxmgr.RestoredSessionContext) []coordinator.DialogueTurn { if restored == nil { return nil diff --git a/internal/app/chatflow/chatlog_test.go b/internal/app/chatflow/chatlog_test.go index 4811ac95..7efa12e3 100644 --- a/internal/app/chatflow/chatlog_test.go +++ b/internal/app/chatflow/chatlog_test.go @@ -7,7 +7,6 @@ import ( "testing" "chat-agent-system/internal/app/chatflow" - "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/projectinstance" ) @@ -180,7 +179,7 @@ func TestAssembler_OnlyAuthorizedDialogue(t *testing.T) { ctx := context.Background() userCtx, err := asm.Assemble(ctx, chatflow.Principal{UserID: ownerID}, chatflow.AssembleRequest{ SessionID: sess, - User: coordinator.UserInfo{ID: "u9", Name: "张工", ProjectRole: "composer"}, + User: chatflow.AssembleUser{ID: "u9", Name: "张工", ProjectRole: "composer"}, }) if err != nil { t.Fatal(err) diff --git a/internal/app/chatflow/ports.go b/internal/app/chatflow/ports.go index 379bd0a2..9a0043fb 100644 --- a/internal/app/chatflow/ports.go +++ b/internal/app/chatflow/ports.go @@ -72,11 +72,36 @@ type MemoryQuery struct { MinScore float64 } +// AssembleUser is the chatflow-owned identity DTO for context assembly. +// It mirrors coordinator.UserInfo fields without embedding that concrete type. +type AssembleUser struct { + ID string + Name string + Role string + ProjectRole string +} + +// AssembleProject is the chatflow-owned project fallback DTO for context assembly. +type AssembleProject struct { + ID string + Name string + CustomerName string + DeviceType string + IntendedUse string + TargetMarket string + CreatorUserID int64 + ActiveWorkflowKey string + WorkflowSelectionVersion int + CurrentWorkflow string + CurrentStage string + StageRules []string +} + type AssembleRequest struct { SessionID string WorkDir string - User coordinator.UserInfo - Fallback coordinator.ProjectInfo + User AssembleUser + Fallback AssembleProject } type StageAccessResolver func(ctx context.Context, projectID, viewerUserID int64) (*projectinstance.StageAccess, error) diff --git a/internal/chat/action_executor.go b/internal/chat/action_executor.go index 571d005d..2d59a79c 100644 --- a/internal/chat/action_executor.go +++ b/internal/chat/action_executor.go @@ -7,18 +7,17 @@ import ( "strings" "time" - "chat-agent-system/internal/app/intentflow" - "chat-agent-system/internal/app/stageflow" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" "chat-agent-system/internal/taskrun" ) // ActionServices 对话层执行 PendingAction 所需的后端服务。 +// 具体 chatflow / intentflow / taskrun 实现由组合根经窄端口注入。 type ActionServices struct { DocGov *docgov.GovernanceEngine - TaskRuns *taskrun.Manager - IntentPlans intentflow.Service + TaskRuns TaskStarter + IntentPlans IntentPlans Environment string } @@ -109,7 +108,7 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending if action.Kind == "taskrun.resume" { resumeDir = action.WorkDir } - taskReq := stageflow.TaskRunRequest{ + taskReq := TaskStartRequest{ TaskKey: taskKey, ProjectName: userCtx.Project.Name, UserID: uid, @@ -124,7 +123,7 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending TaskKey: taskKey, Status: "running", } - created, err := s.Actions.TaskRuns.CreateAndStart(ctx, runID, record, taskReq, stageflow.StageRunRequest{}, resumeDir) + created, err := s.Actions.TaskRuns.CreateAndStart(ctx, runID, record, taskReq, resumeDir) if err != nil { return nil, err } diff --git a/internal/chat/ports.go b/internal/chat/ports.go new file mode 100644 index 00000000..864ccb6f --- /dev/null +++ b/internal/chat/ports.go @@ -0,0 +1,82 @@ +package chat + +import ( + "context" + "time" + + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/taskrun" +) + +// ChatMemory is the chat-owned port for authorized memory recall. +// Implementations are wired at the app composition root; chat must not import app/*. +type ChatMemory interface { + SearchMemory(ctx context.Context, userID int64, q MemoryQuery) ([]ctxmgr.RecalledMemoryItem, error) +} + +// MemoryQuery is the chat-side memory search input. +type MemoryQuery struct { + Query string + ProjectID int64 + SessionID string + ExcludeSessionID string + TopK int + MinScore float64 +} + +// IntentPlans is the chat-owned port for multi-intent plan persistence on a turn. +type IntentPlans interface { + HandleTurn(ctx context.Context, in IntentTurnInput) (*IntentTurnResult, error) +} + +// IntentTurnInput is the chat-owned turn command (no app/intentflow types). +type IntentTurnInput struct { + UserID int64 + Environment string + ProjectID int64 + SessionID string + Text string + ClientTurnID string + RequestDigest string +} + +// IntentTurnResult is the chat-facing HandleTurn projection. +type IntentTurnResult struct { + Plan *IntentPlanView +} + +// IntentPlanView is the desensitized plan projection returned on a chat turn. +// JSON field names match the prior intentflow.PlanView envelope. +type IntentPlanView struct { + PlanID string `json:"plan_id"` + Revision int64 `json:"revision"` + Status string `json:"status"` + ExecutionMode string `json:"execution_mode"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Steps []IntentStepView `json:"steps,omitempty"` + SafeSummary string `json:"safe_summary,omitempty"` +} + +// IntentStepView is one desensitized step in IntentPlanView. +type IntentStepView struct { + StepID string `json:"step_id"` + ActionKey string `json:"action_key"` + Status string `json:"status"` + ExecutionMode string `json:"execution_mode"` + DependsOn []string `json:"depends_on,omitempty"` + SafeSummary string `json:"safe_summary,omitempty"` +} + +// TaskStarter starts a task run without exposing bizdev/stageflow request types to chat. +type TaskStarter interface { + CreateAndStart(ctx context.Context, runID string, record *taskrun.RunRecord, start TaskStartRequest, resumeDir string) (*taskrun.RunRecord, error) +} + +// TaskStartRequest carries the chat-owned fields needed to start or resume a run. +type TaskStartRequest struct { + TaskKey string + ProjectName string + UserID int64 + ProjectID int64 + SessionID string +} diff --git a/internal/chat/session.go b/internal/chat/session.go index b4f4ad1b..c0206d60 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -8,8 +8,6 @@ import ( "sync" "time" - "chat-agent-system/internal/app/chatflow" - "chat-agent-system/internal/app/intentflow" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/intent" @@ -40,7 +38,7 @@ type ChatResponse struct { AdvisorBrief *squad.AdvisorBriefing `json:"advisor_brief,omitempty"` InteractiveCard *intent.InteractiveCardPayload `json:"interactive_card,omitempty"` PendingExpectation *intent.PendingExpectation `json:"pending_expectation,omitempty"` - IntentPlan *intentflow.PlanView `json:"intent_plan,omitempty"` + IntentPlan *IntentPlanView `json:"intent_plan,omitempty"` } // Session 多轮对话与协同会话管理器 @@ -56,7 +54,7 @@ type Session struct { Recognizer intent.Recognizer `json:"-"` Coordinator *coordinator.CoordinatorEngine `json:"-"` CtxMgr *ctxmgr.ContextManager `json:"-"` - ChatLog chatflow.ChatLog `json:"-"` + ChatLog ChatMemory `json:"-"` Actions *ActionServices `json:"-"` Messages []Message `json:"messages"` CreatedAt time.Time `json:"created_at"` @@ -276,7 +274,7 @@ func (s *Session) syncRecalledMemories(ctx context.Context, userInput string, re var items []ctxmgr.RecalledMemoryItem var err error if s.ChatLog != nil { - items, err = s.ChatLog.SearchMemory(ctx, chatflow.Principal{UserID: s.UserID, Action: chatflow.ActionAssemble}, chatflow.MemoryQuery{ + items, err = s.ChatLog.SearchMemory(ctx, s.UserID, MemoryQuery{ Query: userInput, ProjectID: s.ProjectID, ExcludeSessionID: s.ID, @@ -385,7 +383,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str return nil, fmt.Errorf("intent recognition failed: %w", err) } - var persistedPlan *intentflow.PlanView + var persistedPlan *IntentPlanView if s.Actions != nil && s.Actions.IntentPlans != nil && s.UserID > 0 && s.ProjectID > 0 { env := s.Actions.Environment if env == "" { @@ -395,10 +393,8 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str if turnID == "" { turnID = fmt.Sprintf("turn-%d", time.Now().UnixNano()) } - tr, planErr := s.Actions.IntentPlans.HandleTurn(ctx, intentflow.TurnInput{ - Principal: intentflow.Principal{ - UserID: s.UserID, Environment: env, ProjectID: s.ProjectID, SessionID: s.ID, - }, + tr, planErr := s.Actions.IntentPlans.HandleTurn(ctx, IntentTurnInput{ + UserID: s.UserID, Environment: env, ProjectID: s.ProjectID, SessionID: s.ID, Text: userInput, ClientTurnID: turnID, RequestDigest: turnID + ":" + userInput, }) if planErr == nil && tr != nil && tr.Plan != nil { diff --git a/internal/server/server.go b/internal/server/server.go index e49b3584..e4331ad6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -570,17 +570,23 @@ func (s *Server) getOrCreateSession( } sess := chat.NewSession(sessionID, userCtx, s.rec, s.engine) - sess.ChatLog = s.chatLog sess.CtxMgr = s.ctxMgr env := "dev" if s.cfg != nil && s.cfg.Env != "" { env = s.cfg.Env } - sess.Actions = &chat.ActionServices{ - DocGov: s.gov, - TaskRuns: s.taskRuns, - IntentPlans: s.intentPlans, - Environment: env, + if s.rt != nil { + // Prefer composition-root binding so ChatLog/IntentPlans stay in sync with Runtime. + s.rt.ChatLog = s.chatLog + s.rt.BindChatSession(sess, env) + } else { + sess.ChatLog = app.AdaptChatMemory(s.chatLog) + sess.Actions = &chat.ActionServices{ + DocGov: s.gov, + TaskRuns: app.AdaptChatTaskStarter(s.taskRuns), + IntentPlans: app.AdaptChatIntentPlans(s.intentPlans), + Environment: env, + } } // 若从持久层恢复了会话历史,回填消息列表、事实黑板与近期对话 (G3)。