feat(projectcontext): bizdev 注入项目对话并完成 PG 治理 E2E
新增 ctxmgr 项目对话采集并接入 PM/TE BuildSnapshot;PG 测试统一 TEST_DATABASE_URL 并使用三正式工作流 key。
This commit is contained in:
@@ -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 全链路验收。
|
||||
|
||||
|
||||
@@ -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 检查历史不一致。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user