From d6f5e0e28abf2016ef09889d68231a7f04ce698e Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 15:16:02 +0000 Subject: [PATCH] =?UTF-8?q?feat(projectcontext):=20bizdev=20=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E9=A1=B9=E7=9B=AE=E5=AF=B9=E8=AF=9D=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E6=88=90=20PG=20=E6=B2=BB=E7=90=86=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ctxmgr 项目对话采集并接入 PM/TE BuildSnapshot;PG 测试统一 TEST_DATABASE_URL 并使用三正式工作流 key。 --- .doc/2026-09-12-remediation-gap-matrix.md | 4 +- ...flow-governance-remediation-test-report.md | 40 ++++- internal/bizdev/projectcontext_inputs.go | 39 ++++ internal/bizdev/projectcontext_inputs_test.go | 48 +++++ internal/bizdev/service.go | 7 +- internal/ctxmgr/manager.go | 7 + internal/ctxmgr/project_dialogue.go | 169 ++++++++++++++++++ internal/ctxmgr/project_dialogue_test.go | 60 +++++++ .../server/e2e_workflow_governance_test.go | 55 ++---- internal/server/test_helpers_test.go | 71 ++++++++ .../server/workflow_governance_pg_test.go | 61 ++----- .../workflow_governance_piagent_pg_test.go | 22 +-- 12 files changed, 468 insertions(+), 115 deletions(-) create mode 100644 internal/bizdev/projectcontext_inputs.go create mode 100644 internal/bizdev/projectcontext_inputs_test.go create mode 100644 internal/ctxmgr/project_dialogue.go create mode 100644 internal/ctxmgr/project_dialogue_test.go diff --git a/.doc/2026-09-12-remediation-gap-matrix.md b/.doc/2026-09-12-remediation-gap-matrix.md index 6bab68ae..d81640ea 100644 --- a/.doc/2026-09-12-remediation-gap-matrix.md +++ b/.doc/2026-09-12-remediation-gap-matrix.md @@ -18,9 +18,9 @@ | BUG-10 | 无审计视图 | PASS | `recent_workflow_events` in settings + UI | 已完成 | | BUG-11 | 旧 API 未下线 | PARTIAL | 治理开启时 410 | 保持 + 文档标注 | | BUG-12 | 前端名称硬编码 | PASS | `ProjectWorkflowSwitcher` catalog API | 已完成 | -| MISSING-01 | 上下文快照 0% | PARTIAL | `projectcontext/service.go` 五层源 + 私有对话排除计数 | PM/TE bizdev 传入 Dialogue 待加深 | +| MISSING-01 | 上下文快照 0% | PASS | `ctxmgr.CollectProjectDialogue` + bizdev `collectSnapshotDialogueInputs` → `BuildSnapshot` | 已完成 | | MISSING-02 | 终止 0% | PASS | `GetTerminationPreview` + 取消在途 + 前端 | 已完成 | -| MISSING-04 | E2E 0% | PARTIAL | `e2e_workflow_governance_test.go` 单工作流完成→planning | 三正式工作流 + 真实 PI Agent 待 test 分支补跑 | +| MISSING-04 | E2E 0% | PARTIAL | PG E2E:`test_manager` 完成→planning + `main` 激活/批准/推进(test 库 PASS) | 三流程 + 真实 PI Agent 全链路待补跑 | **结论**: Phase A 全部 PASS;Phase B/C 核心项 PASS,E2E 与上下文快照仍有 PARTIAL。test 分支合并前需 `TEST_DATABASE_URL` + 真实 PI Agent 全链路验收。 diff --git a/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md b/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md index a4d39836..c22111ed 100644 --- a/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md +++ b/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md @@ -7,7 +7,7 @@ ## 二、 测试目标 (Test Objectives) - Phase A:生命周期补测、推进不改文档、禁用文档、对话不切 bizdev、token 一次性、DocumentAccessDecider + HTTP 409 -- Phase B:终止 preview、E2E 完成→planning、上下文快照可复现(含私有对话排除计数) +- Phase B:终止 preview、E2E 完成→planning、上下文快照可复现(bizdev→projectcontext Dialogue 集成) - Phase C:catalog 驱动名称、版本冲突提示、settings 审计事件 ## 三、 测试方案与环境 (Test Plan & Environment) @@ -38,25 +38,47 @@ **判定**: PASS — live 实例存在时 `ExecuteStage` 调用次数为 0 -### 5.3 上下文快照 +### 5.3 上下文快照与 Dialogue 集成 -**命令**: `go test ./internal/projectcontext/... -count=1` +**命令**: +```bash +go test ./internal/ctxmgr/... ./internal/bizdev/... ./internal/projectcontext/... \ + -run 'CollectProjectDialogue|CollectSnapshot|BuildSnapshot' -count=1 -v +``` -**判定**: PASS — 同输入 hash 可复现;`[private]`/`[draft]` 对话计入 `ExcludedPrivateCount` 且不进入来源清单 +**判定**: PASS — bizdev `system_reqs`/`test_manager` 执行路径经 `CollectProjectDialogue` 注入共享对话与里程碑决策;私有/禁用导出对话被排除 -### 5.4 PG E2E(需 TEST_DATABASE_URL) +### 5.4 PG E2E(隔离 test 库) -**命令**: `TEST_DATABASE_URL=... go test ./internal/server/... -run TestE2E_WorkflowGovernance_CompleteAndReplan -count=1 -v` +**环境**: `TEST_DATABASE_URL=postgres://app:***@127.0.0.1:5434/agent_system_test`(configs/.env.test 对齐) -**判定**: SKIP 或 PASS(视 CI 是否配置隔离库) +**命令**: +```bash +TEST_DATABASE_URL='postgres://app:test_secret_db_pass@127.0.0.1:5434/agent_system_test' \ + go test ./internal/server/... \ + -run 'TestE2E_WorkflowGovernance_CompleteAndReplan|TestWorkflowGovernance_PG_ActivateApproveAdvanceChain' \ + -count=1 -v +``` + +**实际输出**: +``` +--- PASS: TestE2E_WorkflowGovernance_CompleteAndReplan (0.25s) +--- PASS: TestWorkflowGovernance_PG_ActivateApproveAdvanceChain (0.26s) +PASS ok chat-agent-system/internal/server 0.514s +``` + +**评价判定**: PASS — 正式工作流 `test_manager` 末阶段完成→`planning`;正式工作流 `main` 激活/DocGov 批准/阶段推进链通过 ## 六、 自动化测试套件汇总 (Test Suite Execution) ``` -go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... -count=1 +go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... \ + ./internal/ctxmgr/... ./internal/bizdev/... -count=1 +TEST_DATABASE_URL=... go test ./internal/server/... -run 'TestE2E_WorkflowGovernance|TestWorkflowGovernance_PG' -count=1 ``` ## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) -- 整改代码路径已用单元测试锁定;PG 全链路需在 `test` 分支部署前用真实 PI Agent 补跑并更新本报告第五节 5.4 为 PASS 实录。 +- Phase A–C 核心路径与 PG 治理 E2E 已通过;`test_manager`/`main` 正式工作流在隔离 test 库验证 PASS。 +- 仍待:三流程 + 真实 PI Agent(MiniMax-M3)全链路实测(`TestWorkflowGovernance_PiAgent_PG_FullChain`)写入本报告后再合 main。 - 建议每周运行 `scripts/fix-completed-instance-lifecycle.sql` dry-run 检查历史不一致。 diff --git a/internal/bizdev/projectcontext_inputs.go b/internal/bizdev/projectcontext_inputs.go new file mode 100644 index 00000000..b8f2bab3 --- /dev/null +++ b/internal/bizdev/projectcontext_inputs.go @@ -0,0 +1,39 @@ +package bizdev + +import ( + "context" + "fmt" + + "chat-agent-system/internal/ctxmgr" +) + +type snapshotDialogueInputs struct { + Dialogue []string + Decisions []string + ExcludedPrivate int + ExcludedNoAccess int +} + +func (s *Service) collectSnapshotDialogueInputs(ctx context.Context, projectID, userID int64) snapshotDialogueInputs { + out := snapshotDialogueInputs{} + if s == nil || projectID <= 0 || s.ctxMgr == nil { + return out + } + + if coll, err := s.getProjectContextForUser(ctx, projectID, userID); err == nil && coll != nil { + for _, d := range coll.MilestoneDecisions { + out.Decisions = append(out.Decisions, fmt.Sprintf("[%s] %s (%s)", d.ActivityType, d.Title, d.UserName)) + } + } + + dialogue, err := s.ctxMgr.CollectProjectDialogue(ctx, projectID, ctxmgr.CollectProjectDialogueOptions{ + Limit: 120, + }) + if err != nil || dialogue == nil { + return out + } + out.Dialogue = dialogue.Lines + out.ExcludedPrivate = dialogue.ExcludedPrivate + out.ExcludedNoAccess = dialogue.ExcludedNoAccess + return out +} diff --git a/internal/bizdev/projectcontext_inputs_test.go b/internal/bizdev/projectcontext_inputs_test.go new file mode 100644 index 00000000..228abafa --- /dev/null +++ b/internal/bizdev/projectcontext_inputs_test.go @@ -0,0 +1,48 @@ +package bizdev + +import ( + "context" + "testing" + + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/projectcontext" + "chat-agent-system/internal/replyexport" +) + +func TestCollectSnapshotDialogueInputs_FeedsBuildSnapshot(t *testing.T) { + ctx := context.Background() + mgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig()) + projectID := int64(601) + meta := map[string]any{"project_id": projectID} + + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "user", "测试经理需要覆盖注册分类路径", "", "", meta) + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "assistant", "我会整理测试验证范围与风险点。", "", "", map[string]any{ + "project_id": projectID, + "export_policy": string(replyexport.PolicyFull), + "content_kind": string(replyexport.KindDocumentText), + }) + _, _ = mgr.RecordActivity(ctx, projectID, 1, "张工", "decision", "确认先做注册分类调研", map[string]any{}, 1, true) + + svc := &Service{ctxMgr: mgr} + inputs := svc.collectSnapshotDialogueInputs(ctx, projectID, 1) + if len(inputs.Dialogue) == 0 { + t.Fatalf("expected dialogue lines, got none") + } + if len(inputs.Decisions) != 1 { + t.Fatalf("expected 1 decision, got %d", len(inputs.Decisions)) + } + + ctxSvc := projectcontext.NewService(nil) + snap, err := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{ + ProjectID: projectID, + TaskKey: "test_manager", + Dialogue: inputs.Dialogue, + Decisions: inputs.Decisions, + }) + if err != nil { + t.Fatalf("BuildSnapshot: %v", err) + } + if snap.SourceCount < 2 { + t.Fatalf("expected dialogue + decision sources, got %d", snap.SourceCount) + } +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 642512e0..d54f777a 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -378,12 +378,17 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun } } } + dialogueInputs := s.collectSnapshotDialogueInputs(ctx, req.ProjectID, req.UserID) ctxSvc := projectcontext.NewService(s.pool) snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{ ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID, ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs, - Knowledge: hits, + Knowledge: hits, Dialogue: dialogueInputs.Dialogue, Decisions: dialogueInputs.Decisions, }) + if snap != nil { + snap.ExcludedPrivateCount += dialogueInputs.ExcludedPrivate + snap.ExcludedNoAccessCount += dialogueInputs.ExcludedNoAccess + } if snap != nil { if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 { materials = append(materials, extra...) diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 26efa618..49065a38 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -226,6 +226,13 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID } m.sessions[sessionID] = sessSum } + if meta != nil { + if pVal, ok := meta["project_id"].(int64); ok && pVal > 0 { + sessSum.ProjectID = pVal + } else if pValFloat, ok := meta["project_id"].(float64); ok && pValFloat > 0 { + sessSum.ProjectID = int64(pValFloat) + } + } sessSum.MessageCount = len(m.messages[sessionID]) sessSum.LastMessage = content sessSum.UpdatedAt = time.Now() diff --git a/internal/ctxmgr/project_dialogue.go b/internal/ctxmgr/project_dialogue.go new file mode 100644 index 00000000..69a0467e --- /dev/null +++ b/internal/ctxmgr/project_dialogue.go @@ -0,0 +1,169 @@ +package ctxmgr + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "chat-agent-system/internal/replyexport" +) + +const defaultProjectDialogueLimit = 120 + +// ProjectDialogueCollectResult 项目级对话采集结果(供上下文快照使用)。 +type ProjectDialogueCollectResult struct { + Lines []string + ExcludedPrivate int + ExcludedNoAccess int + AllowedStageKey string + StageFilteredCount int +} + +// CollectProjectDialogueOptions 采集选项。 +type CollectProjectDialogueOptions struct { + Limit int + AllowedStageKey string // 非空时仅保留无 stage_key 或匹配的对话 +} + +// CollectProjectDialogue 收集进入项目知识边界的共享对话行。 +func (m *ContextManager) CollectProjectDialogue(ctx context.Context, projectID int64, opts CollectProjectDialogueOptions) (*ProjectDialogueCollectResult, error) { + out := &ProjectDialogueCollectResult{AllowedStageKey: opts.AllowedStageKey} + if m == nil || projectID <= 0 { + return out, nil + } + limit := opts.Limit + if limit <= 0 { + limit = defaultProjectDialogueLimit + } + + var msgs []ChatMessageRecord + if m.pool != nil { + rows, err := m.pool.Query(ctx, ` + SELECT cm.id, cm.session_id, COALESCE(cm.user_id, 0), cm.role, cm.content, + cm.intent, cm.branch, cm.metadata, COALESCE(cm.day_key::text, ''), + COALESCE(cm.category, ''), COALESCE(cm.content_kind, ''), + COALESCE(cm.export_policy, 'none'), cm.created_at + FROM chat_messages cm + JOIN chat_sessions cs ON cs.session_id = cm.session_id + WHERE cs.project_id = $1 AND cm.deleted_at IS NULL + ORDER BY cm.created_at ASC + LIMIT $2 + `, projectID, limit) + if err != nil { + return nil, fmt.Errorf("collect project dialogue: %w", err) + } + defer rows.Close() + for rows.Next() { + var rec ChatMessageRecord + var metaJSON []byte + if err := rows.Scan( + &rec.ID, &rec.SessionID, &rec.UserID, &rec.Role, &rec.Content, + &rec.Intent, &rec.Branch, &metaJSON, &rec.DayKey, &rec.Category, + &rec.ContentKind, &rec.ExportPolicy, &rec.CreatedAt, + ); err != nil { + return nil, err + } + if len(metaJSON) > 0 { + _ = json.Unmarshal(metaJSON, &rec.Metadata) + } + msgs = append(msgs, rec) + } + } else { + m.mu.RLock() + for sessionID, sessionMsgs := range m.messages { + sess := m.sessions[sessionID] + if sess != nil && sess.ProjectID != projectID { + continue + } + for _, rec := range sessionMsgs { + if sess == nil || sess.ProjectID == 0 { + if pid := messageProjectID(rec); pid != projectID { + continue + } + } + msgs = append(msgs, rec) + } + } + m.mu.RUnlock() + } + + for _, rec := range msgs { + line, include, excludedPrivate, excludedNoAccess := classifyDialogueLine(rec, opts.AllowedStageKey) + if excludedPrivate { + out.ExcludedPrivate++ + } + if excludedNoAccess { + out.ExcludedNoAccess++ + out.StageFilteredCount++ + } + if !include || line == "" { + continue + } + out.Lines = append(out.Lines, line) + } + return out, nil +} + +func messageProjectID(rec ChatMessageRecord) int64 { + if rec.Metadata == nil { + return 0 + } + if pVal, ok := rec.Metadata["project_id"].(int64); ok { + return pVal + } + if pVal, ok := rec.Metadata["project_id"].(float64); ok { + return int64(pVal) + } + return 0 +} + +func classifyDialogueLine(rec ChatMessageRecord, allowedStageKey string) (line string, include bool, excludedPrivate bool, excludedNoAccess bool) { + content := strings.TrimSpace(rec.Content) + if content == "" { + return "", false, true, false + } + if strings.HasPrefix(content, "[private]") || strings.HasPrefix(content, "[draft]") { + return "", false, true, false + } + if vis, _ := rec.Metadata["visibility"].(string); vis == "private" { + return "", false, true, false + } + + stageKey := MessageStageKey(rec.Metadata) + if allowedStageKey != "" && stageKey != "" && stageKey != allowedStageKey { + return "", false, false, true + } + + switch rec.Role { + case "user": + line = fmt.Sprintf("用户: %s", content) + return line, true, false, false + case "assistant": + switch rec.ContentKind { + case string(replyexport.KindActionCard), string(replyexport.KindStatusUpdate), + string(replyexport.KindErrorNotice), string(replyexport.KindSystemNotice): + return "", false, true, false + } + switch rec.ExportPolicy { + case string(replyexport.PolicyFull), string(replyexport.PolicyCopyOnly): + line = fmt.Sprintf("助手: %s", content) + return line, true, false, false + default: + return "", false, true, false + } + default: + return "", false, true, false + } +} + +// MessageStageKey 从消息 metadata 提取阶段键。 +func MessageStageKey(metadata map[string]any) string { + if metadata == nil { + return "" + } + if sk, ok := metadata["stage_key"].(string); ok { + return sk + } + return "" +} diff --git a/internal/ctxmgr/project_dialogue_test.go b/internal/ctxmgr/project_dialogue_test.go new file mode 100644 index 00000000..85f6518f --- /dev/null +++ b/internal/ctxmgr/project_dialogue_test.go @@ -0,0 +1,60 @@ +package ctxmgr + +import ( + "context" + "testing" + + "chat-agent-system/internal/replyexport" +) + +func TestCollectProjectDialogue_ExcludesPrivateAndIncludesShared(t *testing.T) { + ctx := context.Background() + mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + projectID := int64(501) + meta := map[string]any{"project_id": projectID} + + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "[private] 不应纳入", "", "", meta) + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "项目需要支持多用户协作", "", "", meta) + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "assistant", "好的,我会整理需求要点。", "", "", map[string]any{ + "project_id": projectID, + "export_policy": string(replyexport.PolicyFull), + "content_kind": string(replyexport.KindDocumentText), + }) + + res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{}) + if err != nil { + t.Fatalf("CollectProjectDialogue: %v", err) + } + if len(res.Lines) != 2 { + t.Fatalf("expected 2 shared lines, got %d: %v", len(res.Lines), res.Lines) + } + if res.ExcludedPrivate < 1 { + t.Fatalf("expected excluded private >= 1, got %d", res.ExcludedPrivate) + } +} + +func TestCollectProjectDialogue_StageFilter(t *testing.T) { + ctx := context.Background() + mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + projectID := int64(502) + + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "立项阶段讨论", "", "", map[string]any{ + "project_id": projectID, "stage_key": "biz_intake", + }) + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "研发阶段讨论", "", "", map[string]any{ + "project_id": projectID, "stage_key": "biz_feasibility", + }) + + res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{ + AllowedStageKey: "biz_intake", + }) + if err != nil { + t.Fatalf("CollectProjectDialogue: %v", err) + } + if len(res.Lines) != 1 { + t.Fatalf("expected 1 stage-filtered line, got %d: %v", len(res.Lines), res.Lines) + } + if res.ExcludedNoAccess != 1 { + t.Fatalf("expected 1 excluded by stage, got %d", res.ExcludedNoAccess) + } +} diff --git a/internal/server/e2e_workflow_governance_test.go b/internal/server/e2e_workflow_governance_test.go index 115100ef..5ec70d35 100644 --- a/internal/server/e2e_workflow_governance_test.go +++ b/internal/server/e2e_workflow_governance_test.go @@ -26,36 +26,17 @@ func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) { defer pool.Close() ctx := context.Background() - handler, _ := setupPGGovernanceServer(t, pool) + handler, srv := setupPGGovernanceServer(t, pool) creatorToken := loginTestUser(t, handler, "zhanggong", "123456") creatorID := fetchAuthUserID(t, handler, creatorToken) - wfSvc := workflowdef.NewService(pool, nil) - wfKey := "e2e_gov_" + time.Now().Format("150405.000") - _, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ - Key: wfKey, Name: "E2E Governance WF", - Definition: workflowdef.VersionDefinition{ - SchemaVersion: 1, - Stages: []workflowdef.StageDef{{ - Key: "only_stage", Name: "唯一阶段", Position: 0, - DocTasks: []workflowdef.DocTaskDef{{ - Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, - OutputDocType: "intake", OutputDocTitle: "Intake", - }}, - }}, - }, - }, 1) - if err != nil { - t.Fatalf("create workflow: %v", err) - } - if _, err := wfSvc.PublishVersion(ctx, ver.WorkflowID, ver.Version, 1); err != nil { - t.Fatalf("publish workflow: %v", err) - } + wfKey := workflowdef.OfficialKeyTestManager + seedOfficialWorkflowManifest(t, pool, wfKey) var projectID int64 code := "e2e-gov-" + time.Now().Format("150405.000") - err = pool.QueryRow(ctx, ` + err := pool.QueryRow(ctx, ` INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) VALUES ($1, 'E2E Governance', 'active', $2, 'planning', '', 1, 0) RETURNING id `, code, creatorID).Scan(&projectID) @@ -66,7 +47,7 @@ func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) { _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) }) - reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil) + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, wfKey), nil) withBearerAuth(reqDefault, creatorToken) recDefault := httptest.NewRecorder() handler.ServeHTTP(recDefault, reqDefault) @@ -114,23 +95,25 @@ func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) { var activated projectinstance.ActivateResult _ = json.Unmarshal(recActivate.Body.Bytes(), &activated) - // 批准并推进至完成(复用 governance 测试中的 docgov 路径) - docID := fmt.Sprintf("doc_e2e_%d", projectID) - // 文档批准链在 workflow_governance_pg_test 已覆盖;此处直接标记实例文档 approved 并推进 - _, err = pool.Exec(ctx, ` - UPDATE project_doc_task_instances dti SET status = 'approved', updated_at = now() - FROM project_stage_instances psi - WHERE dti.stage_instance_id = psi.id AND psi.workflow_instance_id = $1 AND dti.task_key = 'intake' - `, activated.Instance.ID) + docID := fmt.Sprintf("doc_proj%d_test_manager", projectID) + docEngine := srv.BizdevService().DocGov() + _, err = docEngine.CreateOrGetDocument(ctx, docID, projectID, "test_manager", "系统验证方案及计划", "system_verification_planning", "", + creatorID, "Author", reviewerID, "Reviewer", approverID, "Approver") if err != nil { - t.Fatalf("mark approved: %v", err) + t.Fatalf("create doc: %v", err) + } + _ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_e2e_te", false) + _, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author") + _, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok") + _, err = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok") + if err != nil { + t.Fatalf("approve doc: %v", err) } - _ = docID advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ - StageKey: "only_stage", ExpectedConfigRevision: activated.Instance.ConfigRevision, + StageKey: "system_verification_planning", ExpectedConfigRevision: activated.Instance.ConfigRevision, }) - reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/only_stage/advance", activated.Instance.ID), bytes.NewReader(advanceBody)) + reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/system_verification_planning/advance", activated.Instance.ID), bytes.NewReader(advanceBody)) withBearerAuth(reqAdvance, creatorToken) recAdvance := httptest.NewRecorder() handler.ServeHTTP(recAdvance, reqAdvance) diff --git a/internal/server/test_helpers_test.go b/internal/server/test_helpers_test.go index f38a5c34..a38bec9a 100644 --- a/internal/server/test_helpers_test.go +++ b/internal/server/test_helpers_test.go @@ -2,13 +2,24 @@ package server_test import ( "bytes" + "context" "encoding/json" + "net" "net/http" "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/auth" "chat-agent-system/internal/config" + "chat-agent-system/internal/db" + "chat-agent-system/internal/workflowdef" ) func loginTestAdmin(t *testing.T, handler http.Handler, cfg *config.Config) string { @@ -67,3 +78,63 @@ func loginTestUser(t *testing.T, handler http.Handler, username, password string func withUserIDAuth(req *http.Request, token string) { req.Header.Set("Authorization", "Bearer "+token) } + +func configFromTestDatabaseURL(t *testing.T) *config.Config { + t.Helper() + dsn := db.SkipIfNoTestDatabaseURL(t) + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse TEST_DATABASE_URL: %v", err) + } + host, portStr, splitErr := net.SplitHostPort(parsed.Host) + if splitErr != nil { + host = parsed.Host + portStr = "5432" + } + port, _ := strconv.Atoi(portStr) + user := parsed.User.Username() + password, _ := parsed.User.Password() + dbName := strings.TrimPrefix(parsed.Path, "/") + return &config.Config{ + Env: "test", + DataDir: t.TempDir(), + AgentBackend: "stub", + AllowInMemory: false, + ContextDbHost: host, + ContextDbPort: port, + ContextDbUser: user, + ContextDbPassword: password, + ContextDbName: dbName, + AdminUsername: envOr("ADMIN_USERNAME", "admin"), + AdminPassword: envOr("ADMIN_PASSWORD", "admin-secret-password-123"), + } +} + +func envOr(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func seedOfficialWorkflowManifest(t *testing.T, pool *pgxpool.Pool, workflowKey string) { + t.Helper() + filename, ok := workflowdef.V2ManifestFiles[workflowKey] + if !ok { + t.Fatalf("unknown official workflow key %q", workflowKey) + } + def, err := workflowdef.LoadManifestDefinition(filename) + if err != nil { + t.Fatalf("load manifest %s: %v", filename, err) + } + wfStore := workflowdef.NewStore(pool) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{ + Key: workflowKey, + Name: workflowdef.OfficialWorkflowName(workflowKey), + Definition: def, + }, 1); err != nil { + t.Fatalf("seed official workflow %s: %v", workflowKey, err) + } +} diff --git a/internal/server/workflow_governance_pg_test.go b/internal/server/workflow_governance_pg_test.go index 51108f83..bad064d4 100644 --- a/internal/server/workflow_governance_pg_test.go +++ b/internal/server/workflow_governance_pg_test.go @@ -14,7 +14,6 @@ import ( "chat-agent-system/internal/auth" "chat-agent-system/internal/bizdev" - "chat-agent-system/internal/config" "chat-agent-system/internal/db" "chat-agent-system/internal/notification" "chat-agent-system/internal/projectinstance" @@ -49,9 +48,10 @@ func openPGPoolWithMigrations(t *testing.T) *pgxpool.Pool { t.Skip("workflow/project instance tables not ready") } if auth.IAMTablesReady(ctx, pool) { - authStore, err := auth.NewPgStore(pool, &config.Config{Env: "dev"}) + testCfg := configFromTestDatabaseURL(t) + authStore, err := auth.NewPgStore(pool, testCfg) if err == nil { - _ = auth.ReactivateDevSeedUsers(ctx, authStore, &config.Config{Env: "dev"}) + _ = auth.ReactivateDevSeedUsers(ctx, authStore, testCfg) } } return pool @@ -82,19 +82,7 @@ func fetchAuthUserID(t *testing.T, handler http.Handler, token string) int64 { func setupPGGovernanceServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) { t.Helper() - cfg := &config.Config{ - Env: "dev", - DataDir: t.TempDir(), - AgentBackend: "stub", - AllowInMemory: false, - ContextDbHost: "127.0.0.1", - ContextDbPort: 5433, - ContextDbUser: "app", - ContextDbPassword: "dev_secret_db_pass", - ContextDbName: "agent_system_dev", - AdminUsername: "admin", - AdminPassword: "admin-secret-password-123", - } + cfg := configFromTestDatabaseURL(t) svc := bizdev.MustNewService(t, cfg) if svc.Pool() == nil { t.Skip("bizdev service not connected to postgres") @@ -113,36 +101,8 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { ctx := context.Background() handler, srv := setupPGGovernanceServer(t, pool) - wfSvc := workflowdef.NewService(pool, nil) - wfKey := "pg_p5_gov_" + time.Now().Format("150405.000") - _, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ - Key: wfKey, Name: "PG Phase5 Governance WF", - Definition: workflowdef.VersionDefinition{ - SchemaVersion: 1, - Stages: []workflowdef.StageDef{ - { - Key: "biz_intake", Name: "商务摸底", Position: 0, - DocTasks: []workflowdef.DocTaskDef{{ - Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, - OutputDocType: "intake", OutputDocTitle: "客户需求采集", - }}, - }, - { - Key: "biz_feasibility", Name: "法规可行性", Position: 1, Optional: true, - DocTasks: []workflowdef.DocTaskDef{{ - Key: "feasibility_reg", Required: false, Blocking: false, DefaultEnabled: true, - OutputDocType: "feasibility", OutputDocTitle: "法规路径评估", - }}, - }, - }, - }, - }, 1) - if err != nil { - t.Fatalf("create workflow def: %v", err) - } - if _, err := wfSvc.PublishVersion(ctx, ver.WorkflowID, ver.Version, 1); err != nil { - t.Fatalf("publish workflow: %v", err) - } + wfKey := workflowdef.OfficialKeyMain + seedOfficialWorkflowManifest(t, pool, wfKey) creatorToken := loginTestUser(t, handler, "zhanggong", "123456") guestToken := loginTestUser(t, handler, "guest", "123456") @@ -151,6 +111,7 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { // 1. 创建项目(PG 直写,与 projectinstance PG 集成测试一致) var projectID int64 code := "pg-p5-" + time.Now().Format("150405.000") + var err error err = pool.QueryRow(ctx, ` INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) VALUES ($1, 'PG Phase5 Governance Test', 'active', $2, 'planning', '', 1, 0) RETURNING id @@ -170,7 +131,7 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) }) // 2. 获取默认规划并保存三权 - reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil) + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, wfKey), nil) withBearerAuth(reqDefault, creatorToken) recDefault := httptest.NewRecorder() handler.ServeHTTP(recDefault, reqDefault) @@ -282,10 +243,10 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { // 7. 创建者推进阶段 advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ - StageKey: "biz_intake", + StageKey: "V0.1_triage", ExpectedConfigRevision: activated.Instance.ConfigRevision, }) - reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/biz_intake/advance", instanceID), bytes.NewReader(advanceBody)) + reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/V0.1_triage/advance", instanceID), bytes.NewReader(advanceBody)) withBearerAuth(reqAdvance, creatorToken) recAdvance := httptest.NewRecorder() handler.ServeHTTP(recAdvance, reqAdvance) @@ -296,7 +257,7 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { if err := json.Unmarshal(recAdvance.Body.Bytes(), &advanceResult); err != nil { t.Fatalf("decode advance: %v", err) } - if advanceResult.NextStageKey != "biz_feasibility" && !advanceResult.InstanceDone { + if advanceResult.NextStageKey != "V0.5_feasibility" && !advanceResult.InstanceDone { t.Fatalf("unexpected advance result: %+v", advanceResult) } diff --git a/internal/server/workflow_governance_piagent_pg_test.go b/internal/server/workflow_governance_piagent_pg_test.go index 279b7d5e..12e6de13 100644 --- a/internal/server/workflow_governance_piagent_pg_test.go +++ b/internal/server/workflow_governance_piagent_pg_test.go @@ -13,7 +13,6 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/bizdev" - "chat-agent-system/internal/config" "chat-agent-system/internal/notification" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" @@ -34,22 +33,11 @@ func piAgentRunnerHealthy() bool { func setupPGPiAgentServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) { t.Helper() - cfg := &config.Config{ - Env: "dev", - DataDir: t.TempDir(), - AgentBackend: "pi-agent", - PiAgentRunnerURL: "http://127.0.0.1:18865", - PiAgentModel: "MiniMax-M3", - PiAgentProvider: "minimax", - AllowInMemory: false, - ContextDbHost: "127.0.0.1", - ContextDbPort: 5433, - ContextDbUser: "app", - ContextDbPassword: "dev_secret_db_pass", - ContextDbName: "agent_system_dev", - AdminUsername: "admin", - AdminPassword: "admin-secret-password-123", - } + cfg := configFromTestDatabaseURL(t) + cfg.AgentBackend = "pi-agent" + cfg.PiAgentRunnerURL = "http://127.0.0.1:18865" + cfg.PiAgentModel = "MiniMax-M3" + cfg.PiAgentProvider = "minimax" svc := bizdev.MustNewService(t, cfg) if svc.Pool() == nil { t.Skip("bizdev service not connected to postgres")