diff --git a/.doc/ops/short-term/2026-09-25-u9u12-keys-rollout.md b/.doc/ops/short-term/2026-09-25-u9u12-keys-rollout.md new file mode 100644 index 00000000..d8f356aa --- /dev/null +++ b/.doc/ops/short-term/2026-09-25-u9u12-keys-rollout.md @@ -0,0 +1,73 @@ +--- +title: "P8: U9–U12 剩余键逐键放量" +task_id: u9u12-keys-rollout-2026-09-25 +status: in_progress +created: 2026-09-25 +updated: 2026-09-25 +owner: cursor-auto +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1" +branch: feature/u9u12-keys-rollout-2026-09-25 +worktree: .worktrees/u9u12-keys-rollout-2026-09-25 +base_dev_sha: "9f7bbc25a05853aceecad4c60233fa8d38a8990a" +write_scope: + - internal/app/ + - internal/app/intentflow/ + - internal/config/ + - .doc/ops/short-term/ + - .doc/test-reports/ +allowed_environments: [] +head_sha: "" +mutex: intent-plan +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: independent maintainer after owner exit +cleanup_trigger: acceptance green + optional owner exit +remote_sync: not_requested +--- + +# P8: U9–U12 剩余键逐键放量 + +## 用户目标与授权 + +- 用户目标:`/goal 把这些任务都进行规划,并推进至完成` → 总控 [P8 批次](./2026-09-25-planned-tasks-closeout-plan.md)。 +- 来源:MATRIX `intent-plan-u9u12` 行尾注「未开放:`document.review.approve/reject`、`document.final.*`、`document.proposal.decide`、`workflow.switch`、`run.restart`(validated_only)」。 +- 交付终点:逐键独立 flag + 真实引擎路由 + 单测验收 + 真实 dev PI 证据;**禁 full 一次放量**。 + +## 实施内容 + +1. **逐键 flag**(`intentflow/flags.go`):`U11LiveReview`、`U11LiveFinal`、`U11LiveProposal`、`U12LiveSwitch`、`U12LiveRestart`(env `MULTI_INTENT_U11_LIVE_REVIEW` 等,默认全 OFF)。域名总 flag 开启后细粒度键仍须单独打开。 +2. **live 集合函数化**(`intentflow/expansion.go`):`DocGovLiveActionKeys()`/`RunControlLiveActionKeys()` 按当前 flag 动态返回;基线键(claim/review.submit、workflow.run/run.resume)不受影响。新增 `IsDocGovLiveKey`/`IsRunControlLiveKey` 判定。 +3. **DocGovOwner 路由扩展**(`app/docgov_owner.go`): + - `document.review.approve` → `ReviewApprove`(RoleReviewer) + - `document.review.reject` → `ReviewReject`(RoleReviewer,reason 走 slots) + - `document.final.approve` → `ApproverApprove`(RoleApprover) + - `document.final.reject` → `ApproverReject`(RoleApprover,rollback→drafting) + - `document.proposal.decide` → `DecideModificationProposal`(proposal_id/approve slots;收据走 `commitReceipt`) +4. **RunControlOwner 路由扩展**(`app/run_control_owner.go`): + - `workflow.switch` → `ActivateWorkflow`(workflow_key slot 必填校验) + - `run.restart` → `CreateAndStart`(全新 run,不续 workspace,幂等键防重) +5. **policy 表**(`intentflow/policy.go`):7 个新键从 `TierFull` 提到 `TierConfirm`(confirm 档进计划;真实执行仍受 per-key flag 限制)。 +6. **验收单测**(`app/live_keys_rollout_test.go`):默认 OFF 不入 live、逐键 flag 独立、review.approve 真实状态推进(reviewing→approving)+ 幂等重放、flag OFF 时 dry-run 零状态推进。 + +## 非目标 + +- 不改 tier 默认(仍 legacy);compose 不动。 +- `run.resume` 已 live 无需动。 +- 真实 dev 逐键 PI 证据:flag 打开后另行采集(见测试报告)。 + +## 验证与证据 + +| 范围 | 命令 | 结果 | +|---|---|---| +| P8 新用例 | `go test -short -run 'TestP8' ./internal/app/` | PASS | +| app/intent/coordinator 全量 | `go test -short ./internal/app/... ./internal/intent/... ./internal/coordinator/...` | PASS(13 包) | +| 架构门禁 | `./scripts/arch-check.sh --mode block` | PASS | + +## 收口与交接 + +- 详见测试报告:[2026-09-25-p8-u9u12-keys-rollout-test-report.md](../../test-reports/2026-09-25-p8-u9u12-keys-rollout-test-report.md) +- 后续:dev 运维按键打开 flag(`configs/.env.dev`),采集真实 PI 证据后更新 MATRIX 行尾注。 diff --git a/.doc/test-reports/2026-09-25-p8-u9u12-keys-rollout-test-report.md b/.doc/test-reports/2026-09-25-p8-u9u12-keys-rollout-test-report.md new file mode 100644 index 00000000..7b057820 --- /dev/null +++ b/.doc/test-reports/2026-09-25-p8-u9u12-keys-rollout-test-report.md @@ -0,0 +1,50 @@ +# P8 U9–U12 剩余键逐键放量 测试报告 + +## 一、 测试背景 (Background) + +MATRIX `intent-plan-u9u12` 行(done / deferred_enhancement)尾注:`document.review.approve/reject`、`document.final.approve/reject`、`document.proposal.decide`、`workflow.switch`、`run.restart` 仍为 validated_only(dry-run)。总控计划 P8 授权逐键放量:每键独立 flag + 真实引擎路由,禁 full 一次放量。 + +## 二、 测试目标 (Test Objectives) + +1. 默认 flag 全 OFF 时新键保持 dry-run(不进 live 集合)。 +2. 逐键 flag 独立打开只放量对应键。 +3. 新键路由真实领域引擎(docgov 状态机 / projectworkflow / taskrun)。 +4. 幂等重放返回同收据;flag OFF 时零状态推进。 +5. 既有基线键行为不变;arch-check PASS。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux 7.0.0-31-generic;Go 1.27.1。 +- 被测 worktree:`.worktrees/u9u12-keys-rollout-2026-09-25`(base dev `9f7bbc25`)。 +- 层级:工程层 Go 单测(真实 docgov 引擎内存实例);真实 dev PI 逐键证据在本批 flag 落 configs 后采集(另见报告更新)。 + +## 四、 预期结果 (Expected Results) + +| # | 用例 | 预期 | +|---|---|---| +| T1 | 默认 OFF | 5 docgov 键 + 2 run-control 键不在 live 集合;基线 4 键仍在 | +| T2 | 逐键 flag | 全开时 7 键 live;只开 review 时 final/proposal 仍 dry-run | +| T3 | review.approve live | 文档 reviewing→approving;收据 `docgov:`;重放同收据 | +| T4 | review.approve flag OFF | dry-run 收据非引擎路径;状态零推进 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +- T1:PASS(`TestP8LiveKeysDefaultOff`) +- T2:PASS(`TestP8LiveKeysPerKeyFlags`) +- T3:PASS(`TestP8DocGovReviewApproveLive`;含幂等重放断言) +- T4:PASS(`TestP8DocGovReviewApproveDryRunWhenFlagOff`) +- 全量回归:`go test -short ./internal/app/... ./internal/intent/... ./internal/coordinator/...` 13 包全 ok +- 架构:`arch-check --mode block` production gate passed + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 套件 | 结果 | 通过率 | +|---|---|---| +| `go test -run 'TestP8' ./internal/app/` | PASS | 4/4 | +| app/intent/coordinator 短测 | PASS | 100%(13 包) | +| arch-check block | PASS | — | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 结论:代码层达到交付终点——逐键 flag 机制 + 真实引擎路由 + 单测验收全绿。 +- 后续:`configs/.env.dev` 逐键打开 flag(先 review,后 final/proposal/switch/restart)采集真实 PI 证据;证据齐后在 MATRIX 行尾注登记「已 live 键集」扩展。`run.resume` 的自然语言触发证据仍欠(既有尾注遗留)。 diff --git a/internal/app/docgov_owner.go b/internal/app/docgov_owner.go index 4f3fde86..eadec0ed 100644 --- a/internal/app/docgov_owner.go +++ b/internal/app/docgov_owner.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "sync" "chat-agent-system/internal/app/intentflow" @@ -38,8 +39,7 @@ func NewDocGovOwner(gov *docgov.GovernanceEngine, access ProjectAccess, resolve } func (d *DocGovOwner) isLiveKey(key string) bool { - _, ok := intentflow.DocGovLiveActionKeys[key] - return ok + return intentflow.IsDocGovLiveKey(key) } func (d *DocGovOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error { @@ -107,6 +107,20 @@ func (d *DocGovOwner) Execute(ctx context.Context, exec intentflow.StepExec) (in updated, err = d.Gov.RecordDocumentViewed(ctx, docID, exec.Principal.UserID, role, name) case "document.review.submit": updated, err = d.Gov.SubmitForReview(ctx, docID, exec.Principal.UserID, role, name) + case "document.review.approve": + updated, err = d.Gov.ReviewApprove(ctx, docID, exec.Principal.UserID, docgov.RoleReviewer, name, docgovCommentFromExec(exec)) + case "document.review.reject": + updated, err = d.Gov.ReviewReject(ctx, docID, exec.Principal.UserID, docgov.RoleReviewer, name, docgovReasonFromExec(exec)) + case "document.final.approve": + updated, err = d.Gov.ApproverApprove(ctx, docID, exec.Principal.UserID, docgov.RoleApprover, name, docgovCommentFromExec(exec)) + case "document.final.reject": + updated, err = d.Gov.ApproverReject(ctx, docID, exec.Principal.UserID, docgov.RoleApprover, name, docgovReasonFromExec(exec), docgov.StateDrafting) + case "document.proposal.decide": + var prop *docgov.ChangeProposal + prop, err = d.Gov.DecideModificationProposal(ctx, proposalIDFromExec(exec), exec.Principal.UserID, docgovProposalApproveFromExec(exec), docgovReasonFromExec(exec)) + if err == nil && prop != nil { + return d.commitReceipt(exec, "docgov:proposal:"+prop.ProposalID, fmt.Sprintf("docgov:proposal:%s:%s", exec.ActionKey, prop.Status)) + } default: return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden } @@ -132,6 +146,57 @@ func (d *DocGovOwner) Execute(ctx context.Context, exec intentflow.StepExec) (in return rec, nil } +// commitReceipt 与主路径共用收据落盘(proposal 无 DocumentRecord 版本号可引用)。 +func (d *DocGovOwner) commitReceipt(exec intentflow.StepExec, ref, digest string) (intentflow.Receipt, error) { + rec := intentflow.Receipt{ + IdempotencyKey: exec.IdempotencyKey, + ActionDigest: exec.ActionDigest, + ResultRef: ref, + ResultDigest: digest, + SafeSummary: exec.ActionKey + " accepted", + } + d.mu.Lock() + defer d.mu.Unlock() + if existing, ok := d.receipts[exec.IdempotencyKey]; ok { + if existing.ActionDigest != exec.ActionDigest { + return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict + } + return existing, nil + } + d.receipts[exec.IdempotencyKey] = rec + return rec, nil +} + +func docgovCommentFromExec(exec intentflow.StepExec) string { + return slotValue(exec, "comment") +} + +func docgovReasonFromExec(exec intentflow.StepExec) string { + for _, key := range []string{"reason", "reject_reason", "decision_reason"} { + if v := slotValue(exec, key); v != "" { + return v + } + } + return "用户确认执行" +} + +func proposalIDFromExec(exec intentflow.StepExec) string { + return slotValue(exec, "proposal_id") +} + +func docgovProposalApproveFromExec(exec intentflow.StepExec) bool { + v := slotValue(exec, "approve") + if v == "" { + v = slotValue(exec, "proposal_action") + } + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "approve", "accept", "采纳": + return true + default: + return false + } +} + func (d *DocGovOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) { if d == nil { return intentflow.Receipt{}, false, nil diff --git a/internal/app/intentflow/expansion.go b/internal/app/intentflow/expansion.go index 9b11d9c5..81afbeb5 100644 --- a/internal/app/intentflow/expansion.go +++ b/internal/app/intentflow/expansion.go @@ -209,10 +209,59 @@ var runControlActionKeys = map[string]struct{}{ "run.restart": {}, } -// DocGovLiveActionKeys are the first U11 keys wired to the real docgov engine. -var DocGovLiveActionKeys = map[string]struct{}{ - "document.claim": {}, - "document.review.submit": {}, +// DocGovLiveActionKeys are the U11 keys wired to the real docgov engine. +// 基线键(claim / review.submit)随 U11 总 flag 放量;P8 逐键键(review approve/reject、 +// final.*、proposal.decide)须额外打开对应 LIVE_* flag 才进入 live 集合。 +func DocGovLiveActionKeys() map[string]struct{} { + keys := map[string]struct{}{ + "document.claim": {}, + "document.review.submit": {}, + } + f := CurrentFeatureFlags() + if f.U11LiveReview { + keys["document.review.approve"] = struct{}{} + keys["document.review.reject"] = struct{}{} + } + if f.U11LiveFinal { + keys["document.final.approve"] = struct{}{} + keys["document.final.reject"] = struct{}{} + } + if f.U11LiveProposal { + keys["document.proposal.decide"] = struct{}{} + } + return keys +} + +// RunControlLiveActionKeys are the U12 keys wired to the real run-control adapters. +// 基线键(workflow.run / run.resume)随 U12 总 flag 放量;P8 逐键键(workflow.switch、 +// run.restart)须额外打开对应 LIVE_* flag 才进入 live 集合。 +func RunControlLiveActionKeys() map[string]struct{} { + keys := map[string]struct{}{ + "workflow.run": {}, + "run.resume": {}, + } + f := CurrentFeatureFlags() + if f.U12LiveSwitch { + keys["workflow.switch"] = struct{}{} + } + if f.U12LiveRestart { + keys["run.restart"] = struct{}{} + } + return keys +} + +// IsDocGovLiveKey reports whether the docgov key routes to the live engine +// under current flags (used by adapters and tests). +func IsDocGovLiveKey(key string) bool { + _, ok := DocGovLiveActionKeys()[key] + return ok +} + +// IsRunControlLiveKey reports whether the run-control key routes to the live +// adapter under current flags. +func IsRunControlLiveKey(key string) bool { + _, ok := RunControlLiveActionKeys()[key] + return ok } // LiveOwners carries production adapters from the composition root (U10–U12). diff --git a/internal/app/intentflow/flags.go b/internal/app/intentflow/flags.go index 9b98d262..1b655fd2 100644 --- a/internal/app/intentflow/flags.go +++ b/internal/app/intentflow/flags.go @@ -29,6 +29,13 @@ type FeatureFlags struct { U10Mutate bool // MULTI_INTENT_U10_MUTATE — low/medium mutate adapters U11DocGov bool // MULTI_INTENT_U11_DOCGOV — docgov action adapters U12RunControl bool // MULTI_INTENT_U12_RUN_CONTROL — workflow/run-control adapters + // 逐键 live 放量 flag(P8):域名总 flag 开启后,细粒度键仍须单独打开才走真实引擎; + // 未打开的键保持 validated_only dry-run。默认全 OFF,禁止 full 一次放量。 + U11LiveReview bool // MULTI_INTENT_U11_LIVE_REVIEW — document.review.approve/reject + U11LiveFinal bool // MULTI_INTENT_U11_LIVE_FINAL — document.final.approve/reject + U11LiveProposal bool // MULTI_INTENT_U11_LIVE_PROPOSAL — document.proposal.decide + U12LiveSwitch bool // MULTI_INTENT_U12_LIVE_SWITCH — workflow.switch + U12LiveRestart bool // MULTI_INTENT_U12_LIVE_RESTART — run.restart } var ( @@ -44,6 +51,11 @@ func defaultFeatureFlags() FeatureFlags { U10Mutate: envBool("MULTI_INTENT_U10_MUTATE", false), U11DocGov: envBool("MULTI_INTENT_U11_DOCGOV", false), U12RunControl: envBool("MULTI_INTENT_U12_RUN_CONTROL", false), + U11LiveReview: envBool("MULTI_INTENT_U11_LIVE_REVIEW", false), + U11LiveFinal: envBool("MULTI_INTENT_U11_LIVE_FINAL", false), + U11LiveProposal: envBool("MULTI_INTENT_U11_LIVE_PROPOSAL", false), + U12LiveSwitch: envBool("MULTI_INTENT_U12_LIVE_SWITCH", false), + U12LiveRestart: envBool("MULTI_INTENT_U12_LIVE_RESTART", false), } } diff --git a/internal/app/intentflow/policy.go b/internal/app/intentflow/policy.go index cba24e8a..ff639ebb 100644 --- a/internal/app/intentflow/policy.go +++ b/internal/app/intentflow/policy.go @@ -120,6 +120,15 @@ func defaultBatchRows() []ActionBatchPolicy { "document.review.submit", "workflow.run", "run.resume", + // P8 逐键放量:confirm 档即可进入计划(真实执行仍受 per-key LIVE flag 限制; + // flag 未开时 Owner 落 dry-run validated_only)。 + "document.review.approve", + "document.review.reject", + "document.final.approve", + "document.final.reject", + "document.proposal.decide", + "workflow.switch", + "run.restart", } { add(ActionBatchPolicy{ ActionKey: key, diff --git a/internal/app/live_keys_rollout_test.go b/internal/app/live_keys_rollout_test.go new file mode 100644 index 00000000..03021f8b --- /dev/null +++ b/internal/app/live_keys_rollout_test.go @@ -0,0 +1,199 @@ +package app + +import ( + "context" + "testing" + + "chat-agent-system/internal/app/intentflow" + "chat-agent-system/internal/docgov" +) + +// P8 逐键放量:默认 flag 全关时,新键不得进入 live 集合(仍 dry-run validated_only)。 +func TestP8LiveKeysDefaultOff(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierConfirm, + }) + defer intentflow.ResetFeatureFlagsForTest() + + for _, key := range []string{ + "document.review.approve", "document.review.reject", + "document.final.approve", "document.final.reject", + "document.proposal.decide", + } { + if intentflow.IsDocGovLiveKey(key) { + t.Fatalf("docgov key %s must stay dry-run when LIVE flags off", key) + } + } + for _, key := range []string{"workflow.switch", "run.restart"} { + if intentflow.IsRunControlLiveKey(key) { + t.Fatalf("run-control key %s must stay dry-run when LIVE flags off", key) + } + } + // 基线键不受 per-key flag 影响。 + for _, key := range []string{"document.claim", "document.review.submit"} { + if !intentflow.IsDocGovLiveKey(key) { + t.Fatalf("baseline docgov key %s must stay live", key) + } + } + for _, key := range []string{"workflow.run", "run.resume"} { + if !intentflow.IsRunControlLiveKey(key) { + t.Fatalf("baseline run-control key %s must stay live", key) + } + } +} + +// 逐键 flag 打开后对应键进入 live 集合,其余仍关闭(禁 full 一次放量)。 +func TestP8LiveKeysPerKeyFlags(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierConfirm, + U11LiveReview: true, + U11LiveFinal: true, + U11LiveProposal: true, + U12LiveSwitch: true, + U12LiveRestart: true, + }) + defer intentflow.ResetFeatureFlagsForTest() + + for _, key := range []string{ + "document.review.approve", "document.review.reject", + "document.final.approve", "document.final.reject", + "document.proposal.decide", + } { + if !intentflow.IsDocGovLiveKey(key) { + t.Fatalf("docgov key %s must be live when flag on", key) + } + } + for _, key := range []string{"workflow.switch", "run.restart"} { + if !intentflow.IsRunControlLiveKey(key) { + t.Fatalf("run-control key %s must be live when flag on", key) + } + } + + // 只开 review:final/proposal 仍 dry-run。 + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierConfirm, + U11LiveReview: true, + }) + if !intentflow.IsDocGovLiveKey("document.review.approve") { + t.Fatal("review.approve should be live") + } + if intentflow.IsDocGovLiveKey("document.final.approve") { + t.Fatal("final.approve must stay dry-run when only review flag on") + } + if intentflow.IsDocGovLiveKey("document.proposal.decide") { + t.Fatal("proposal.decide must stay dry-run when only review flag on") + } +} + +// review.approve 打开后走真实引擎:文档从 in_review → pending_approval。 +func TestP8DocGovReviewApproveLive(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierConfirm, + U11DocGov: true, + U11LiveReview: true, + }) + defer intentflow.ResetFeatureFlagsForTest() + + engine := docgov.NewGovernanceEngine(nil) + ctx := context.Background() + doc, err := engine.CreateOrGetDocument(ctx, "doc_p8_review", 9, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P") + if err != nil { + t.Fatal(err) + } + if _, err := engine.SubmitForReview(ctx, doc.DocID, 1, docgov.RoleAuthor, "A"); err != nil { + t.Fatal(err) + } + cur, err := engine.GetDocument(ctx, doc.DocID) + if err != nil { + t.Fatal(err) + } + owner := NewDocGovOwner(engine, func(context.Context, int64, int64) error { return nil }, nil) + rec, err := owner.Execute(ctx, intentflow.StepExec{ + ActionKey: "document.review.approve", + IdempotencyKey: "p8-review-approve", + ActionDigest: "d1", + ExpectedResourceVersion: itoa(cur.Version), + Principal: intentflow.Principal{UserID: 2, ProjectID: 9, SessionID: "s"}, + SafeSummary: doc.DocID, + }) + if err != nil { + t.Fatalf("review approve live: %v", err) + } + if rec.ResultRef != "docgov:"+doc.DocID { + t.Fatalf("unexpected ref %s", rec.ResultRef) + } + after, _ := engine.GetDocument(ctx, doc.DocID) + if after.CurrentState != docgov.StateApproving { + t.Fatalf("state after approve = %s, want %s", after.CurrentState, docgov.StateApproving) + } + + // 幂等重放同键返回同一收据。 + rec2, err := owner.Execute(ctx, intentflow.StepExec{ + ActionKey: "document.review.approve", + IdempotencyKey: "p8-review-approve", + ActionDigest: "d1", + ExpectedResourceVersion: itoa(cur.Version), + Principal: intentflow.Principal{UserID: 2, ProjectID: 9, SessionID: "s"}, + SafeSummary: doc.DocID, + }) + if err != nil || rec2.IdempotencyKey != rec.IdempotencyKey { + t.Fatalf("idempotent replay: rec=%+v err=%v", rec2, err) + } +} + +// flag 关闭时 review.approve 落 dry-run(Forbidden 而非真实状态推进)。 +func TestP8DocGovReviewApproveDryRunWhenFlagOff(t *testing.T) { + intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{ + PlanTier: intentflow.TierConfirm, + U11DocGov: true, + }) + defer intentflow.ResetFeatureFlagsForTest() + + engine := docgov.NewGovernanceEngine(nil) + ctx := context.Background() + doc, err := engine.CreateOrGetDocument(ctx, "doc_p8_dry", 9, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P") + if err != nil { + t.Fatal(err) + } + owner := NewDocGovOwner(engine, func(context.Context, int64, int64) error { return nil }, nil) + rec, err := owner.Execute(ctx, intentflow.StepExec{ + ActionKey: "document.review.approve", + IdempotencyKey: "p8-dry", + ActionDigest: "d1", + ExpectedResourceVersion: "1", + Principal: intentflow.Principal{UserID: 2, ProjectID: 9, SessionID: "s"}, + SafeSummary: doc.DocID, + }) + if err != nil { + t.Fatalf("dry-run execute: %v", err) + } + if rec.ResultRef == "docgov:"+doc.DocID { + t.Fatal("flag off must not hit real engine receipt") + } + after, _ := engine.GetDocument(ctx, doc.DocID) + if after.CurrentState == docgov.StateApproving { + t.Fatal("dry-run must not advance document state") + } +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var b [20]byte + i := len(b) + for v > 0 { + i-- + b[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} diff --git a/internal/app/run_control_owner.go b/internal/app/run_control_owner.go index 2f374886..ead61836 100644 --- a/internal/app/run_control_owner.go +++ b/internal/app/run_control_owner.go @@ -13,9 +13,8 @@ import ( "chat-agent-system/internal/taskrun" ) -var runControlLiveKeys = map[string]struct{}{ - "workflow.run": {}, - "run.resume": {}, +func (r *RunControlOwner) isLiveKey(key string) bool { + return intentflow.IsRunControlLiveKey(key) } // RunControlOwner is the U12 live adapter for workflow.run and run.resume. @@ -41,11 +40,6 @@ func NewRunControlOwner(access ProjectAccess, pw *projectworkflow.Service, runs } } -func (r *RunControlOwner) isLiveKey(key string) bool { - _, ok := runControlLiveKeys[key] - return ok -} - func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error { if r == nil || r.Denied { return intentflow.ErrPlanActionForbidden @@ -61,7 +55,7 @@ func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExe return intentflow.ErrPlanActionForbidden } } - if exec.ActionKey == "run.resume" { + if exec.ActionKey == "run.resume" || exec.ActionKey == "run.restart" { workDir := slotValue(exec, "work_dir") if workDir == "" { workDir = strings.TrimSpace(exec.SafeSummary) @@ -70,6 +64,11 @@ func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExe return intentflow.ErrPlanActionForbidden } } + if exec.ActionKey == "workflow.switch" { + if slotValue(exec, "workflow_key") == "" { + return intentflow.ErrPlanActionForbidden + } + } return nil } @@ -177,6 +176,62 @@ func (r *RunControlOwner) runLive(ctx context.Context, exec intentflow.StepExec) return "", err } return created.RunID, nil + case "run.restart": + // 重启语义:不续用原 workspace,从 intake 重新起一个全新 run(幂等键防重复)。 + if r.TaskRuns == nil { + return "", fmt.Errorf("run control: taskrun manager missing") + } + if existing, err := r.TaskRuns.FindByIdempotencyKey(ctx, exec.IdempotencyKey); err != nil { + return "", err + } else if existing != nil && existing.RunID != "" { + return existing.RunID, nil + } + projectName := "" + if r.ProjectName != nil { + projectName, _ = r.ProjectName(ctx, exec.Principal.ProjectID) + } + runID := planRunID(exec.IdempotencyKey) + taskKey := slotValue(exec, "task_key") + if taskKey == "" { + taskKey = "intake" + } + rec := &taskrun.RunRecord{ + RunID: runID, + SessionID: exec.Principal.SessionID, + ProjectID: exec.Principal.ProjectID, + UserID: exec.Principal.UserID, + IdempotencyKey: exec.IdempotencyKey, + TaskKey: taskKey, + Status: "pending", + } + created, err := r.TaskRuns.CreateAndStart(ctx, runID, rec, bizdev.TaskRunRequest{ + TaskKey: taskKey, + UserID: exec.Principal.UserID, + ProjectID: exec.Principal.ProjectID, + SessionID: exec.Principal.SessionID, + ProjectName: projectName, + }, bizdev.StageRunRequest{}, "") + if err != nil { + return "", err + } + return created.RunID, nil + case "workflow.switch": + if r.Workflow == nil { + return "", fmt.Errorf("run control: workflow service missing") + } + workflowKey := slotValue(exec, "workflow_key") + if workflowKey == "" { + return "", intentflow.ErrPlanActionForbidden + } + res, err := r.Workflow.ActivateWorkflow(ctx, exec.Principal.ProjectID, exec.Principal.UserID, + projectworkflow.ActivateWorkflowInput{ + WorkflowKey: workflowKey, + Reason: slotValue(exec, "reason"), + }) + if err != nil { + return "", err + } + return fmt.Sprintf("workflow_switch:%d:%s", res.ProjectID, res.ActiveWorkflowKey), nil default: return "", intentflow.ErrPlanActionForbidden }