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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"chat-agent-system/internal/app/intentflow"
|
"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 {
|
func (d *DocGovOwner) isLiveKey(key string) bool {
|
||||||
_, ok := intentflow.DocGovLiveActionKeys[key]
|
return intentflow.IsDocGovLiveKey(key)
|
||||||
return ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DocGovOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
|
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)
|
updated, err = d.Gov.RecordDocumentViewed(ctx, docID, exec.Principal.UserID, role, name)
|
||||||
case "document.review.submit":
|
case "document.review.submit":
|
||||||
updated, err = d.Gov.SubmitForReview(ctx, docID, exec.Principal.UserID, role, name)
|
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:
|
default:
|
||||||
return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden
|
return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden
|
||||||
}
|
}
|
||||||
@@ -132,6 +146,57 @@ func (d *DocGovOwner) Execute(ctx context.Context, exec intentflow.StepExec) (in
|
|||||||
return rec, nil
|
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) {
|
func (d *DocGovOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
|
||||||
if d == nil {
|
if d == nil {
|
||||||
return intentflow.Receipt{}, false, nil
|
return intentflow.Receipt{}, false, nil
|
||||||
|
|||||||
@@ -209,10 +209,59 @@ var runControlActionKeys = map[string]struct{}{
|
|||||||
"run.restart": {},
|
"run.restart": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// DocGovLiveActionKeys are the first U11 keys wired to the real docgov engine.
|
// DocGovLiveActionKeys are the U11 keys wired to the real docgov engine.
|
||||||
var DocGovLiveActionKeys = map[string]struct{}{
|
// 基线键(claim / review.submit)随 U11 总 flag 放量;P8 逐键键(review approve/reject、
|
||||||
"document.claim": {},
|
// final.*、proposal.decide)须额外打开对应 LIVE_* flag 才进入 live 集合。
|
||||||
"document.review.submit": {},
|
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).
|
// 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
|
U10Mutate bool // MULTI_INTENT_U10_MUTATE — low/medium mutate adapters
|
||||||
U11DocGov bool // MULTI_INTENT_U11_DOCGOV — docgov action adapters
|
U11DocGov bool // MULTI_INTENT_U11_DOCGOV — docgov action adapters
|
||||||
U12RunControl bool // MULTI_INTENT_U12_RUN_CONTROL — workflow/run-control 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 (
|
var (
|
||||||
@@ -44,6 +51,11 @@ func defaultFeatureFlags() FeatureFlags {
|
|||||||
U10Mutate: envBool("MULTI_INTENT_U10_MUTATE", false),
|
U10Mutate: envBool("MULTI_INTENT_U10_MUTATE", false),
|
||||||
U11DocGov: envBool("MULTI_INTENT_U11_DOCGOV", false),
|
U11DocGov: envBool("MULTI_INTENT_U11_DOCGOV", false),
|
||||||
U12RunControl: envBool("MULTI_INTENT_U12_RUN_CONTROL", 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",
|
"document.review.submit",
|
||||||
"workflow.run",
|
"workflow.run",
|
||||||
"run.resume",
|
"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{
|
add(ActionBatchPolicy{
|
||||||
ActionKey: key,
|
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"
|
"chat-agent-system/internal/taskrun"
|
||||||
)
|
)
|
||||||
|
|
||||||
var runControlLiveKeys = map[string]struct{}{
|
func (r *RunControlOwner) isLiveKey(key string) bool {
|
||||||
"workflow.run": {},
|
return intentflow.IsRunControlLiveKey(key)
|
||||||
"run.resume": {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunControlOwner is the U12 live adapter for workflow.run and run.resume.
|
// 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 {
|
func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
|
||||||
if r == nil || r.Denied {
|
if r == nil || r.Denied {
|
||||||
return intentflow.ErrPlanActionForbidden
|
return intentflow.ErrPlanActionForbidden
|
||||||
@@ -61,7 +55,7 @@ func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExe
|
|||||||
return intentflow.ErrPlanActionForbidden
|
return intentflow.ErrPlanActionForbidden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if exec.ActionKey == "run.resume" {
|
if exec.ActionKey == "run.resume" || exec.ActionKey == "run.restart" {
|
||||||
workDir := slotValue(exec, "work_dir")
|
workDir := slotValue(exec, "work_dir")
|
||||||
if workDir == "" {
|
if workDir == "" {
|
||||||
workDir = strings.TrimSpace(exec.SafeSummary)
|
workDir = strings.TrimSpace(exec.SafeSummary)
|
||||||
@@ -70,6 +64,11 @@ func (r *RunControlOwner) Authorize(ctx context.Context, exec intentflow.StepExe
|
|||||||
return intentflow.ErrPlanActionForbidden
|
return intentflow.ErrPlanActionForbidden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if exec.ActionKey == "workflow.switch" {
|
||||||
|
if slotValue(exec, "workflow_key") == "" {
|
||||||
|
return intentflow.ErrPlanActionForbidden
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +176,62 @@ func (r *RunControlOwner) runLive(ctx context.Context, exec intentflow.StepExec)
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return created.RunID, nil
|
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:
|
default:
|
||||||
return "", intentflow.ErrPlanActionForbidden
|
return "", intentflow.ErrPlanActionForbidden
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user