merge: candidate u9u12-keys-rollout-2026-09-25 into dev
This commit is contained in:
@@ -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 行尾注。
|
||||
@@ -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:<docID>`;重放同收据 |
|
||||
| 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` 的自然语言触发证据仍欠(既有尾注遗留)。
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user