refactor(chat): break chat→app import cycle via owned ports

Invert chat dependencies on chatflow/intentflow/stageflow through
composition-root adapters; replace AssembleRequest coordinator embeds
with local DTOs and tighten the module-boundaries allowlist.
This commit is contained in:
WeiCHEN
2026-09-22 21:25:44 +08:00
parent 364ed7f1a3
commit a2eabe01b1
10 changed files with 377 additions and 33 deletions
@@ -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)
+1 -3
View File
@@ -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
+138
View File
@@ -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,
}
}
+34 -4
View File
@@ -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
+1 -2
View File
@@ -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)
+27 -2
View File
@@ -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)
+5 -6
View File
@@ -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
}
+82
View File
@@ -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
}
+6 -10
View File
@@ -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 {
+12 -6
View File
@@ -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)。