fix(governance): 统一文档访问判定与上下文快照排除计数

补齐 DocumentAccessDecider HTTP 409、projectcontext 私有对话排除统计,并更新整改 gap matrix 与测试报告。
This commit is contained in:
weichen
2026-09-12 15:10:23 +00:00
parent e197e3f69a
commit bb138475e3
28 changed files with 1587 additions and 160 deletions
+35
View File
@@ -0,0 +1,35 @@
# 整改缺口复核矩阵
**日期**: 2026-09-12(更新)
**分支**: dev
**用途**: 审计文档 vs 代码现状;后续 Task 仅引用本矩阵
| ID | 审计描述 | 状态 | 证据 | 计划动作 |
|---|---|---|---|---|
| BUG-01 | 完成后 lifecycle 未更新 | PASS | `stage_remediation_test.go`;`scripts/fix-completed-instance-lifecycle.sql` | 已完成 |
| BUG-02 | 推进强制批文档 | PASS | `AdvanceStage` 无 doc UPDATE;`TestAdvanceStage_DoesNotModifyDocumentApprovalStatus` | 已完成 |
| BUG-03 | 无 skipped_disabled | PASS | memStore `skipped_disabled`;`TestDisabledDocumentPersistence` | 已完成 |
| BUG-04 | 对话走旧 bizdev | PASS | `engine.go` live 门控;`instance_advance_governance_test.go` | 已完成 |
| BUG-05 | 拒绝逻辑分散 | PASS | `document_access.go` + `stage_access_helpers.go`;HTTP 409 `DOC_TASK_DISABLED` | 已完成 |
| BUG-06 | token 明文/重放 | PASS | `ActivateInstanceWithToken` 同事务;`activation_token_test.go` | 已完成 |
| BUG-07 | 完成后下一流程 | PASS | 同 BUG-01 + settings `lifecycle_status=planning` E2E | 已完成 |
| BUG-08 | 测试库隔离 | PARTIAL | `db/testdb.go` Require/Skip;治理 PG 测试仍 Skip 无库时 | CI 必须设 `TEST_DATABASE_URL` |
| BUG-09 | 版本冲突自动重试 | PASS | `StageAdvanceBanner` reload;`advanceStage` 409 提示 | 已完成 |
| 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-02 | 终止 0% | PASS | `GetTerminationPreview` + 取消在途 + 前端 | 已完成 |
| MISSING-04 | E2E 0% | PARTIAL | `e2e_workflow_governance_test.go` 单工作流完成→planning | 三正式工作流 + 真实 PI Agent 待 test 分支补跑 |
**结论**: Phase A 全部 PASS;Phase B/C 核心项 PASS,E2E 与上下文快照仍有 PARTIAL。test 分支合并前需 `TEST_DATABASE_URL` + 真实 PI Agent 全链路验收。
## 发布门禁(test → main)
```
□ go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... 全绿
□ TEST_DATABASE_URL 下 go test ./internal/server/... -run 'TestE2E_WorkflowGovernance|TestWorkflowGovernance' 全绿
□ 真实 PI Agent(MiniMax-M3)三工作流治理 E2E 实录写入 test-reports
□ scripts/fix-completed-instance-lifecycle.sql 在目标库 dry-run 无意外
□ 前端:版本冲突刷新、终止 preview、catalog 名称、审计事件列表人工抽检
```
@@ -0,0 +1,62 @@
# 三工作流治理整改 测试报告
## 一、 测试背景 (Background)
2026-09-12 全面审计发现治理链路存在文档拒绝分散、对话双轨推进、终止 preview 缺失等问题。本报告记录 Phase A–C 整改后的单元/集成验证。
## 二、 测试目标 (Test Objectives)
- Phase A:生命周期补测、推进不改文档、禁用文档、对话不切 bizdev、token 一次性、DocumentAccessDecider + HTTP 409
- Phase B:终止 preview、E2E 完成→planning、上下文快照可复现(含私有对话排除计数)
- Phase C:catalog 驱动名称、版本冲突提示、settings 审计事件
## 三、 测试方案与环境 (Test Plan & Environment)
- OS: Linux
- Go: 项目默认 toolchain
- 单元测试:内存 Store(`internal/projectinstance`)
- PG 集成:`TEST_DATABASE_URL` 设置时运行 `internal/server/e2e_workflow_governance_test.go`
## 四、 预期结果 (Expected Results)
- 末阶段推进后 `lifecycle_status=planning`
- live 实例对话推进零次 `bizdev.ExecuteStage`
- 禁用文档返回 `DOC_TASK_DISABLED`(task run HTTP 409)
- 终止后项目可重新规划
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 单元测试(内存 Store)
**命令**: `go test ./internal/projectinstance/... -count=1`
**判定**: PASS(含 `TestAdvanceStage_CompletesProjectAfterLastStage`、`TestDisabledDocumentPersistence`、`TestActivationToken_OneTimeUse`、`TestTerminateInstance_UpdatesProjectLifecycle`、`TestCheckDocTaskAccess_DisabledDocumentRejected`)
### 5.2 Coordinator 治理测试
**命令**: `go test ./internal/coordinator/... -run TestDialogueAdvanceStage_LiveInstanceDoesNotUseBizdev -count=1`
**判定**: PASS — live 实例存在时 `ExecuteStage` 调用次数为 0
### 5.3 上下文快照
**命令**: `go test ./internal/projectcontext/... -count=1`
**判定**: PASS — 同输入 hash 可复现;`[private]`/`[draft]` 对话计入 `ExcludedPrivateCount` 且不进入来源清单
### 5.4 PG E2E(需 TEST_DATABASE_URL)
**命令**: `TEST_DATABASE_URL=... go test ./internal/server/... -run TestE2E_WorkflowGovernance_CompleteAndReplan -count=1 -v`
**判定**: SKIP 或 PASS(视 CI 是否配置隔离库)
## 六、 自动化测试套件汇总 (Test Suite Execution)
```
go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... -count=1
```
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
- 整改代码路径已用单元测试锁定;PG 全链路需在 `test` 分支部署前用真实 PI Agent 补跑并更新本报告第五节 5.4 为 PASS 实录。
- 建议每周运行 `scripts/fix-completed-instance-lifecycle.sql` dry-run 检查历史不一致。
+36 -3
View File
@@ -680,11 +680,28 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u
}
func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
if e.instSvc != nil && e.instSvc.Available() && userCtx != nil && userCtx.Project.CreatorUserID > 0 {
if e.instSvc != nil && e.instSvc.Available() && userCtx != nil && userCtx.Project.ID != "" {
projectID, parseErr := strconv.ParseInt(userCtx.Project.ID, 10, 64)
userID, userErr := strconv.ParseInt(userCtx.User.ID, 10, 64)
if parseErr == nil && userErr == nil && projectID > 0 && userID == userCtx.Project.CreatorUserID {
if res, handled, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID); handled {
if parseErr == nil && userErr == nil && projectID > 0 && userID > 0 {
isCreator := userCtx.Project.CreatorUserID > 0 && userID == userCtx.Project.CreatorUserID
settings, settingsErr := e.instSvc.GetSettings(ctx, projectID, userID, isCreator)
hasLiveGovernance := settingsErr == nil && settings != nil && settings.LiveInstance != nil &&
settings.LiveInstance.Status != projectinstance.InstanceCompleted &&
settings.LiveInstance.Status != "terminated"
if hasLiveGovernance {
if !isCreator {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: projectinstance.ErrForbiddenNotCreator.Error(),
NaturalReply: "只有项目创建者可以确认推进工作流阶段。",
UpdatedContext: userCtx,
}, projectinstance.ErrForbiddenNotCreator
}
res, _, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID)
if advErr != nil {
return &CoordinationResult{
Status: StatusRejected,
@@ -697,6 +714,22 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent
}
return res, nil
}
if isCreator {
if res, handled, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID); handled {
if advErr != nil {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: advErr.Error(),
NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr),
UpdatedContext: userCtx,
}, advErr
}
return res, nil
}
}
}
}
@@ -0,0 +1,83 @@
package coordinator
import (
"context"
"testing"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/storage"
)
type countingBizdev struct {
stageCalls int
}
func (c *countingBizdev) ListTasks() []bizdev.TaskMeta { return nil }
func (c *countingBizdev) ListStages() []bizdev.StageMeta { return nil }
func (c *countingBizdev) ExecuteTask(_ context.Context, _ bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error) {
return &bizdev.TaskRunResult{}, nil
}
func (c *countingBizdev) ExecuteStage(_ context.Context, _ bizdev.StageRunRequest) (*bizdev.StageRunResult, error) {
c.stageCalls++
return &bizdev.StageRunResult{StageTitle: "legacy"}, nil
}
func (c *countingBizdev) InspectWorkspace(_ context.Context, _ string) (*squad.WorkspaceState, error) {
return nil, nil
}
func (c *countingBizdev) ResumeOrModifyWorkspace(_ context.Context, _ squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) {
return nil, nil
}
func (c *countingBizdev) ContextManager() *ctxmgr.ContextManager { return nil }
func (c *countingBizdev) CAS() *storage.CAS { return nil }
func (c *countingBizdev) DocGov() *docgov.GovernanceEngine { return nil }
func (c *countingBizdev) KB() *kb.Client { return nil }
func TestDialogueAdvanceStage_LiveInstanceDoesNotUseBizdev(t *testing.T) {
store := projectinstance.NewMemoryStore()
_ = projectinstance.SeedProjectMeta(store, 55, projectinstance.LifecyclePlanning, "", 1, 0)
instSvc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, &projectinstance.StubDocChecker{States: map[string]string{"intake": "approved"}})
ctx := context.Background()
_, _ = instSvc.SaveWorkflowPlan(ctx, 55, 1, true, projectinstance.SavePlanInput{
Plan: projectinstance.ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
preview, _ := instSvc.ActivationPreview(ctx, 55, 1, true)
_, _ = instSvc.Activate(ctx, 55, 1, true, projectinstance.ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
counter := &countingBizdev{}
engine := NewCoordinatorEngine(counter)
engine.SetInstanceService(instSvc)
userCtx := &UserProjectContext{
Project: ProjectInfo{ID: "55", CreatorUserID: 1, CurrentStage: bizdev.StageV01_Triage},
User: UserInfo{ID: "1", ProjectRole: "owner"},
}
recRes := &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentAdvanceStage,
FilledSlots: map[string]string{
intent.SlotTargetStage: string(bizdev.StageV05_Feasibility),
},
},
}
res, err := engine.ProcessIntent(ctx, recRes, userCtx)
if err != nil {
t.Fatalf("ProcessIntent: %v", err)
}
if res.Status != StatusAccepted {
t.Fatalf("expected accepted via instance service, got %s (%s)", res.Status, res.RejectReason)
}
if counter.stageCalls != 0 {
t.Fatalf("expected zero bizdev ExecuteStage calls with live instance, got %d", counter.stageCalls)
}
}
+31 -19
View File
@@ -38,13 +38,15 @@ type Source struct {
// Snapshot 不可变项目上下文快照。
type Snapshot struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
TaskKey string `json:"task_key"`
CutoffAt time.Time `json:"cutoff_at"`
AggregateHash string `json:"aggregate_hash"`
SourceCount int `json:"source_count"`
Sources []Source `json:"sources"`
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
TaskKey string `json:"task_key"`
CutoffAt time.Time `json:"cutoff_at"`
AggregateHash string `json:"aggregate_hash"`
SourceCount int `json:"source_count"`
ExcludedPrivateCount int `json:"excluded_private_count"`
ExcludedNoAccessCount int `json:"excluded_no_access_count"`
Sources []Source `json:"sources"`
}
// BuildInput 构建快照所需输入。
@@ -77,17 +79,19 @@ func (s *Service) Available() bool {
// BuildSnapshot 建立来源清单并按任务检索相关片段(不 dump 全量原文)。
func (s *Service) BuildSnapshot(ctx context.Context, in BuildInput) (*Snapshot, error) {
cutoff := time.Now()
sources := enumerateSources(in)
sources, excludedPrivate, excludedNoAccess := enumerateSources(in)
sources = selectForTask(in.TaskKey, sources)
aggHash := aggregateHash(sources)
snap := &Snapshot{
ProjectID: in.ProjectID,
TaskKey: in.TaskKey,
CutoffAt: cutoff,
AggregateHash: aggHash,
SourceCount: len(sources),
Sources: sources,
ProjectID: in.ProjectID,
TaskKey: in.TaskKey,
CutoffAt: cutoff,
AggregateHash: aggHash,
SourceCount: len(sources),
ExcludedPrivateCount: excludedPrivate,
ExcludedNoAccessCount: excludedNoAccess,
Sources: sources,
}
if !s.Available() || in.ProjectID <= 0 {
return snap, nil
@@ -137,8 +141,10 @@ func MaterialsForWorkspace(snap *Snapshot, maxItems int) []squad.MaterialItem {
return out
}
func enumerateSources(in BuildInput) []Source {
func enumerateSources(in BuildInput) ([]Source, int, int) {
out := make([]Source, 0)
excludedPrivate := 0
excludedNoAccess := 0
for k, v := range in.Facts {
if strings.TrimSpace(v) == "" {
continue
@@ -177,12 +183,18 @@ func enumerateSources(in BuildInput) []Source {
})
}
for i, line := range in.Dialogue {
if strings.TrimSpace(line) == "" {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
excludedPrivate++
continue
}
if strings.HasPrefix(trimmed, "[private]") || strings.HasPrefix(trimmed, "[draft]") {
excludedPrivate++
continue
}
out = append(out, Source{
Type: "dialogue", ID: fmt.Sprintf("turn_%d", i), ContentHash: hashText(line),
AuthorityLevel: AuthorityDialogue, PermissionScope: "project_shared", Snippet: truncate(line, 2000),
Type: "dialogue", ID: fmt.Sprintf("turn_%d", i), ContentHash: hashText(trimmed),
AuthorityLevel: AuthorityDialogue, PermissionScope: "project_shared", Snippet: truncate(trimmed, 2000),
})
}
for i, d := range in.Decisions {
@@ -204,7 +216,7 @@ func enumerateSources(in BuildInput) []Source {
sort.Slice(out, func(i, j int) bool {
return authorityRank(out[i].AuthorityLevel) < authorityRank(out[j].AuthorityLevel)
})
return out
return out, excludedPrivate, excludedNoAccess
}
func selectForTask(taskKey string, sources []Source) []Source {
+62
View File
@@ -0,0 +1,62 @@
package projectcontext
import (
"testing"
"chat-agent-system/internal/squad"
)
func TestBuildSnapshot_ConsistentForSameInput(t *testing.T) {
svc := NewService(nil)
in := BuildInput{
ProjectID: 1,
TaskKey: "system_reqs",
UserID: 1,
Facts: map[string]string{"product_type": "超声设备"},
Materials: []squad.MaterialItem{{Filename: "note.md", Content: []byte("材料说明")}},
Dialogue: []string{"项目需要 CE 认证"},
Decisions: []string{"目标市场:欧盟"},
}
s1, err := svc.BuildSnapshot(t.Context(), in)
if err != nil {
t.Fatalf("BuildSnapshot: %v", err)
}
s2, err := svc.BuildSnapshot(t.Context(), in)
if err != nil {
t.Fatalf("BuildSnapshot second: %v", err)
}
if s1.AggregateHash != s2.AggregateHash {
t.Fatalf("expected reproducible hash, got %s vs %s", s1.AggregateHash, s2.AggregateHash)
}
}
func TestBuildSnapshot_ExcludesEmptyPrivateDialogue(t *testing.T) {
svc := NewService(nil)
snap, err := svc.BuildSnapshot(t.Context(), BuildInput{
ProjectID: 2,
TaskKey: "test_manager",
Dialogue: []string{"", " ", "[private] 不应纳入", "可共享的项目讨论"},
})
if err != nil {
t.Fatalf("BuildSnapshot: %v", err)
}
if snap.SourceCount != 1 {
t.Fatalf("expected 1 shared dialogue source, got %d", snap.SourceCount)
}
if snap.ExcludedPrivateCount < 3 {
t.Fatalf("expected excluded private count >= 3, got %d", snap.ExcludedPrivateCount)
}
}
func TestMaterialsForWorkspace_RespectsAuthority(t *testing.T) {
snap := &Snapshot{
Sources: []Source{
{Type: "dialogue", ID: "turn_1", Referenced: false, Snippet: "ignored"},
{Type: AuthorityApprovedDoc, ID: "srs", Referenced: true, Snippet: "SRS body"},
},
}
items := MaterialsForWorkspace(snap, 5)
if len(items) != 1 {
t.Fatalf("expected 1 referenced material, got %d", len(items))
}
}
@@ -0,0 +1,80 @@
package projectinstance
import (
"context"
"errors"
"sync"
"testing"
)
func TestActivationToken_OneTimeUse(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 200, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
_, _ = svc.SaveWorkflowPlan(ctx, 200, 1, true, SavePlanInput{
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
preview, err := svc.ActivationPreview(ctx, 200, 1, true)
if err != nil {
t.Fatalf("ActivationPreview: %v", err)
}
_, err = svc.Activate(ctx, 200, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken,
ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
if err != nil {
t.Fatalf("first activate: %v", err)
}
_, err = svc.Activate(ctx, 200, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken,
ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
if !errors.Is(err, ErrTokenInvalid) && !errors.Is(err, ErrPlanFrozen) {
t.Fatalf("expected ErrTokenInvalid or ErrPlanFrozen on reuse, got %v", err)
}
}
func TestActivationToken_ConcurrentActivateOnlyOneSucceeds(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 201, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
_, _ = svc.SaveWorkflowPlan(ctx, 201, 1, true, SavePlanInput{
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
preview, err := svc.ActivationPreview(ctx, 201, 1, true)
if err != nil {
t.Fatalf("ActivationPreview: %v", err)
}
var wg sync.WaitGroup
successes := 0
var mu sync.Mutex
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, actErr := svc.Activate(ctx, 201, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken,
ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
if actErr == nil {
mu.Lock()
successes++
mu.Unlock()
}
}()
}
wg.Wait()
if successes != 1 {
t.Fatalf("expected exactly 1 successful activation, got %d", successes)
}
}
+132
View File
@@ -0,0 +1,132 @@
package projectinstance
import (
"context"
)
const (
DocAccessActionView = "view"
DocAccessActionEdit = "edit"
DocAccessActionSubmit = "submit"
DocAccessActionReview = "review"
DocAccessActionApprove = "approve"
DocAccessActionRun = "run"
AccessCodeDocTaskDisabled = "DOC_TASK_DISABLED"
AccessCodeNotCurrentStage = "DOC_TASK_NOT_IN_ACTIVE_STAGE"
AccessCodeForbiddenDuty = "DOC_DUTY_FORBIDDEN"
AccessCodeInstanceClosed = "WORKFLOW_INSTANCE_CLOSED"
)
// DocAccessDecision 统一文档任务访问判定结果。
type DocAccessDecision struct {
Allowed bool `json:"allowed"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// CheckDocTaskAccess 对 live 治理实例下的文档任务做 fail-closed 判定。
// 无 live 实例时不限制(legacy 兼容)。
func (s *Service) CheckDocTaskAccess(
ctx context.Context,
projectID int64,
userID int64,
taskKey string,
action string,
isCreator bool,
) DocAccessDecision {
allow := DocAccessDecision{Allowed: true}
if s == nil || s.store == nil || projectID <= 0 || userID <= 0 || taskKey == "" {
return allow
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil || live == nil {
return allow
}
if live.Status == InstanceCompleted || live.Status == "terminated" {
return DocAccessDecision{
Allowed: false,
Code: AccessCodeInstanceClosed,
Message: "工作流实例已结束",
}
}
detail, err := s.store.GetInstanceByID(ctx, live.ID)
if err != nil || detail == nil {
return DocAccessDecision{Allowed: false, Code: AccessCodeInstanceClosed, Message: "工作流实例不可用"}
}
var docInst *DocTaskInstance
var stageKey string
for _, st := range detail.Stages {
for i := range st.DocTasks {
if st.DocTasks[i].TaskKey == taskKey {
copyDoc := st.DocTasks[i]
docInst = &copyDoc
stageKey = st.StageKey
break
}
}
if docInst != nil {
break
}
}
if docInst == nil {
return allow
}
if docInst.Status == "skipped_disabled" {
return DocAccessDecision{
Allowed: false,
Code: AccessCodeDocTaskDisabled,
Message: "该文档已被创建者禁用",
}
}
if stageKey != "" && stageKey != detail.ActiveStageKey {
if action != DocAccessActionView {
return DocAccessDecision{
Allowed: false,
Code: AccessCodeNotCurrentStage,
Message: "文档不在当前活动阶段",
}
}
}
if action == DocAccessActionView {
return allow
}
planTask := findDocTaskPlan(live.ConfigSnapshot, taskKey)
if planTask == nil || !planTask.Enabled {
return DocAccessDecision{
Allowed: false,
Code: AccessCodeDocTaskDisabled,
Message: "该文档已被创建者禁用",
}
}
if isCreator {
return allow
}
if docTaskDutyForUser(*planTask, userID) {
return allow
}
return DocAccessDecision{
Allowed: false,
Code: AccessCodeForbiddenDuty,
Message: "当前用户不是该文档职责人",
}
}
func findDocTaskPlan(plan WorkflowPlan, taskKey string) *DocTaskPlan {
for _, st := range plan.Stages {
for i := range st.DocTasks {
if st.DocTasks[i].TaskKey == taskKey {
return &st.DocTasks[i]
}
}
}
return nil
}
@@ -0,0 +1,58 @@
package projectinstance
import (
"context"
"testing"
"chat-agent-system/internal/workflowdef"
)
func TestCheckDocTaskAccess_DisabledDocumentRejected(t *testing.T) {
ctx := context.Background()
store := NewMemoryStore()
_ = SeedProjectMeta(store, 400, LifecyclePlanning, "", 1, 0)
resolver := disabledDocResolver()
svc := NewService(store, resolver, nil, nil)
version := resolver.versions[workflowdef.OfficialKeyMain]
plan := buildDefaultPlan(version)
for i := range plan.Stages[0].DocTasks {
if plan.Stages[0].DocTasks[i].TaskKey == "research" {
plan.Stages[0].DocTasks[i].Enabled = false
continue
}
plan.Stages[0].DocTasks[i].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}
}
_, _ = svc.SaveWorkflowPlan(ctx, 400, 1, true, SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1})
preview, _ := svc.ActivationPreview(ctx, 400, 1, true)
_, _ = svc.Activate(ctx, 400, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
decision := svc.CheckDocTaskAccess(ctx, 400, 1, "research", DocAccessActionRun, true)
if decision.Allowed || decision.Code != AccessCodeDocTaskDisabled {
t.Fatalf("expected DOC_TASK_DISABLED, got %+v", decision)
}
}
func TestCheckDocTaskAccess_NonDutyUserRejected(t *testing.T) {
ctx := context.Background()
store := NewMemoryStore()
_ = SeedProjectMeta(store, 401, LifecyclePlanning, "", 1, 0)
svc := NewService(store, TestResolver(), nil, nil)
_, _ = svc.SaveWorkflowPlan(ctx, 401, 1, true, SavePlanInput{
Plan: ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
preview, _ := svc.ActivationPreview(ctx, 401, 1, true)
_, _ = svc.Activate(ctx, 401, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
decision := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionRun, false)
if decision.Allowed || decision.Code != AccessCodeForbiddenDuty {
t.Fatalf("expected DOC_DUTY_FORBIDDEN, got %+v", decision)
}
}
+1 -13
View File
@@ -253,18 +253,6 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i
return nil, fmt.Errorf("%w: no saved plan", ErrInvalidPlan)
}
consumed, err := s.store.ConsumeActivationToken(ctx, in.ConfirmationToken, projectID, planInst.PlanHash)
if err != nil {
return nil, err
}
currentHash, err := computePlanHash(*consumed)
if err != nil {
return nil, err
}
if currentHash != planInst.PlanHash {
return nil, ErrPlanChangedReconfirm
}
version, err := s.resolvePublishedVersion(ctx, planInst.ConfigSnapshot)
if err != nil {
return nil, err
@@ -274,7 +262,7 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i
return nil, ErrValidationFailed
}
activated, err := s.store.ActivateInstance(ctx, projectID, planInst, version, selVer, userID)
activated, err := s.store.ActivateInstanceWithToken(ctx, in.ConfirmationToken, projectID, planInst, version, selVer, userID, planInst.PlanHash)
if err != nil {
return nil, err
}
-70
View File
@@ -8,76 +8,6 @@ import (
"chat-agent-system/internal/workflowdef"
)
type stubVersionResolver struct {
versions map[string]*workflowdef.Version
defs map[string]*workflowdef.Definition
}
func (s *stubVersionResolver) GetDefinitionByKey(_ context.Context, key string) (*workflowdef.Definition, error) {
d, ok := s.defs[key]
if !ok {
return nil, workflowdef.ErrNotFound
}
return d, nil
}
func (s *stubVersionResolver) GetVersion(_ context.Context, workflowID int64, version int) (*workflowdef.Version, error) {
for _, v := range s.versions {
if v.WorkflowID == workflowID && v.Version == version {
copyV := *v
return &copyV, nil
}
}
return nil, workflowdef.ErrNotFound
}
func (s *stubVersionResolver) ListDefinitions(_ context.Context) ([]workflowdef.Definition, error) {
out := make([]workflowdef.Definition, 0, len(s.defs))
for _, d := range s.defs {
out = append(out, *d)
}
return out, nil
}
func testResolver() *stubVersionResolver {
key := workflowdef.OfficialKeyMain
ver := &workflowdef.Version{
ID: 100, WorkflowID: 10, Version: 1, Status: workflowdef.StatusPublished,
WorkflowKey: key, WorkflowName: workflowdef.OfficialWorkflowName(key),
Definition: workflowdef.VersionDefinition{
SchemaVersion: 2,
WorkflowKey: key,
Stages: []workflowdef.StageDef{{
Key: "V0.1_triage", Name: "V0.1 需求摸底", Optional: false, Position: 10,
DocTasks: []workflowdef.DocTaskDef{{
Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true,
OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表",
}},
}},
},
}
return &stubVersionResolver{
defs: map[string]*workflowdef.Definition{
key: {ID: 10, Key: key, Name: workflowdef.OfficialWorkflowName(key), LatestPublishedVersion: 1},
},
versions: map[string]*workflowdef.Version{key: ver},
}
}
func validPlan() WorkflowPlan {
return WorkflowPlan{
WorkflowDefinitionKey: workflowdef.OfficialKeyMain,
WorkflowVersion: 1,
Stages: []StagePlan{{
StageKey: "V0.1_triage", Enabled: true,
DocTasks: []DocTaskPlan{{
TaskKey: "intake", Enabled: true,
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
}},
}},
}
}
func TestService_PlanPreviewActivate(t *testing.T) {
ctx := context.Background()
store := newMemStore()
@@ -0,0 +1,209 @@
package projectinstance
import (
"context"
"testing"
"chat-agent-system/internal/workflowdef"
)
func TestAdvanceStage_CompletesProjectAfterLastStage(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 100, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
_, err := svc.SaveWorkflowPlan(ctx, 100, 1, true, SavePlanInput{
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
if err != nil {
t.Fatalf("SaveWorkflowPlan: %v", err)
}
preview, err := svc.ActivationPreview(ctx, 100, 1, true)
if err != nil {
t.Fatalf("ActivationPreview: %v", err)
}
result, err := svc.Activate(ctx, 100, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken,
ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
if err != nil {
t.Fatalf("Activate: %v", err)
}
advanced, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision,
})
if err != nil {
t.Fatalf("AdvanceStage: %v", err)
}
if !advanced.InstanceDone {
t.Fatal("expected instance done after last stage")
}
if advanced.Instance.Status != InstanceCompleted {
t.Fatalf("expected completed instance, got %q", advanced.Instance.Status)
}
lifecycle, activeKey, _, _, err := store.GetProjectMeta(ctx, 100)
if err != nil {
t.Fatalf("GetProjectMeta: %v", err)
}
if lifecycle != LifecyclePlanning {
t.Fatalf("expected lifecycle planning, got %q", lifecycle)
}
if activeKey != "" {
t.Fatalf("expected empty active workflow key, got %q", activeKey)
}
}
func TestAdvanceStage_DoesNotChangeProjectOnNonLastStage(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 101, LifecyclePlanning, "", 1, 0)
resolver := testResolverMultiStage()
svc := NewService(store, resolver, nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
plan := validPlan()
plan.Stages = append(plan.Stages, StagePlan{
StageKey: "V0.5_feasibility", Enabled: true,
DocTasks: []DocTaskPlan{{
TaskKey: "feasibility_reg", Enabled: true,
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
}},
})
_, err := svc.SaveWorkflowPlan(ctx, 101, 1, true, SavePlanInput{
Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
if err != nil {
t.Fatalf("SaveWorkflowPlan: %v", err)
}
preview, _ := svc.ActivationPreview(ctx, 101, 1, true)
result, _ := svc.Activate(ctx, 101, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
_, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision,
})
if err != nil {
t.Fatalf("AdvanceStage: %v", err)
}
lifecycle, _, _, _, err := store.GetProjectMeta(ctx, 101)
if err != nil {
t.Fatalf("GetProjectMeta: %v", err)
}
if lifecycle != LifecycleActive {
t.Fatalf("expected lifecycle active on non-last stage, got %q", lifecycle)
}
}
func TestAdvanceStage_DoesNotModifyDocumentApprovalStatus(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 102, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
_, _ = svc.SaveWorkflowPlan(ctx, 102, 1, true, SavePlanInput{
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
preview, _ := svc.ActivationPreview(ctx, 102, 1, true)
result, _ := svc.Activate(ctx, 102, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
before, err := store.GetInstanceByID(ctx, result.Instance.ID)
if err != nil {
t.Fatalf("GetInstanceByID: %v", err)
}
beforeStatus := before.Stages[0].DocTasks[0].Status
_, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision,
})
if err != nil {
t.Fatalf("AdvanceStage: %v", err)
}
after, err := store.GetInstanceByID(ctx, result.Instance.ID)
if err != nil {
t.Fatalf("GetInstanceByID after: %v", err)
}
if after.Stages[0].DocTasks[0].Status != beforeStatus {
t.Fatalf("doc status changed from %q to %q after advance", beforeStatus, after.Stages[0].DocTasks[0].Status)
}
}
func TestDisabledDocumentPersistence(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 103, LifecyclePlanning, "", 1, 0)
resolver := disabledDocResolver()
svc := NewService(store, resolver, nil, nil)
version := resolver.versions[workflowdef.OfficialKeyMain]
plan := buildDefaultPlan(version)
for i := range plan.Stages[0].DocTasks {
if plan.Stages[0].DocTasks[i].TaskKey == "research" {
plan.Stages[0].DocTasks[i].Enabled = false
continue
}
plan.Stages[0].DocTasks[i].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}
}
_, err := svc.SaveWorkflowPlan(ctx, 103, 1, true, SavePlanInput{
Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
if err != nil {
t.Fatalf("SaveWorkflowPlan: %v", err)
}
preview, _ := svc.ActivationPreview(ctx, 103, 1, true)
result, err := svc.Activate(ctx, 103, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
if err != nil {
t.Fatalf("Activate: %v", err)
}
detail, err := store.GetInstanceByID(ctx, result.Instance.ID)
if err != nil {
t.Fatalf("GetInstanceByID: %v", err)
}
foundDisabled := false
for _, st := range detail.Stages {
for _, dt := range st.DocTasks {
if dt.TaskKey == "research" {
if dt.Status != "skipped_disabled" {
t.Fatalf("expected skipped_disabled, got %q", dt.Status)
}
foundDisabled = true
}
}
}
if !foundDisabled {
t.Fatal("disabled doc task instance not persisted")
}
decision := svc.CheckDocTaskAccess(ctx, 103, 1, "research", DocAccessActionRun, true)
if decision.Allowed || decision.Code != AccessCodeDocTaskDisabled {
t.Fatalf("expected DOC_TASK_DISABLED, got %+v", decision)
}
}
func testResolverMultiStage() *stubVersionResolver {
r := testResolver()
r.versions[workflowdef.OfficialKeyMain].Definition.Stages = append(
r.versions[workflowdef.OfficialKeyMain].Definition.Stages,
workflowdef.StageDef{
Key: "V0.5_feasibility", Name: "V0.5 可行性", Optional: false, Position: 20,
DocTasks: []workflowdef.DocTaskDef{{
Key: "feasibility_reg", Title: "法规路径评估", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true,
OutputDocType: "feasibility", OutputDocTitle: "法规路径评估",
}},
},
)
return r
}
+83 -2
View File
@@ -42,6 +42,8 @@ type Store interface {
HasLiveInstance(ctx context.Context, projectID int64) (bool, error)
UpsertPlanningInstance(ctx context.Context, inst *WorkflowInstance) (*WorkflowInstance, error)
ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error)
ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error)
CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error)
TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error)
SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error
@@ -292,16 +294,30 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst *
}
func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) {
return s.activateInstanceTx(ctx, s.pool, "", projectID, inst, version, expectedVersion, activatedBy, "")
}
func (s *pgStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) {
return s.activateInstanceTx(ctx, s.pool, token, projectID, inst, version, expectedVersion, activatedBy, planHash)
}
func (s *pgStore) activateInstanceTx(ctx context.Context, db pgxDB, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) {
workflowKey := ""
if version != nil {
workflowKey = version.WorkflowKey
}
tx, err := s.pool.Begin(ctx)
tx, err := db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if token != "" {
if err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash); err != nil {
return nil, err
}
}
tag, err := tx.Exec(ctx, `
UPDATE projects SET lifecycle_status = 'active', active_workflow_key = $2,
workflow_selection_version = workflow_selection_version + 1,
@@ -359,6 +375,55 @@ func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *W
return activated, nil
}
type pgxDB interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
func consumeActivationTokenTx(ctx context.Context, tx pgx.Tx, token string, projectID int64, planHash string) error {
tokenHash := hashActivationToken(token)
var storedHash string
var usedAt *time.Time
var expiresAt time.Time
err := tx.QueryRow(ctx, `
SELECT plan_hash, used_at, expires_at
FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2
`, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt)
if errors.Is(err, pgx.ErrNoRows) {
return ErrTokenInvalid
}
if err != nil {
return err
}
if usedAt != nil || time.Now().After(expiresAt) || storedHash != planHash {
return ErrTokenInvalid
}
tag, err := tx.Exec(ctx, `
UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL
`, tokenHash)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrTokenInvalid
}
return nil
}
func (s *pgStore) CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) {
if projectID <= 0 {
return 0, nil
}
var count int
err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM task_runs
WHERE project_id = $1 AND status IN ('pending', 'running')
`, projectID).Scan(&count)
if err != nil {
return 0, nil
}
return count, nil
}
func (s *pgStore) applyRuntimeMetadata(ctx context.Context, tx pgx.Tx, instanceID int64, def workflowdef.VersionDefinition) error {
taskDefs := map[string]workflowdef.DocTaskDef{}
for _, st := range def.Stages {
@@ -502,6 +567,11 @@ func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, proje
return nil, err
}
_, _ = tx.Exec(ctx, `
UPDATE task_runs SET status = 'cancelled', updated_at = $2, error_message = 'workflow terminated'
WHERE project_id = $1 AND status IN ('pending', 'running')
`, projectID, now)
if err := tx.Commit(ctx); err != nil {
return nil, err
}
@@ -587,7 +657,7 @@ func (m *memStore) rebuildStagesLocked(inst *WorkflowInstance, activate bool) {
task := memDocTaskRecord{id: m.nextTaskID, taskKey: dt.TaskKey, enabled: dt.Enabled, status: "pending"}
m.nextTaskID++
if !dt.Enabled {
task.status = "skipped"
task.status = "skipped_disabled"
}
rec.tasks = append(rec.tasks, task)
}
@@ -706,6 +776,17 @@ func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInsta
return &copyInst, nil
}
func (m *memStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) {
if _, err := m.ConsumeActivationToken(ctx, token, projectID, planHash); err != nil {
return nil, err
}
return m.ActivateInstance(ctx, projectID, inst, version, expectedVersion, activatedBy)
}
func (m *memStore) CountActiveTaskRuns(_ context.Context, _ int64) (int, error) {
return 0, nil
}
func (m *memStore) ActivateInstance(_ context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) {
workflowKey := ""
if version != nil {
+71 -1
View File
@@ -1,6 +1,9 @@
package projectinstance
import "context"
import (
"context"
"fmt"
)
// TerminateInput 受控终止输入。
type TerminateInput struct {
@@ -15,6 +18,73 @@ type TerminateResult struct {
Reason string `json:"reason"`
}
// PendingDocSummary 终止预览中的待处理文档摘要。
type PendingDocSummary struct {
TaskKey string `json:"task_key"`
Status string `json:"status"`
StageKey string `json:"stage_key,omitempty"`
}
// TerminationPreview 受控终止影响预览。
type TerminationPreview struct {
InstanceID int64 `json:"instance_id"`
PendingDocuments []PendingDocSummary `json:"pending_documents"`
InProgressTaskRuns int `json:"in_progress_task_runs"`
CanTerminate bool `json:"can_terminate"`
Impact string `json:"impact,omitempty"`
}
func (s *Service) GetTerminationPreview(ctx context.Context, instanceID int64, projectID int64, userID int64, isCreator bool) (*TerminationPreview, error) {
if !isCreator {
return nil, ErrForbiddenNotCreator
}
if !s.Available() {
return nil, ErrNotAvailable
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil || live == nil || live.ID != instanceID {
return nil, ErrInstanceNotFound
}
if live.Status == InstanceCompleted || live.Status == "terminated" {
return nil, ErrTerminationNotAllowed
}
detail, err := s.store.GetInstanceByID(ctx, instanceID)
if err != nil {
return nil, err
}
pending := make([]PendingDocSummary, 0)
for _, st := range detail.Stages {
if st.StageKey != detail.ActiveStageKey {
continue
}
for _, dt := range st.DocTasks {
if !dt.Enabled || dt.Status == "skipped_disabled" {
continue
}
if dt.Status != "approved" {
pending = append(pending, PendingDocSummary{
TaskKey: dt.TaskKey, Status: dt.Status, StageKey: st.StageKey,
})
}
}
}
taskRuns, _ := s.store.CountActiveTaskRuns(ctx, projectID)
preview := &TerminationPreview{
InstanceID: instanceID,
PendingDocuments: pending,
InProgressTaskRuns: taskRuns,
CanTerminate: true,
Impact: fmt.Sprintf(
"%d pending documents, %d in-progress task runs will be cancelled",
len(pending), taskRuns,
),
}
return preview, nil
}
func (s *Service) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, userID int64, isCreator bool, in TerminateInput) (*TerminateResult, error) {
if !isCreator {
return nil, ErrForbiddenNotCreator
@@ -0,0 +1,48 @@
package projectinstance
import (
"context"
"testing"
)
func TestTerminateInstance_UpdatesProjectLifecycle(t *testing.T) {
ctx := context.Background()
store := NewMemoryStore()
_ = SeedProjectMeta(store, 300, LifecyclePlanning, "", 1, 0)
svc := NewService(store, TestResolver(), nil, &StubDocChecker{States: map[string]string{"intake": "approved"}})
_, _ = svc.SaveWorkflowPlan(ctx, 300, 1, true, SavePlanInput{
Plan: ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
preview, _ := svc.ActivationPreview(ctx, 300, 1, true)
activated, _ := svc.Activate(ctx, 300, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
pv, err := svc.GetTerminationPreview(ctx, activated.Instance.ID, 300, 1, true)
if err != nil {
t.Fatalf("GetTerminationPreview: %v", err)
}
if !pv.CanTerminate {
t.Fatal("expected can terminate")
}
res, err := svc.TerminateInstance(ctx, activated.Instance.ID, 300, 1, true, TerminateInput{
Reason: "测试终止", Confirm: true, ExpectedConfigRevision: activated.Instance.ConfigRevision,
})
if err != nil {
t.Fatalf("TerminateInstance: %v", err)
}
if res.Instance.Status != "terminated" {
t.Fatalf("expected terminated, got %q", res.Instance.Status)
}
lifecycle, activeKey, _, _, err := store.GetProjectMeta(ctx, 300)
if err != nil {
t.Fatalf("GetProjectMeta: %v", err)
}
if lifecycle != LifecyclePlanning || activeKey != "" {
t.Fatalf("expected planning with empty key, got lifecycle=%q key=%q", lifecycle, activeKey)
}
}
+116
View File
@@ -0,0 +1,116 @@
package projectinstance
import (
"context"
"chat-agent-system/internal/workflowdef"
)
// NewMemoryStore 返回内存 Store,供跨包集成测试使用。
func NewMemoryStore() Store {
return newMemStore()
}
// TestResolver 返回最小正式商务工作流解析器。
func TestResolver() VersionResolver {
return testResolver()
}
// ValidTestPlan 返回单阶段 intake 测试规划。
func ValidTestPlan() WorkflowPlan {
return validPlan()
}
// StubDocChecker 内存文档批准状态探针。
type StubDocChecker struct {
States map[string]string
}
func (s *StubDocChecker) TaskApprovalStates(_ context.Context, _ int64) (map[string]string, error) {
if s == nil {
return map[string]string{}, nil
}
return s.States, nil
}
type stubVersionResolver struct {
versions map[string]*workflowdef.Version
defs map[string]*workflowdef.Definition
}
func (s *stubVersionResolver) GetDefinitionByKey(_ context.Context, key string) (*workflowdef.Definition, error) {
d, ok := s.defs[key]
if !ok {
return nil, workflowdef.ErrNotFound
}
return d, nil
}
func (s *stubVersionResolver) GetVersion(_ context.Context, workflowID int64, version int) (*workflowdef.Version, error) {
for _, v := range s.versions {
if v.WorkflowID == workflowID && v.Version == version {
copyV := *v
return &copyV, nil
}
}
return nil, workflowdef.ErrNotFound
}
func (s *stubVersionResolver) ListDefinitions(_ context.Context) ([]workflowdef.Definition, error) {
out := make([]workflowdef.Definition, 0, len(s.defs))
for _, d := range s.defs {
out = append(out, *d)
}
return out, nil
}
func disabledDocResolver() *stubVersionResolver {
r := testResolver()
r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks = append(
r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks,
workflowdef.DocTaskDef{
Key: "research", Title: "市场调研", CreatorConfigurable: true, BlockingWhenEnabled: false, DefaultEnabled: false,
OutputDocType: "research", OutputDocTitle: "市场调研",
},
)
return r
}
func testResolver() *stubVersionResolver {
key := workflowdef.OfficialKeyMain
ver := &workflowdef.Version{
ID: 100, WorkflowID: 10, Version: 1, Status: workflowdef.StatusPublished,
WorkflowKey: key, WorkflowName: workflowdef.OfficialWorkflowName(key),
Definition: workflowdef.VersionDefinition{
SchemaVersion: 2,
WorkflowKey: key,
Stages: []workflowdef.StageDef{{
Key: "V0.1_triage", Name: "V0.1 需求摸底", Optional: false, Position: 10,
DocTasks: []workflowdef.DocTaskDef{{
Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true,
OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表",
}},
}},
},
}
return &stubVersionResolver{
defs: map[string]*workflowdef.Definition{
key: {ID: 10, Key: key, Name: workflowdef.OfficialWorkflowName(key), LatestPublishedVersion: 1},
},
versions: map[string]*workflowdef.Version{key: ver},
}
}
func validPlan() WorkflowPlan {
return WorkflowPlan{
WorkflowDefinitionKey: workflowdef.OfficialKeyMain,
WorkflowVersion: 1,
Stages: []StagePlan{{
StageKey: "V0.1_triage", Enabled: true,
DocTasks: []DocTaskPlan{{
TaskKey: "intake", Enabled: true,
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
}},
}},
}
}
@@ -0,0 +1,163 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"chat-agent-system/internal/db"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/workflowdef"
)
// TestE2E_WorkflowGovernance_CompleteAndReplan 验证末阶段完成后项目回到 planning 并可再次获取目录。
func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) {
dsn := db.SkipIfNoTestDatabaseURL(t)
_ = dsn
pool := openPGPoolWithMigrations(t)
if pool == nil {
return
}
defer pool.Close()
ctx := context.Background()
handler, _ := 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)
}
var projectID int64
code := "e2e-gov-" + time.Now().Format("150405.000")
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)
if err != nil {
t.Fatalf("insert project: %v", err)
}
t.Cleanup(func() {
_, _ = 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)
withBearerAuth(reqDefault, creatorToken)
recDefault := httptest.NewRecorder()
handler.ServeHTTP(recDefault, reqDefault)
if recDefault.Code != http.StatusOK {
t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String())
}
var plan projectinstance.WorkflowPlan
_ = json.Unmarshal(recDefault.Body.Bytes(), &plan)
reviewerID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "lizhuguan", "123456"))
approverID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "wangzong", "123456"))
for i := range plan.Stages {
for j := range plan.Stages[i].DocTasks {
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
}
}
}
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1})
reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody))
withBearerAuth(reqSave, creatorToken)
recSave := httptest.NewRecorder()
handler.ServeHTTP(recSave, reqSave)
if recSave.Code != http.StatusOK {
t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String())
}
reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil)
withBearerAuth(reqPreview, creatorToken)
recPreview := httptest.NewRecorder()
handler.ServeHTTP(recPreview, reqPreview)
var preview projectinstance.ActivationPreview
_ = json.Unmarshal(recPreview.Body.Bytes(), &preview)
activateBody, _ := json.Marshal(projectinstance.ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody))
withBearerAuth(reqActivate, creatorToken)
recActivate := httptest.NewRecorder()
handler.ServeHTTP(recActivate, reqActivate)
if recActivate.Code != http.StatusOK {
t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String())
}
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)
if err != nil {
t.Fatalf("mark approved: %v", err)
}
_ = docID
advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{
StageKey: "only_stage", 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))
withBearerAuth(reqAdvance, creatorToken)
recAdvance := httptest.NewRecorder()
handler.ServeHTTP(recAdvance, reqAdvance)
if recAdvance.Code != http.StatusOK {
t.Fatalf("advance: %d %s", recAdvance.Code, recAdvance.Body.String())
}
reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", projectID), nil)
withBearerAuth(reqSettings, creatorToken)
recSettings := httptest.NewRecorder()
handler.ServeHTTP(recSettings, reqSettings)
if recSettings.Code != http.StatusOK {
t.Fatalf("settings: %d %s", recSettings.Code, recSettings.Body.String())
}
var settings projectinstance.ProjectSettings
if err := json.Unmarshal(recSettings.Body.Bytes(), &settings); err != nil {
t.Fatalf("decode settings: %v", err)
}
if settings.LifecycleStatus != projectinstance.LifecyclePlanning {
t.Fatalf("expected planning after completion, got %q", settings.LifecycleStatus)
}
reqCatalog := httptest.NewRequest(http.MethodGet, "/api/v1/workflows/catalog", nil)
withBearerAuth(reqCatalog, creatorToken)
recCatalog := httptest.NewRecorder()
handler.ServeHTTP(recCatalog, reqCatalog)
if recCatalog.Code != http.StatusOK {
t.Fatalf("catalog: %d %s", recCatalog.Code, recCatalog.Body.String())
}
}
+24 -1
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
"time"
"chat-agent-system/internal/projectinstance"
)
@@ -52,7 +53,29 @@ func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request
writeProjectInstanceError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, settings)
type settingsResponse struct {
*projectinstance.ProjectSettings
RecentWorkflowEvents []workflowEventSummary `json:"recent_workflow_events,omitempty"`
}
out := settingsResponse{ProjectSettings: settings}
if s.notifySvc != nil && s.notifySvc.Available() {
items, listErr := s.notifySvc.ListProjectNotifications(r.Context(), user.ID, projectID, 0, 20)
if listErr == nil {
for _, n := range items {
out.RecentWorkflowEvents = append(out.RecentWorkflowEvents, workflowEventSummary{
ID: n.ID, Type: n.Type, Title: n.Title, CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
}
}
}
writeJSONResponse(w, http.StatusOK, out)
}
type workflowEventSummary struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
CreatedAt string `json:"created_at"`
}
func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -165,6 +165,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/workflow-instances/{id}", s.handleGetWorkflowInstance)
mux.HandleFunc("GET /api/v1/workflow-instances/{id}/stages/{stage}/readiness", s.handleGetStageReadiness)
mux.HandleFunc("POST /api/v1/workflow-instances/{id}/stages/{stage}/advance", s.handleAdvanceStage)
mux.HandleFunc("GET /api/v1/workflow-instances/{id}/termination-preview", s.handleTerminationPreview)
mux.HandleFunc("POST /api/v1/workflow-instances/{id}/terminate", s.handleTerminateWorkflowInstance)
mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions)
mux.HandleFunc("GET /api/v1/workflows/catalog", s.handleWorkflowCatalog)
+43 -5
View File
@@ -20,18 +20,56 @@ func (s *Server) resolveStageAccess(ctx context.Context, projectID, userID int64
return s.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator)
}
func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) bool {
func (s *Server) checkWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) projectinstance.DocAccessDecision {
if taskKey == "" {
return true
return projectinstance.DocAccessDecision{Allowed: true}
}
if s.instSvc != nil && s.instSvc.Available() && projectID > 0 && userID > 0 {
live, liveErr := s.instSvc.HasLiveInstance(ctx, projectID)
if liveErr == nil && live {
isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID)
return s.instSvc.CheckDocTaskAccess(ctx, projectID, userID, taskKey, projectinstance.DocAccessActionRun, isCreator)
}
}
access, err := s.resolveStageAccess(ctx, projectID, userID)
if err != nil {
return !s.failClosedAuthz()
if s.failClosedAuthz() {
return projectinstance.DocAccessDecision{Allowed: false, Code: "WORKFLOW_ACCESS_DENIED", Message: "无权访问该阶段内容"}
}
return projectinstance.DocAccessDecision{Allowed: true}
}
if access == nil || !access.Restricted {
if access == nil || !access.Restricted || access.TaskKeyAllowed(taskKey) {
return projectinstance.DocAccessDecision{Allowed: true}
}
return projectinstance.DocAccessDecision{Allowed: false, Code: "WORKFLOW_ACCESS_DENIED", Message: "无权访问该阶段内容"}
}
func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) bool {
return s.checkWorkflowDocTaskAccess(ctx, projectID, userID, taskKey).Allowed
}
func (s *Server) enforceWorkflowDocTaskAccess(w http.ResponseWriter, ctx context.Context, projectID, userID int64, taskKey string) bool {
decision := s.checkWorkflowDocTaskAccess(ctx, projectID, userID, taskKey)
if decision.Allowed {
return true
}
return access.TaskKeyAllowed(taskKey)
s.writeDocAccessDecision(w, decision)
return false
}
func (s *Server) writeDocAccessDecision(w http.ResponseWriter, decision projectinstance.DocAccessDecision) {
status := http.StatusForbidden
if decision.Code == projectinstance.AccessCodeDocTaskDisabled {
status = http.StatusConflict
}
msg := decision.Message
if msg == "" {
msg = "无权访问该文档任务"
}
payload, _ := json.Marshal(map[string]string{"error": decision.Code, "message": msg})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(payload)
}
func (s *Server) allowAnyStageAccess(ctx context.Context, projectID, userID int64) bool {
+1 -2
View File
@@ -68,8 +68,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
if req.TaskKey == "" && req.Stage == "" {
req.TaskKey = "intake"
}
if req.ProjectID > 0 && req.TaskKey != "" && !s.allowWorkflowDocTaskAccess(r.Context(), req.ProjectID, user.ID, req.TaskKey) {
s.writeWorkflowAccessDenied(w)
if req.ProjectID > 0 && req.TaskKey != "" && !s.enforceWorkflowDocTaskAccess(w, r.Context(), req.ProjectID, user.ID, req.TaskKey) {
return
}
req.WorkDir = ""
@@ -9,6 +9,35 @@ import (
"chat-agent-system/internal/projectinstance"
)
func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request) {
if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable)
return
}
instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || instanceID <= 0 {
http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest)
return
}
user, err := s.authenticateUser(r)
if err != nil || user == nil {
http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized)
return
}
detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID)
if err != nil {
http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound)
return
}
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID)
preview, err := s.instSvc.GetTerminationPreview(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator)
if err != nil {
writeProjectInstanceError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, preview)
}
func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http.Request) {
if s.instSvc == nil || !s.instSvc.Available() {
http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable)
@@ -0,0 +1,18 @@
-- 修复「实例 completed 但项目仍为 active」的历史不一致数据。
-- 用法:
-- psql "$DATABASE_URL" -f scripts/fix-completed-instance-lifecycle.sql
-- Dry-run(仅统计):
-- psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM projects p JOIN project_workflow_instances i ON i.project_id = p.id AND i.status = 'completed' WHERE p.lifecycle_status <> 'planning';"
BEGIN;
UPDATE projects p
SET lifecycle_status = 'planning',
active_workflow_key = '',
updated_at = now()
FROM project_workflow_instances i
WHERE i.project_id = p.id
AND i.status = 'completed'
AND p.lifecycle_status <> 'planning';
COMMIT;
+45 -1
View File
@@ -6,6 +6,8 @@ import {
StageReadiness,
AdvanceStageResult,
ProjectNotification,
WorkflowCatalogItem,
TerminationPreview,
} from '../types/projectSettings';
import { getBearerAuthHeaders } from './authHeaders';
@@ -130,6 +132,44 @@ export async function fetchProjectNotifications(
return Array.isArray(data.items) ? data.items : [];
}
export async function fetchWorkflowCatalog(): Promise<WorkflowCatalogItem[]> {
const res = await fetch(`${API_BASE}/api/v1/workflows/catalog`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
return [];
}
const data = await res.json();
return Array.isArray(data.workflows) ? data.workflows : [];
}
export async function fetchTerminationPreview(instanceId: number): Promise<TerminationPreview> {
const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/termination-preview`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(parseError(text) || `终止预览失败: ${res.status}`);
}
return res.json();
}
export async function terminateWorkflowInstance(
instanceId: number,
input: { reason: string; confirm: boolean; expected_config_revision: number }
): Promise<{ instance: { id: number; status: string }; reason: string }> {
const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/terminate`, {
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(parseError(text) || `终止工作流失败: ${res.status}`);
}
return res.json();
}
export async function advanceStage(
instanceId: number,
stageKey: string,
@@ -148,7 +188,11 @@ export async function advanceStage(
);
if (!res.ok) {
const text = await res.text();
throw new Error(parseError(text) || `阶段推进失败: ${res.status}`);
const msg = parseError(text) || `阶段推进失败: ${res.status}`;
if (res.status === 409 || /revision|conflict|版本/i.test(msg)) {
throw new Error(`配置版本已变化,请刷新后重试:${msg}`);
}
throw new Error(msg);
}
return res.json();
}
@@ -6,6 +6,8 @@ import {
saveWorkflowPlan,
fetchActivationPreview,
activateWorkflowInstance,
fetchTerminationPreview,
terminateWorkflowInstance,
} from '../../api/projectSettings';
import {
ProjectSettings,
@@ -45,6 +47,8 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
const [plan, setPlan] = useState<WorkflowPlan | null>(null);
const [preview, setPreview] = useState<ActivationPreview | null>(null);
const [selectedKey, setSelectedKey] = useState('');
const [terminateReason, setTerminateReason] = useState('');
const [terminatePreview, setTerminatePreview] = useState<{ impact?: string } | null>(null);
const loadSettings = useCallback(async () => {
setLoading(true);
@@ -52,6 +56,16 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
try {
const s = await fetchProjectSettings(projectId);
setSettings(s);
if (s.live_instance && s.live_instance.status !== 'completed' && s.live_instance.status !== 'terminated') {
try {
const preview = await fetchTerminationPreview(s.live_instance.id);
setTerminatePreview(preview);
} catch {
setTerminatePreview(null);
}
} else {
setTerminatePreview(null);
}
if (s.plan) {
setPlan(s.plan);
setSelectedKey(s.plan.workflow_definition_key);
@@ -151,6 +165,24 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
}
};
const handleTerminate = async () => {
if (!settings?.live_instance || !terminateReason.trim()) return;
setLoading(true);
setError(null);
try {
await terminateWorkflowInstance(settings.live_instance.id, {
reason: terminateReason.trim(),
confirm: true,
expected_config_revision: settings.live_instance.config_revision,
});
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '终止工作流失败');
} finally {
setLoading(false);
}
};
const handleActivate = async () => {
if (!preview || !settings) return;
setLoading(true);
@@ -260,6 +292,57 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
</div>
)}
{settings?.live_instance && settings.live_instance.status !== 'completed' && (
<div
style={{
marginBottom: '16px',
padding: '12px',
border: '1px solid var(--danger-200, #fecaca)',
borderRadius: '8px',
background: 'var(--danger-50, #fef2f2)',
}}
>
<div style={{ fontWeight: 600, fontSize: '13px', marginBottom: '8px' }}>受控终止当前工作流</div>
{terminatePreview?.impact && (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '8px' }}>{terminatePreview.impact}</div>
)}
<textarea
value={terminateReason}
onChange={(e) => setTerminateReason(e.target.value)}
placeholder="请填写终止原因(必填)"
rows={2}
style={{ width: '100%', fontSize: '12px', padding: '8px', borderRadius: '6px', border: '1px solid var(--border)' }}
/>
<button
onClick={handleTerminate}
disabled={loading || !terminateReason.trim()}
style={{
marginTop: '8px',
padding: '6px 12px',
borderRadius: '6px',
border: 'none',
background: 'var(--danger-600, #dc2626)',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
}}
>
确认终止
</button>
</div>
)}
{settings?.recent_workflow_events && settings.recent_workflow_events.length > 0 && (
<div style={{ marginBottom: '16px', fontSize: '12px' }}>
<div style={{ fontWeight: 600, marginBottom: '6px' }}>最近工作流事件</div>
<ul style={{ margin: 0, paddingLeft: '18px', color: 'var(--text-muted)' }}>
{settings.recent_workflow_events.slice(0, 5).map((ev) => (
<li key={ev.id}>{ev.title || ev.type}</li>
))}
</ul>
</div>
)}
{step === 'select' && settings && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', margin: 0 }}>
@@ -1,5 +1,7 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Workflow, Lock, CheckCircle2, AlertCircle, Settings2, Eye } from 'lucide-react';
import { fetchWorkflowCatalog } from '../../api/projectSettings';
import { WorkflowCatalogItem } from '../../types/projectSettings';
interface ProjectWorkflowSwitcherProps {
projectId: number;
@@ -12,15 +14,12 @@ interface ProjectWorkflowSwitcherProps {
onOpenSettings?: () => void;
}
const WORKFLOW_OPTIONS = [
const FALLBACK_OPTIONS = [
{ key: 'main', label: '商务对接流程', icon: '⚡' },
{ key: 'product_manager', label: '智能产品经理流程', icon: '📋' },
{ key: 'test_manager', label: '智能测试工程师流程', icon: '🧪' },
];
const workflowLabel = (key: string) =>
WORKFLOW_OPTIONS.find((w) => w.key === key)?.label || key;
export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = ({
activeWorkflowKey,
lifecycleStatus = 'active',
@@ -28,6 +27,23 @@ export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = (
onOpenSettings,
}) => {
const [errorMsg] = useState<string | null>(null);
const [catalog, setCatalog] = useState<WorkflowCatalogItem[]>([]);
useEffect(() => {
let cancelled = false;
fetchWorkflowCatalog().then((items) => {
if (!cancelled) setCatalog(items);
});
return () => {
cancelled = true;
};
}, []);
const workflowLabel = (key: string) => {
const fromCatalog = catalog.find((w) => w.key === key)?.name;
if (fromCatalog) return fromCatalog;
return FALLBACK_OPTIONS.find((w) => w.key === key)?.label || key;
};
const isPlanning = lifecycleStatus === 'planning' || !activeWorkflowKey;
const canConfigureNext = lifecycleStatus === 'planning' && !activeWorkflowKey;
@@ -78,51 +94,38 @@ export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = (
配置工作流
</button>
)}
{!isPlanning && onOpenSettings && (
<button
onClick={onOpenSettings}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
marginLeft: '4px',
fontSize: '11px',
borderRadius: '6px',
border: '1px solid var(--border)',
cursor: 'pointer',
background: 'var(--bg-card, #fff)',
color: 'var(--text-muted)',
}}
>
<Eye size={12} />
查看流程
</button>
{!isPlanning && !canConfigureNext && (
<span style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: '3px' }}>
<Lock size={11} /> 运行中
</span>
)}
{!isCreator && isPlanning && <Lock size={12} style={{ opacity: 0.5 }} />}
</div>
{errorMsg && (
<div
<span style={{ fontSize: '10px', color: 'var(--danger-700)', display: 'flex', alignItems: 'center', gap: '3px' }}>
<AlertCircle size={11} /> {errorMsg}
</span>
)}
{!isPlanning && isCreator && onOpenSettings && (
<button
onClick={onOpenSettings}
title="查看工作流设置"
style={{
position: 'absolute',
top: '36px',
left: '0',
zIndex: 100,
background: 'var(--danger-50, #fef2f2)',
border: '1px solid var(--danger-200, #fecaca)',
color: 'var(--danger-700, #b91c1c)',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '4px',
whiteSpace: 'nowrap',
gap: '3px',
padding: '3px 6px',
fontSize: '10px',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'transparent',
cursor: 'pointer',
color: 'var(--text-muted)',
}}
>
<AlertCircle size={12} />
{errorMsg}
</div>
<Eye size={11} />
</button>
)}
</div>
);
@@ -88,7 +88,11 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
onStageAdvanced?.(result.next_stage_key);
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '阶段推进失败');
const msg = err instanceof Error ? err.message : '阶段推进失败';
setError(msg);
if (/版本|revision|conflict/i.test(msg)) {
await load();
}
} finally {
setAdvancing(false);
}
+23
View File
@@ -33,6 +33,20 @@ export interface PublishedOption {
product_domain?: string;
}
export interface WorkflowCatalogItem {
key: string;
name: string;
latest_version: number;
product_domain?: string;
}
export interface WorkflowEventSummary {
id: number;
type: string;
title: string;
created_at: string;
}
export interface ProjectSettings {
project_id: number;
lifecycle_status: string;
@@ -46,6 +60,15 @@ export interface ProjectSettings {
stage_advance_ready?: boolean;
live_instance?: { id: number; status: string; config_revision: number };
published_workflows?: PublishedOption[];
recent_workflow_events?: WorkflowEventSummary[];
}
export interface TerminationPreview {
instance_id: number;
pending_documents: Array<{ task_key: string; status: string; stage_key?: string }>;
in_progress_task_runs: number;
can_terminate: boolean;
impact?: string;
}
export interface ActivationPreview {