From b4699e9cbe2e24e4448c6c0652a45e62459a510a Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 07:42:48 +0000 Subject: [PATCH 01/10] docs(ops): start governance action-level authz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 5 is on local dev@19f6df5; claim the workflow mutex and register the implementation worktree for G1–G5. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 9 +- .../2026-09-14-mvp-governance-rules-review.md | 86 +++++++++++++++++++ ...026-09-14-governance-action-level-authz.md | 80 +++++++++++++++++ .doc/status/FEATURE-MATRIX.md | 2 +- 4 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 .doc/ops/research/2026-09-14-mvp-governance-rules-review.md create mode 100644 .doc/ops/short-term/2026-09-14-governance-action-level-authz.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 0b4f8166..94feaab8 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,9 +5,9 @@ ## 焦点 -1. **CHAT-INTENT**: [对话意图运行时状态](../audit/2026-09-13-conversation-intent-runtime-state.md)(`fix/conversation-intent-state`;与文档账本运行时无关) -2. **CHAT**: [对话历史刷新](../audit/2026-09-13-chat-history-refresh-disappears.md)(认领仍 `in_progress`;gap 行已 PASS,以代码/handoff 为准收敛) -3. **WORKFLOW-PLAN(已收口设置页)**: [冻结 409 与可选职责](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md)(页面矩阵 PASS;真实 PI 生成/审批长链路另立项) +1. **GOVERNANCE-AUTHZ**: [职责-动作鉴权](../ops/short-term/2026-09-14-governance-action-level-authz.md)(`fix/governance-action-level-authz`;基线 `dev@19f6df5`) +2. **CHAT-INTENT**: [对话意图运行时状态](../audit/2026-09-13-conversation-intent-runtime-state.md)(`fix/conversation-intent-state`;与文档账本运行时无关) +3. **CHAT**: [对话历史刷新](../audit/2026-09-13-chat-history-refresh-disappears.md)(认领仍 `in_progress`;gap 行已 PASS,以代码/handoff 为准收敛) ## 并行认领 @@ -47,10 +47,11 @@ | Cursor-Auto | document-revision-session-close | feature/document-revision-session-close | - | document-revision-session-close | done | | Cursor-Auto | vite-frp-allowed-hosts | fix/vite-frp-allowed-hosts | - | web-devserver | done | | implementer_cursor | workflow-plan-409-frozen-and-optional-duties | fix/workflow-plan-409-frozen-and-optional-duties | .worktrees/fix-workflow-plan-409-frozen-and-optional-duties | workflow | done | +| implementer_cursor | governance-action-level-authz | fix/governance-action-level-authz | .worktrees/fix-governance-action-level-authz | workflow | in_progress | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 -> workflow-plan 409 设置页已收口并释放集成槽。禁止终止或改写项目 143。真实 PI 生成/审批长链路另立项。 +> workflow-plan 409 设置页已收口。`governance-action-level-authz` 从 `dev@19f6df5` 开工;G1–G4 在 worktree 编码,G5 再占集成槽。禁止终止或改写项目 143。 ## 集成槽位 diff --git a/.doc/ops/research/2026-09-14-mvp-governance-rules-review.md b/.doc/ops/research/2026-09-14-mvp-governance-rules-review.md new file mode 100644 index 00000000..8822a7ec --- /dev/null +++ b/.doc/ops/research/2026-09-14-mvp-governance-rules-review.md @@ -0,0 +1,86 @@ +--- +title: "MVP 治理规则方案评审(零治理创建 / 禁止零治理激活与执行 / 职责-动作对应)" +status: done +created: 2026-09-14 +updated: 2026-09-14 +author: Cline +--- + +# MVP 治理规则方案评审(零治理创建 / 禁止零治理激活与执行 / 职责-动作对应) + +## 评审对象 + +用户转来的治理原则提案(外部评审稿,自称「仅完成审核判断,尚未修改此前落盘方案或业务代码」),核心主张: + +> 允许零治理创建项目和普通对话;禁止零治理激活和执行文档任务。文档任务按动作进行职责鉴权,编制、审核、批准只能由对应文档负责人执行,项目创建者不具有隐式越权能力。 + +## 结论(先说判断) + +**方向合理、证据属实、与已实施的 DutyMode 架构(Wave 1–4)高度兼容,建议采纳为治理总原则;但存在 4 个必须先决策/修正的分歧点,其中 A(none 文档可否生成)与 B(pre-activation 202 契约)若按提案字面实施,会直接推翻刚落地的已验收语义与测试。** + +## 证据核实矩阵(提案引用的代码全部属实) + +| # | 提案主张 | 核实结果 | 证据位置 | +|---|----------|----------|----------| +| 1 | 无治理实例时文档任务访问直接放行 | ✅ 属实 | `internal/projectinstance/document_access.go:49-51`(`gov == nil → return allow`);`stage_access_test.go:163` 断言该行为 | +| 2 | 创建者可绕过文档职责检查 | ✅ 属实 | `document_access.go:115-117`(`isCreator → allow`);`stage_access.go:65,67,79`(`allow = isCreator \|\| hasDuty`) | +| 3 | 只判断是否三职之一,不区分动作 | ✅ 属实 | `stage_access.go:96-100`(`docTaskDutyForUser` 任意一职即通过;`DocAccessAction*` 常量已存在于 `document_access.go:8-13` 但未与职责绑定) | +| 4 | `initial_workflow_key` 直通 active | ✅ 属实 | `internal/projectworkflow/service.go:57-83`(非空即 `LifecycleStatus="active"`);E2E `project_workflow_api_test.go:101-105` 依赖该旁路 | +| 5 | 创建弹窗默认勾选创建者+推荐审核/批准(成员角色≠文档职责) | ✅ 属实 | `web/src/components/project/CreateProjectModal.tsx:50-69`(creator/reviewer/approver 为项目成员角色) | +| 6 | 启用文档须完整三职(现行 validator) | ✅ 属实(主树) | `internal/projectinstance/validator.go:93-95`;已实施 worktree 已改为 DutyMode none/partial/full | + +## 与已实施代码的交叉分析(提案未覆盖的现场) + +上一轮整改(409 冻结 + 全有或全无)**已在 `fix/workflow-plan-409-frozen-and-optional-duties` worktree 实施**:Wave 1–4 已提交(`classifyDocDuty`/`DutyMode` 统一校验、门禁/readiness/访问一致语义、前端逐文档「纳入人工审批」开关),Wave 5(dev 真实浏览器验收)进行中(e2e spec 与 test-reports assets 未提交,集成槽位已占用)。dev 库实测:`133 active / 204 planning`,其中 **81 个 planning 项目带非空 `active_workflow_key`**(`initial_workflow_key` 旁路/历史迁移遗留的异态存量)——提案第 4 差距点在 dev 有真实影响面。 + +### 分歧点 A(最关键):none 模式文档可否生成 —— **已决策(2026-09-14,用户拍板)** + +- 提案:「编制、审核、批准全部为空:该文档**不启用,不可发起任务**,也不阻塞阶段」。 +- 已实施 D4(remediation 76-78 行):`DutyModeNone` = 文档**启用**、不要求 approved、不阻塞阶段,**项目创建者可触发生成和人工编辑**,其他成员只读。 +- **用户决策:保持现实现(none = 不走人工审批但可生成)**。提案的「不可发起任务」正式收窄为「不可发起**审批流**动作(提交审核/审核/批准)」,不推翻 Wave 4/5。后续实施以本决策为准。 + +### 分歧点 B:pre-activation 202 契约 + +- 提案:「未激活:不创建任务、不启动小队、不获取文档租约」。 +- 已验收契约:`ResolveMessageScopeForTask` 从未激活 → `ScopePreActivation`;`POST /api/v1/tasks/runs` 必须 **202**(`TestCreateTaskRun_PreActivationAcceptedNot409`,`internal/server/task_run_busy_preflight_test.go:75`);E2E UI-02 以 2xx 为硬门禁(409 即 FAIL)。 +- 按字面「不创建任务」会推翻该契约并破坏 E2E。**建议**修正表述为:保留 202 创建 run(进入 `blocked_pending_governance` 之类状态、不启动小队、不取租约),chat 流内返回自然语言提示(提案的示例文案即为此设计)。提案意图(不执行)与现状兼容,仅「不创建」三个字必须改。 + +### 分歧点 C:creator 越权移除的影响面与 view 范围 + +- 移除 `isCreator` 例外(`document_access.go:115`、`stage_access.go:65,67,79`)方向正确,但大量已验收测试断言 creator allow(如 `access_governing_pg_test.go:140`),需同步重写测试语义,工作量可控但必须列入计划。 +- 提案职责表「查看文档 = 具有项目访问权的成员」与 `document_access.go:102-104`(view 全放行)一致;但 `ResolveStageAccess` 的 UI 导航收窄会把无职责成员/创建者限制在职责阶段。需明确:view 豁免 stage 收窄(按提案表格),`AllowedStageKeys` 仅约束非 view 动作与任务发起入口。 + +### 分歧点 D:initial_workflow_key 旁路的存量处理 + +- 关闭入口正确(`projectworkflow/service.go:80-83` 改为仅挂载目录、不置 active),但需同步:`CreateProjectInput.InitialWorkflowKey` 字段废弃(`types.go:96`、前端 `web/src/types/projectWorkflow.ts:66`)、E2E legacy 用例(`project_workflow_api_test.go:101-105`)语义更新、以及 dev 库 81 个 planning+with_wf 异态项目的分类处置(至少审计:真 legacy / 测试数据,不得静默改库)。 + +## 提案其余要点的核实意见 + +- 三概念分离(创建/激活/执行)优于「创建即治理」:成立。创建者自动入成员已有实现(`CreateProjectModal` 默认勾选 + `ensureCreatedProjectWithMembers` 持久化),无死锁。 +- 「至少一个 full 文档才能激活;全部未启用可存草稿禁激活」:与已实施 Wave 2 校验(至少一份 `DutyModeFull` + active member 复核)**完全一致**,无需重复实施。 +- 「审核人退回 → 待编制人修订,审核人不得直接启动重编」:与职责-动作对应表一致,合理;现状 `docTaskDutyForUser` 不区分动作是真实缺口。 +- 「激活冻结提示留空文档不可补配」:与现有冻结语义一致,属 Wave 1 frozen 视图文案的自然扩展。 +- 「项目管理员」概念:现有成员角色枚举(creator/reviewer/approver/viewer)无 admin;提案未定义 role 模型如何演进(谁可配成员/职责目前是创建者,`IsProjectCreator` 权威)。建议 MVP 维持「创建者=项目管理员」,role 枚举演进留 backlog。 + +## 建议实施时序 + +1. 等 `fix/workflow-plan-409-frozen-and-optional-duties` Wave 5 验收收敛(验收中途改语义会导致返工与报告失真)。 +2. 用户先决策分歧点 A(其次 C 的 view 范围)。 +3. 采纳后新开 short-term(建议 `fix/governance-action-level-authz`,mutex=`workflow`),分波次:后端动作-职责映射(`CheckDocTaskAccess` 按动作判 author/reviewer/approver + 移除 creator 例外 + gov==nil 由「放行」改为「未激活拦截」但保留 202 契约)→ 关闭 `initial_workflow_key` 直通 → 前端交互与提示 → 存量异态审计 → E2E 更新。 + +## 采纳与派生记录(2026-09-14) + +- 分歧点 A:用户拍板「保持现实现(none 可生成)」——见上文。 +- 分歧点 B/C/D:按本评审建议采纳(B:保留 202 契约 + blocked 状态;C:view 豁免阶段收窄、creator 仅保留配置权与 none 生成触发;D:关闭直通 + 字段废弃 + 存量只审计不改库)。 +- 已派生实施任务:[2026-09-14-governance-action-level-authz.md](../short-term/2026-09-14-governance-action-level-authz.md)(status=blocked,等待 Wave 5 收敛;含采纳版职责-动作矩阵与 G1–G5 波次)。 +- Wave 5 现状(2026-09-14 实测):`implementer_cursor` 占集成槽位 phase=e2e,worktree 存在未提交 e2e 产物;新任务已排入槽位 queue。 + +## 相关链接 + +- Audit: [2026-09-14-workflow-plan-409-frozen-and-optional-duties.md](../../audit/2026-09-14-workflow-plan-409-frozen-and-optional-duties.md) +- Remediation(已实施版): [2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md](../../remediation/2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md) +- 先例: [2026-09-13-workflow-access-chat-members-ui01-02.md](../../audit/2026-09-13-workflow-access-chat-members-ui01-02.md)(202 契约与三态作用域来源) + +## 备注 + +主树 `.doc/ops/CURRENT.md`(`feature/remove-herdr-agent-settings` 分支)焦点 1 仍写「worktree 尚未创建、blocked」,已过时:worktree 已存在且 Wave 1–4 落地、Wave 5 进行中。该更新属实施分支职责,本评审不跨分支代改,仅在此记录提醒收敛时同步。 diff --git a/.doc/ops/short-term/2026-09-14-governance-action-level-authz.md b/.doc/ops/short-term/2026-09-14-governance-action-level-authz.md new file mode 100644 index 00000000..f5dfb5ca --- /dev/null +++ b/.doc/ops/short-term/2026-09-14-governance-action-level-authz.md @@ -0,0 +1,80 @@ +--- +title: "治理鉴权升级:职责-动作严格对应 + 未激活拦截 + 关闭创建直通" +status: in_progress +created: 2026-09-14 +updated: 2026-09-14 +owner: implementer_cursor +branch: fix/governance-action-level-authz +worktree: ".worktrees/fix-governance-action-level-authz" +mutex: workflow +--- + +# 治理鉴权升级:职责-动作严格对应 + 未激活拦截 + 关闭创建直通 + +## 目标 + +采纳 [MVP 治理规则评审](../../ops/research/2026-09-14-mvp-governance-rules-review.md) 修正版原则:**允许零治理创建与普通对话;禁止零治理激活与执行文档任务(审批流动作)**。文档动作按职责鉴权,创建者不隐式越权,`initial_workflow_key` 不再直通 active。 + +## 决策记录(已定,不再开放) + +- **A(用户拍板 2026-09-14)**:`DutyModeNone` 文档 = 不走人工审批但**可生成**(创建者触发,维持已实施 D4);「不可发起任务」收窄为不可发起**审批流动作**(submit/review/approve)。 +- **B(按评审建议采纳)**:pre-activation 保留 202 契约——`POST /tasks/runs` 仍 202 创建 run,但置 blocked 状态、不启动小队、不取文档租约,chat 流内返回自然语言治理提示。 +- **C(按评审建议采纳)**:移除 `isCreator` 对 full 文档的隐式全通行;`view` 豁免阶段收窄(项目访问成员可查看);创建者保留:配置成员/职责 + none 文档生成触发。 +- **D(按评审建议采纳)**:关闭 `initial_workflow_key` 直通 active(仅挂载目录);后端/前端 types 字段废弃标注;E2E legacy 用例语义更新;dev 库 81 个 planning+with_wf 异态项目**只审计分类,不改库**。 + +## 采纳版职责-动作矩阵 + +| 动作 | full 文档 | none 文档 | 未激活(gov==nil) | +|------|-----------|-----------|--------------------| +| view | 项目访问成员 | 项目访问成员 | 项目访问成员 | +| run(首次生成/重生成) | 编制负责人 | **创建者**(无审批链) | 202 + blocked 状态,chat 内提示,不启小队/不取租约 | +| edit/submit(编辑/提交审核) | 编制负责人 | 拒绝(无审批链) | 拒绝 | +| review(审核/退回) | 审核负责人 | 拒绝 | 拒绝 | +| approve(批准/拒绝批准) | 批准负责人 | 拒绝 | 拒绝 | +| 配置成员/文档职责 | 创建者 | 创建者 | 创建者(planning 期配置正是本阶段语义) | + +退回后形成「待编制人修订」;审核人不得直接启动重编(由编制负责人发起修订)。 + +## 范围 + +- `internal/projectinstance/document_access.go`(动作-职责映射、creator 例外移除、gov==nil 未激活拦截) +- `internal/projectinstance/stage_access.go`(view 豁免语义;`docTaskDutyForUser` 拆分为按职责判定) +- `internal/projectinstance/message_scope.go` + taskrun/chat handler(blocked 状态 + 自然语言提示) +- `internal/projectworkflow/service.go`(关闭 `initial_workflow_key` 直通)+ `types.go` 前后端字段废弃 +- 受影响单测重写(`access_governing_pg_test.go` creator 断言、`TestCreateTaskRun_PreActivationAcceptedNot409` 语义细化)与 E2E legacy 用例 + +## 非目标 + +- 不改三权互斥与 DutyMode 校验(Wave 2 已完成) +- 不做运行中动态补配职责 / 按文档单独激活(冻结语义维持,激活页提示「留空文档不参与、不可补配」) +- 不做成员 role 枚举演进(MVP 维持创建者=项目管理员;role 演进留 backlog) +- 不改 dev 库存量数据(仅审计) + +## 步骤 + +1. [x] 评审落盘 + 决策 A–D 收敛 + 本任务登记(Wave 5 已合入本地 `dev@19f6df5`,G0 已开工) +2. [ ] G1 后端:`CheckDocTaskAccess` 动作-职责映射 + creator 例外移除(保留 none 生成触发)+ gov==nil 未激活拦截(202 契约保留);重写受影响单测 +3. [ ] G2 关闭 `initial_workflow_key` 直通 + 前后端字段废弃 + E2E legacy 用例更新 +4. [ ] G3 前端:chat 治理拦截自然语言提示、按钮级职责禁用、激活确认页冻结提示补强 +5. [ ] G4 dev 库 81 个 planning+with_wf 异态审计分类(只读报告) +6. [ ] G5 集成验证 + 测试报告归档(新项目全链路:零治理创建→对话→配置→激活→职责动作负例) + +## 验收标准 + +- [ ] full 文档 API 负例:author 不可 review/approve;reviewer 不可 run/edit/submit;approver 不可 run/edit/review;creator 不可任一审批动作(view 除外) +- [ ] none 文档:创建者可触发生成;submit/review/approve 全拒 +- [ ] 未激活项目 chat 输入「帮我生成立项文档」:消息正常显示、run 202 且 blocked、无小队/租约、chat 内明确治理提示文案 +- [ ] `POST /projects` 带 `initial_workflow_key`:不再置 active,工作流目录仍自动挂载 +- [ ] `go vet ./...`、`go test -short ./...`、`cd web && npm test && npm run build` 全绿 +- [ ] 异态审计报告与测试报告归档并回链 + +## 相关链接 + +- Research(评审与决策记录): [2026-09-14-mvp-governance-rules-review.md](../research/2026-09-14-mvp-governance-rules-review.md) +- 前序 Remediation(已实施 Wave 1–4): [2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md](../../remediation/2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md) +- 202 契约来源: [2026-09-13-workflow-access-chat-members-ui01-02.md](../../audit/2026-09-13-workflow-access-chat-members-ui01-02.md) + +## 证据 / 备注 + +- Wave 5 已合入本地 `dev@19f6df5`;本任务 worktree 已从该 `dev` 创建,mutex `workflow` 进入 `in_progress`。 +- dev 库:133 active / 204 planning;81 个 planning 项目带非空 `active_workflow_key`(待 G4 审计分类)。 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 0721c1d0..8d34de00 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -18,7 +18,7 @@ | 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 明确写动作与只读总结已分流;完整 hard-negative/eval 集仍待补齐 | `intent` | | 对话 / ctxmgr | 多轮上下文、记忆、摘要 | partial | [system-design/multi-turn-dialogue](../system-design/multi-turn-dialogue/);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | `CHAT-HISTORY-REFRESH`:作用域写入、同集授权统计、历史回填;语义摘要(D5 backlog) | `chat` | | 智能体小队 | 角色-任务-工作区-黑板 | done | [system-design/agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6 backlog) | `squad` | -| 工作流治理 | 三正式工作流 + 阶段门禁 | partial | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md);[冻结态/职责可选整改](../remediation/2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md);[Wave 5 页面矩阵](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md) | 三正式工作流、冻结只读页、DutyMode 与可选「纳入人工审批」已合入并完成设置页真实验收。仍 `partial`:未跑 full/none 文档的真实 PI 生成与审批/阶段不阻塞长链路 | `workflow` | +| 工作流治理 | 三正式工作流 + 阶段门禁 | partial | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md);[冻结态/职责可选整改](../remediation/2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md);[Wave 5 页面矩阵](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[职责-动作鉴权任务](../ops/short-term/2026-09-14-governance-action-level-authz.md) | 设置页/DutyMode 已收口。后续:动作级鉴权、未激活 202-blocked、关闭创建直通(G1–G5) | `workflow` | | 纯软件项目自举 | 软件项目创建、Bootstrap 基线、Delivery 迭代与受控工程交接 | planned | [详细设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[现状调研](../ops/research/2026-09-14-software-project-bootstrap-current-state.md);[验收计划](../ops/checklists/2026-09-14-software-project-bootstrap-acceptance.md) | 当前仅完成方案;目录/模板/执行器/创建向导/真实 PI UI 均未实施。受控代码执行单列后续 Epic | `workflow-design` | | IAM / 鉴权 | 项目级 API 鉴权 + RBAC | partial | [audit/authorization](../audit/authorization-matrix-v2.md);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | 历史读取端点补统一会话 owner + 阶段授权;旧 API 410 标注(BUG-11 partial) | `authz` | | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[项目 143 正文 404 审计](../audit/2026-09-14-project143-controlled-content-404.md);[受控正文真实 UI 验收报告](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[UI-06 缓存竞争审计](../audit/2026-09-14-document-revision-refresh-cache-race.md);[整改](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 生命周期/租约保留。W1–W6 + 审查修补 + UI hydrate 资格 + 晚到 list sync 修复均已合入 dev。项目 143 纯 UI 回归(clean-run3)与新项目全链路(main + post-restart)已全量真实页面验证通过(PASS) | `docgov` | From 66d2a1837f51d2a2efe546b68be6c565ff367e78 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 07:54:43 +0000 Subject: [PATCH 02/10] test(projectinstance): pin action-level duty checks Lock the full/none/pre-activation matrix so creator no longer implies document-task privilege and unactivated runs stay WORKFLOW_NOT_ACTIVATED. Co-authored-by: Cursor --- .../projectinstance/document_access_test.go | 50 ++++++++ internal/projectinstance/duty_action.go | 107 ++++++++++++++++++ internal/projectinstance/duty_action_test.go | 92 +++++++++++++++ internal/projectinstance/stage_access_test.go | 18 ++- 4 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 internal/projectinstance/duty_action.go create mode 100644 internal/projectinstance/duty_action_test.go diff --git a/internal/projectinstance/document_access_test.go b/internal/projectinstance/document_access_test.go index 728f779a..872cd564 100644 --- a/internal/projectinstance/document_access_test.go +++ b/internal/projectinstance/document_access_test.go @@ -102,4 +102,54 @@ func TestCheckDocTaskAccess_UnmanagedDutyModes(t *testing.T) { if approve.Allowed || approve.Code != AccessCodeDutyNotManaged { t.Fatalf("approve on none must be DOC_DUTY_NOT_MANAGED, got %+v", approve) } + submit := svc.CheckDocTaskAccess(ctx, 402, 1, "research", DocAccessActionSubmit, true) + if submit.Allowed || submit.Code != AccessCodeDutyNotManaged { + t.Fatalf("submit on none must be DOC_DUTY_NOT_MANAGED, got %+v", submit) + } +} + +func TestCheckDocTaskAccess_FullActionMatrix(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 403, LifecyclePlanning, "", 1, 0) + svc := NewService(store, TestResolver(), nil, nil) + plan := ValidTestPlan() + plan.Stages[0].DocTasks[0].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3} + if _, err := svc.SaveWorkflowPlan(ctx, 403, 9, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 403, 9, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + if _, err := svc.Activate(ctx, 403, 9, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate: %v", err) + } + + assert := func(userID int64, isCreator bool, action string, wantAllow bool, wantCode string) { + t.Helper() + got := svc.CheckDocTaskAccess(ctx, 403, userID, "intake", action, isCreator) + if got.Allowed != wantAllow || (wantCode != "" && got.Code != wantCode) { + t.Fatalf("user=%d creator=%v action=%s got %+v want allow=%v code=%s", userID, isCreator, action, got, wantAllow, wantCode) + } + } + assert(1, false, DocAccessActionRun, true, "") + assert(1, false, DocAccessActionReview, false, AccessCodeForbiddenDuty) + assert(1, false, DocAccessActionApprove, false, AccessCodeForbiddenDuty) + assert(2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty) + assert(2, false, DocAccessActionSubmit, false, AccessCodeForbiddenDuty) + assert(2, false, DocAccessActionReview, true, "") + assert(3, false, DocAccessActionRun, false, AccessCodeForbiddenDuty) + assert(3, false, DocAccessActionReview, false, AccessCodeForbiddenDuty) + assert(3, false, DocAccessActionApprove, true, "") + assert(9, true, DocAccessActionView, true, "") + assert(9, true, DocAccessActionRun, false, AccessCodeForbiddenDuty) + assert(9, true, DocAccessActionSubmit, false, AccessCodeForbiddenDuty) + assert(9, true, DocAccessActionReview, false, AccessCodeForbiddenDuty) + assert(9, true, DocAccessActionApprove, false, AccessCodeForbiddenDuty) } diff --git a/internal/projectinstance/duty_action.go b/internal/projectinstance/duty_action.go new file mode 100644 index 00000000..d6311d33 --- /dev/null +++ b/internal/projectinstance/duty_action.go @@ -0,0 +1,107 @@ +package projectinstance + +const ( + DutyRoleAuthor = "author" + DutyRoleReviewer = "reviewer" + DutyRoleApprover = "approver" + + AccessCodeNotActivated = "WORKFLOW_NOT_ACTIVATED" + + GovernanceBlockedHint = "当前项目尚未激活工作流,文档任务不会启动。请先在设置中配置并激活至少一份纳入人工审批的文档。" +) + +// requiredDutyRole maps a document action to the single duty that may perform it. +func requiredDutyRole(action string) string { + switch action { + case DocAccessActionRun, DocAccessActionEdit, DocAccessActionSubmit: + return DutyRoleAuthor + case DocAccessActionReview: + return DutyRoleReviewer + case DocAccessActionApprove: + return DutyRoleApprover + default: + return "" + } +} + +func userHoldsDuty(d DocDuty, userID int64, role string) bool { + if userID <= 0 || role == "" { + return false + } + switch role { + case DutyRoleAuthor: + return d.AuthorUserID == userID + case DutyRoleReviewer: + return d.ReviewerUserID == userID + case DutyRoleApprover: + return d.ApproverUserID == userID + default: + return false + } +} + +// classifyAccess applies the locked duty-action matrix. govLive=false is pre-activation. +func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool, govLive bool) DocAccessDecision { + allow := DocAccessDecision{Allowed: true} + if action == DocAccessActionView { + return allow + } + if !govLive { + if action == DocAccessActionRun { + return DocAccessDecision{ + Allowed: true, + Code: AccessCodeNotActivated, + Message: GovernanceBlockedHint, + } + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeNotActivated, + Message: GovernanceBlockedHint, + } + } + if !dt.Enabled { + return DocAccessDecision{Allowed: false, Code: AccessCodeDocTaskDisabled, Message: "该文档已被创建者禁用"} + } + switch classifyDocDuty(dt.Duties) { + case DutyModeNone: + switch action { + case DocAccessActionRun, DocAccessActionEdit: + if isCreator { + return allow + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "未纳入人工审批的文档仅创建者可编辑或执行", + } + case DocAccessActionReview, DocAccessActionApprove, DocAccessActionSubmit: + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeDutyNotManaged, + Message: "该文档未纳入人工审批,不能审核、批准或提交审批", + } + } + case DutyModePartial: + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "文档职责未完整指派,不能执行该动作", + } + case DutyModeFull: + role := requiredDutyRole(action) + if userHoldsDuty(dt.Duties, userID, role) { + return allow + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "当前用户不是该文档的对应职责人", + } + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "当前用户不是该文档职责人", + } +} diff --git a/internal/projectinstance/duty_action_test.go b/internal/projectinstance/duty_action_test.go new file mode 100644 index 00000000..52642c48 --- /dev/null +++ b/internal/projectinstance/duty_action_test.go @@ -0,0 +1,92 @@ +package projectinstance + +import "testing" + +func TestRequiredDutyRole(t *testing.T) { + if requiredDutyRole(DocAccessActionRun) != DutyRoleAuthor { + t.Fatal("run -> author") + } + if requiredDutyRole(DocAccessActionEdit) != DutyRoleAuthor { + t.Fatal("edit -> author") + } + if requiredDutyRole(DocAccessActionSubmit) != DutyRoleAuthor { + t.Fatal("submit -> author") + } + if requiredDutyRole(DocAccessActionReview) != DutyRoleReviewer { + t.Fatal("review -> reviewer") + } + if requiredDutyRole(DocAccessActionApprove) != DutyRoleApprover { + t.Fatal("approve -> approver") + } + if requiredDutyRole(DocAccessActionView) != "" { + t.Fatal("view has no duty role") + } +} + +func TestClassifyAccess_FullDutyMatrix(t *testing.T) { + dt := DocTaskPlan{Enabled: true, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}} + creatorID := int64(9) + + cases := []struct { + name string + userID int64 + isCreator bool + action string + wantAllow bool + wantCode string + }{ + {"author run", 1, false, DocAccessActionRun, true, ""}, + {"author edit", 1, false, DocAccessActionEdit, true, ""}, + {"author submit", 1, false, DocAccessActionSubmit, true, ""}, + {"author review denied", 1, false, DocAccessActionReview, false, AccessCodeForbiddenDuty}, + {"author approve denied", 1, false, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, + {"reviewer run denied", 2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"reviewer edit denied", 2, false, DocAccessActionEdit, false, AccessCodeForbiddenDuty}, + {"reviewer submit denied", 2, false, DocAccessActionSubmit, false, AccessCodeForbiddenDuty}, + {"reviewer review", 2, false, DocAccessActionReview, true, ""}, + {"approver run denied", 3, false, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"approver edit denied", 3, false, DocAccessActionEdit, false, AccessCodeForbiddenDuty}, + {"approver review denied", 3, false, DocAccessActionReview, false, AccessCodeForbiddenDuty}, + {"approver approve", 3, false, DocAccessActionApprove, true, ""}, + {"creator view", creatorID, true, DocAccessActionView, true, ""}, + {"creator run denied", creatorID, true, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"creator submit denied", creatorID, true, DocAccessActionSubmit, false, AccessCodeForbiddenDuty}, + {"creator review denied", creatorID, true, DocAccessActionReview, false, AccessCodeForbiddenDuty}, + {"creator approve denied", creatorID, true, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, + {"reviewer cannot re-run after reject", 2, false, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + } + for _, tc := range cases { + got := classifyAccess(dt, tc.action, tc.userID, tc.isCreator, true) + if got.Allowed != tc.wantAllow || (tc.wantCode != "" && got.Code != tc.wantCode) { + t.Fatalf("%s: got %+v want allow=%v code=%s", tc.name, got, tc.wantAllow, tc.wantCode) + } + } +} + +func TestClassifyAccess_NoneAndPreActivation(t *testing.T) { + none := DocTaskPlan{Enabled: true, Duties: DocDuty{}} + creatorRun := classifyAccess(none, DocAccessActionRun, 1, true, true) + if !creatorRun.Allowed { + t.Fatalf("creator run on none: %+v", creatorRun) + } + creatorEdit := classifyAccess(none, DocAccessActionEdit, 1, true, true) + if !creatorEdit.Allowed { + t.Fatalf("creator edit on none: %+v", creatorEdit) + } + memberRun := classifyAccess(none, DocAccessActionRun, 2, false, true) + if memberRun.Allowed || memberRun.Code != AccessCodeForbiddenDuty { + t.Fatalf("member run on none: %+v", memberRun) + } + submit := classifyAccess(none, DocAccessActionSubmit, 1, true, true) + if submit.Allowed || submit.Code != AccessCodeDutyNotManaged { + t.Fatalf("submit on none: %+v", submit) + } + preRun := classifyAccess(none, DocAccessActionRun, 1, true, false) + if !preRun.Allowed || preRun.Code != AccessCodeNotActivated { + t.Fatalf("pre-activation run: %+v", preRun) + } + preSubmit := classifyAccess(none, DocAccessActionSubmit, 1, true, false) + if preSubmit.Allowed || preSubmit.Code != AccessCodeNotActivated { + t.Fatalf("pre-activation submit: %+v", preSubmit) + } +} diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index 14142d0a..b58a35dc 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -24,6 +24,9 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { if access.TaskKeyAllowed("intake") { t.Fatal("user without duty should not access active stage docs") } + if !access.TaskKeyVisible("intake") { + t.Fatal("view must stay open for enabled docs even without duty") + } authorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, false) if !authorAccess.TaskKeyAllowed("intake") { @@ -70,11 +73,14 @@ func TestResolveStageAccess_UnmanagedDocsStayVisible(t *testing.T) { if err != nil { t.Fatal(err) } - if !member.StageKeyAllowed("V0.1_triage") || !member.TaskKeyAllowed("research") { + if !member.TaskKeyVisible("research") { t.Fatalf("none docs must stay visible to members, got %+v", member) } + if member.TaskKeyAllowed("research") { + t.Fatal("members must not launch unmanaged docs") + } if member.TaskKeyAllowed("intake") { - t.Fatal("members without intake duty must not see the governed task") + t.Fatal("members without intake duty must not act on the governed task") } } @@ -198,8 +204,12 @@ func TestCheckDocTaskAccess_NoGoverningAllows(t *testing.T) { _ = SeedProjectMeta(store, 80, LifecyclePlanning, "", 1, 0) svc := NewService(store, testResolver(), nil, nil) d := svc.CheckDocTaskAccess(ctx, 80, 1, "intake", DocAccessActionRun, true) - if !d.Allowed { - t.Fatalf("expected allow without governing instance, got %+v", d) + if !d.Allowed || d.Code != AccessCodeNotActivated { + t.Fatalf("pre-activation run must allow with WORKFLOW_NOT_ACTIVATED, got %+v", d) + } + submit := svc.CheckDocTaskAccess(ctx, 80, 1, "intake", DocAccessActionSubmit, true) + if submit.Allowed || submit.Code != AccessCodeNotActivated { + t.Fatalf("pre-activation submit must deny, got %+v", submit) } } From 83841369893a3921a9c543125c345b437518e710 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 07:54:43 +0000 Subject: [PATCH 03/10] feat(projectinstance): map document actions to duties Route run/edit/submit/review/approve to a single duty, drop full-doc creator bypass, and keep view listing independent of stage narrowing. Co-authored-by: Cursor --- internal/projectinstance/document_access.go | 38 ++--------------- internal/projectinstance/stage_access.go | 47 ++++++++++++++------- 2 files changed, 36 insertions(+), 49 deletions(-) diff --git a/internal/projectinstance/document_access.go b/internal/projectinstance/document_access.go index 8e29aa60..f92480ab 100644 --- a/internal/projectinstance/document_access.go +++ b/internal/projectinstance/document_access.go @@ -28,8 +28,8 @@ type DocAccessDecision struct { // CheckDocTaskAccess 对治理实例下的文档任务做判定。 // -// GetGoverningInstanceForAccess:从未激活 → 允许(legacy);completed/terminated → InstanceClosed; -// 存储错误 fail-closed;live 下对非职责/非活动阶段拒绝 run/edit。 +// GetGoverningInstanceForAccess:从未激活 → view 放行、run 带 WORKFLOW_NOT_ACTIVATED(HTTP 转 202-blocked)、其余拒绝; +// completed/terminated → InstanceClosed;存储错误 fail-closed。full 文档按动作对应单一职责,创建者不隐式越权。 func (s *Service) CheckDocTaskAccess( ctx context.Context, projectID int64, @@ -48,7 +48,7 @@ func (s *Service) CheckDocTaskAccess( return DocAccessDecision{Allowed: false, Code: AccessCodeInstanceClosed, Message: "工作流实例不可用"} } if gov == nil { - return allow + return classifyAccess(DocTaskPlan{Enabled: true}, action, userID, isCreator, false) } if IsClosedInstanceStatus(gov.Status) { return DocAccessDecision{ @@ -113,37 +113,7 @@ func (s *Service) CheckDocTaskAccess( } } - if classifyDocDuty(planTask.Duties) == DutyModeNone { - switch action { - case DocAccessActionRun, DocAccessActionEdit: - if isCreator { - return allow - } - return DocAccessDecision{ - Allowed: false, - Code: AccessCodeForbiddenDuty, - Message: "未纳入人工审批的文档仅创建者可编辑或执行", - } - case DocAccessActionReview, DocAccessActionApprove, DocAccessActionSubmit: - return DocAccessDecision{ - Allowed: false, - Code: AccessCodeDutyNotManaged, - Message: "该文档未纳入人工审批,不能审核、批准或提交审批", - } - } - } - - if isCreator { - return allow - } - if docTaskDutyForUser(*planTask, userID) { - return allow - } - return DocAccessDecision{ - Allowed: false, - Code: AccessCodeForbiddenDuty, - Message: "当前用户不是该文档职责人", - } + return classifyAccess(*planTask, action, userID, isCreator, true) } func findDocTaskPlan(plan WorkflowPlan, taskKey string) *DocTaskPlan { diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index 69246f62..cea85491 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -6,10 +6,11 @@ import ( // StageAccess 用户在某项目实例下可访问的阶段与文档任务范围。 type StageAccess struct { - Restricted bool `json:"restricted"` - ActiveStageKey string `json:"active_stage_key,omitempty"` - AllowedStageKeys map[string]bool `json:"allowed_stage_keys,omitempty"` - AllowedTaskKeys map[string]bool `json:"allowed_task_keys,omitempty"` + Restricted bool `json:"restricted"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + AllowedStageKeys map[string]bool `json:"allowed_stage_keys,omitempty"` + AllowedTaskKeys map[string]bool `json:"allowed_task_keys,omitempty"` + ViewableTaskKeys map[string]bool `json:"viewable_task_keys,omitempty"` } // ResolveStageAccess 基于治理实例与三权指派计算阶段 ABAC 范围。 @@ -23,6 +24,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI out := &StageAccess{ AllowedStageKeys: make(map[string]bool), AllowedTaskKeys: make(map[string]bool), + ViewableTaskKeys: make(map[string]bool), } if s == nil || s.store == nil || projectID <= 0 || userID <= 0 { return out, nil @@ -60,16 +62,20 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI status := stageStatus[stPlan.StageKey] hasDuty := stageDutyForUser(stPlan, userID) hasUnmanaged := stageHasUnmanagedDoc(stPlan) - allow := false - switch status { - case StageActive: - allow = isCreator || hasDuty || hasUnmanaged - case StageCompleted: - allow = hasDuty || isCreator || hasUnmanaged - default: - allow = false + // View exemption: every enabled doc is listable. Action keys stay duty/creator-none. + for _, dt := range stPlan.DocTasks { + if dt.Enabled { + out.ViewableTaskKeys[dt.TaskKey] = true + } } - if !allow { + allowStage := false + switch status { + case StageActive, StageCompleted: + allowStage = hasDuty || (isCreator && hasUnmanaged) + default: + allowStage = false + } + if !allowStage { continue } out.AllowedStageKeys[stPlan.StageKey] = true @@ -77,7 +83,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI if !dt.Enabled { continue } - if isCreator || docTaskDutyForUser(dt, userID) || classifyDocDuty(dt.Duties) == DutyModeNone { + if docTaskDutyForUser(dt, userID) || (isCreator && classifyDocDuty(dt.Duties) == DutyModeNone) { out.AllowedTaskKeys[dt.TaskKey] = true } } @@ -112,7 +118,7 @@ func stageHasUnmanagedDoc(st StagePlan) bool { return false } -// TaskKeyAllowed 判断 task_key 是否在允许范围内。 +// TaskKeyAllowed 判断 task_key 是否可发起非 view 动作。 func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { if a == nil || !a.Restricted { return true @@ -120,6 +126,17 @@ func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { return a.AllowedTaskKeys[taskKey] } +// TaskKeyVisible 判断文档是否对项目成员可见(view 豁免阶段收窄)。 +func (a *StageAccess) TaskKeyVisible(taskKey string) bool { + if a == nil || !a.Restricted { + return true + } + if len(a.ViewableTaskKeys) == 0 { + return a.AllowedTaskKeys[taskKey] + } + return a.ViewableTaskKeys[taskKey] +} + // StageKeyAllowed 判断 stage_key 是否在允许范围内。 func (a *StageAccess) StageKeyAllowed(stageKey string) bool { if a == nil || !a.Restricted || stageKey == "" { From 295ad89860ec33133bdd13370068eaf85071f914 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 07:54:43 +0000 Subject: [PATCH 04/10] feat(api): block pre-activation runs without starting squads Keep 202 for unactivated document runs, persist blocked_pending_governance, and refuse submit/review/approve/edit until a live workflow exists. Co-authored-by: Cursor --- internal/server/document_revision_access.go | 4 ++ internal/server/handlers.go | 10 +++ internal/server/stage_access_helpers.go | 35 ++++++--- .../server/task_run_busy_preflight_test.go | 61 ++++++++++++++++ internal/server/task_run_handlers.go | 72 ++++++++++++++++++- 5 files changed, 170 insertions(+), 12 deletions(-) diff --git a/internal/server/document_revision_access.go b/internal/server/document_revision_access.go index 71ae6db7..4a12e246 100644 --- a/internal/server/document_revision_access.go +++ b/internal/server/document_revision_access.go @@ -12,6 +12,7 @@ import ( "chat-agent-system/internal/authz" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/freedoc" + "chat-agent-system/internal/projectinstance" ) type docRevisionRef struct { @@ -107,6 +108,9 @@ func (s *Server) requireDocRevisionAccess(w http.ResponseWriter, r *http.Request writeDocRevisionError(w, err) return nil, "", nil, false } + if write && !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, docID, projectinstance.DocAccessActionEdit) { + return nil, "", nil, false + } return user, docID, ref, true } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 3947b016..09bbe592 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -13,6 +13,7 @@ import ( "chat-agent-system/internal/bizdev" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/squad" "chat-agent-system/internal/taskrun" @@ -588,6 +589,9 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) return } + if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionSubmit) { + return + } doc, err := s.svc.DocGov().SubmitForReview(r.Context(), req.DocID, user.ID, docgov.RoleAuthor, user.Name) if err != nil { @@ -626,6 +630,9 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) return } + if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionReview) { + return + } action := strings.ToLower(strings.TrimSpace(req.Action)) var doc *docgov.DocumentRecord @@ -669,6 +676,9 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) return } + if !s.enforceDocGovWorkflowAction(w, r.Context(), user.ID, req.DocID, projectinstance.DocAccessActionApprove) { + return + } action := strings.ToLower(strings.TrimSpace(req.Action)) var doc *docgov.DocumentRecord diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index 940e6e19..de68abb1 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -21,16 +21,16 @@ func (s *Server) resolveStageAccess(ctx context.Context, projectID, userID int64 return s.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator) } -func (s *Server) checkWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) projectinstance.DocAccessDecision { +func (s *Server) checkWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey, action string) projectinstance.DocAccessDecision { if taskKey == "" { return projectinstance.DocAccessDecision{Allowed: true} } + if action == "" { + action = projectinstance.DocAccessActionRun + } 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) - } + isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID) + return s.instSvc.CheckDocTaskAccess(ctx, projectID, userID, taskKey, action, isCreator) } access, err := s.resolveStageAccess(ctx, projectID, userID) if err != nil { @@ -39,18 +39,22 @@ func (s *Server) checkWorkflowDocTaskAccess(ctx context.Context, projectID, user } return projectinstance.DocAccessDecision{Allowed: true} } - if access == nil || !access.Restricted || access.TaskKeyAllowed(taskKey) { + if access == nil || !access.Restricted || access.TaskKeyVisible(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 + return s.checkWorkflowDocTaskAccess(ctx, projectID, userID, taskKey, projectinstance.DocAccessActionView).Allowed } func (s *Server) enforceWorkflowDocTaskAccess(w http.ResponseWriter, ctx context.Context, projectID, userID int64, taskKey string) bool { - decision := s.checkWorkflowDocTaskAccess(ctx, projectID, userID, taskKey) + return s.enforceWorkflowDocTaskAction(w, ctx, projectID, userID, taskKey, projectinstance.DocAccessActionRun) +} + +func (s *Server) enforceWorkflowDocTaskAction(w http.ResponseWriter, ctx context.Context, projectID, userID int64, taskKey, action string) bool { + decision := s.checkWorkflowDocTaskAccess(ctx, projectID, userID, taskKey, action) if decision.Allowed { return true } @@ -58,6 +62,17 @@ func (s *Server) enforceWorkflowDocTaskAccess(w http.ResponseWriter, ctx context return false } +func (s *Server) enforceDocGovWorkflowAction(w http.ResponseWriter, ctx context.Context, userID int64, docID, action string) bool { + if s.svc == nil || s.svc.DocGov() == nil || docID == "" { + return true + } + doc, err := s.svc.DocGov().GetDocument(ctx, docID) + if err != nil || doc == nil || doc.ProjectID <= 0 || doc.TaskKey == "" { + return true + } + return s.enforceWorkflowDocTaskAction(w, ctx, doc.ProjectID, userID, doc.TaskKey, action) +} + func (s *Server) writeDocAccessDecision(w http.ResponseWriter, decision projectinstance.DocAccessDecision) { status := http.StatusForbidden if decision.Code == projectinstance.AccessCodeDocTaskDisabled { @@ -131,7 +146,7 @@ func (s *Server) filterWorkflowDocsByStageAccess( } out := make([]docgov.WorkflowDocumentSummary, 0, len(docs)) for _, d := range docs { - if access.TaskKeyAllowed(d.TaskKey) { + if access.TaskKeyVisible(d.TaskKey) { out = append(out, d) } } diff --git a/internal/server/task_run_busy_preflight_test.go b/internal/server/task_run_busy_preflight_test.go index 3f7c872c..ee6113e4 100644 --- a/internal/server/task_run_busy_preflight_test.go +++ b/internal/server/task_run_busy_preflight_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -99,4 +100,64 @@ func TestCreateTaskRun_PreActivationAcceptedNot409(t *testing.T) { if w.Code != http.StatusAccepted { t.Fatalf("want 202 Accepted, got %d body=%s", w.Code, w.Body.String()) } + var payload createTaskRunResponse + if err := json.NewDecoder(w.Body).Decode(&payload); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + if payload.Status != "blocked_pending_governance" { + t.Fatalf("status=%q want blocked_pending_governance body=%s", payload.Status, w.Body.String()) + } + if rec, err := srv.taskRuns.GetRun(payload.RunID); err == nil && rec != nil && rec.Status == "running" { + t.Fatalf("pre-activation run must not start a live worker: %+v", rec) + } + history, err := srv.svc.ContextManager().GetChatHistory(t.Context(), "sess-p9001-u1", 20, 0) + if err != nil { + t.Fatalf("chat history: %v", err) + } + foundHint := false + for _, msg := range history { + if msg.Role == "assistant" && strings.Contains(msg.Content, projectinstance.GovernanceBlockedHint) { + foundHint = true + break + } + } + if !foundHint { + t.Fatalf("expected governance hint in chat, history=%d", len(history)) + } +} + +func TestDocGovActions_PreActivationRejected(t *testing.T) { + srv := newTestServer(t) + store := projectinstance.NewMemoryStore() + _ = projectinstance.SeedProjectMeta(store, 9002, projectinstance.LifecyclePlanning, "", 1, 0) + srv.instSvc = projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil) + + docID := "doc_proj9002_intake" + if _, err := srv.svc.DocGov().CreateOrGetDocument( + t.Context(), docID, 9002, "intake", "立项文档", "V0.1_triage", "", + 1, "编制人", 2, "审核人", 3, "批准人", + ); err != nil { + t.Fatalf("create document: %v", err) + } + token := loginTestAdmin(t, srv) + for _, tc := range []struct { + path string + body any + }{ + {"/api/v1/docgov/submit-review", DocGovSubmitReviewRequest{DocID: docID, UserID: 1, UserName: "编制人"}}, + {"/api/v1/docgov/review-decision", DocGovDecisionRequest{DocID: docID, UserID: 2, UserName: "审核人", Action: "approve"}}, + {"/api/v1/docgov/approver-decision", DocGovDecisionRequest{DocID: docID, UserID: 3, UserName: "批准人", Action: "approve"}}, + } { + raw, _ := json.Marshal(tc.body) + req := httptest.NewRequest(http.MethodPost, tc.path, bytes.NewReader(raw)) + withBearerAuth(req, token) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code < 400 { + t.Fatalf("%s want 4xx, got %d body=%s", tc.path, w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), projectinstance.AccessCodeNotActivated) { + t.Fatalf("%s want WORKFLOW_NOT_ACTIVATED, body=%s", tc.path, w.Body.String()) + } + } } diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 8afe0ddd..5a6e6b9e 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -71,8 +71,13 @@ 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.enforceWorkflowDocTaskAccess(w, r.Context(), req.ProjectID, user.ID, req.TaskKey) { - return + var accessDecision projectinstance.DocAccessDecision + if req.ProjectID > 0 && req.TaskKey != "" { + accessDecision = s.checkWorkflowDocTaskAccess(r.Context(), req.ProjectID, user.ID, req.TaskKey, projectinstance.DocAccessActionRun) + if !accessDecision.Allowed { + s.writeDocAccessDecision(w, accessDecision) + return + } } req.WorkDir = "" @@ -112,6 +117,10 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { }) return } + if accessDecision.Code == projectinstance.AccessCodeNotActivated { + s.createBlockedGovernanceRun(w, r, req, taskScope, idemKey) + return + } if busy, preflightErr := s.preflightTaskDocumentBusy(r.Context(), req); preflightErr != nil { http.Error(w, preflightErr.Error(), http.StatusBadRequest) return @@ -206,6 +215,65 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Request, req createTaskRunRequest, taskScope *projectinstance.MessageScope, idemKey string) { + runID := fmt.Sprintf("run_%d", time.Now().UnixNano()) + record := &taskrun.RunRecord{ + RunID: runID, + SessionID: req.SessionID, + ProjectID: req.ProjectID, + UserID: req.UserID, + ClientTurnID: req.ClientTurnID, + IdempotencyKey: idemKey, + TaskKey: req.TaskKey, + Stage: req.Stage, + WorkflowInstanceID: 0, + StageInstanceID: 0, + WorkflowStageKey: "", + Status: "blocked_pending_governance", + } + if taskScope != nil { + record.WorkflowInstanceID = taskScope.WorkflowInstanceID + record.StageInstanceID = taskScope.StageInstanceID + record.WorkflowStageKey = taskScope.StageKey + } + claimed, created, claimErr := s.taskRuns.ClaimRun(r.Context(), record) + if claimErr != nil { + http.Error(w, claimErr.Error(), http.StatusInternalServerError) + return + } + if !created { + writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ + RunID: claimed.RunID, + Status: claimed.Status, + }) + return + } + ctxMgr := s.svc.ContextManager() + if ctxMgr != nil && req.UserMessage != "" { + userMeta := map[string]any{ + "project_id": req.ProjectID, + "client_turn_id": req.ClientTurnID, + "task_key": req.TaskKey, + } + projectinstance.ApplyScopeToMetadata(userMeta, taskScope) + _, _ = ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, req.UserID, "user", req.UserMessage, "TRIGGER_TASK", "", userMeta) + asstMeta := map[string]any{ + "run_id": claimed.RunID, + "project_id": req.ProjectID, + "client_turn_id": req.ClientTurnID, + "task_key": req.TaskKey, + "task_status": "blocked_pending_governance", + } + projectinstance.ApplyScopeToMetadata(asstMeta, taskScope) + assistantMsgID, _ := ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, 0, "assistant", projectinstance.GovernanceBlockedHint, "TRIGGER_TASK", "", asstMeta) + _ = s.taskRuns.BindAssistantMsgID(r.Context(), claimed.RunID, assistantMsgID) + } + writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ + RunID: claimed.RunID, + Status: claimed.Status, + }) +} + func taskRunMatchesIdempotentOwner(existing *taskrun.RunRecord, req createTaskRunRequest) bool { if existing == nil { return false From 2e586c99b82ee09713f5ceaa6f359459d30b8c3d Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 07:55:51 +0000 Subject: [PATCH 05/10] test(projectworkflow): reject create-time active bypass Creating with initial_workflow_key must stay in planning with an empty active key while the catalog still mounts. Co-authored-by: Cursor --- internal/projectworkflow/service_test.go | 18 ++++++++ internal/server/project_workflow_api_test.go | 45 ++++++-------------- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 7016e764..266f8f0f 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -58,6 +58,24 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { t.Errorf("expected initial version 1, got %d", proj.WorkflowSelectionVersion) } + keyed, err := svc.CreateProject(ctx, creator, CreateProjectInput{ + Name: "带初始工作流键的规划项目", + InitialWorkflowKey: "main", + }) + if err != nil { + t.Fatalf("CreateProject with initial key: %v", err) + } + if keyed.LifecycleStatus != "planning" || keyed.ActiveWorkflowKey != "" { + t.Fatalf("initial_workflow_key must not activate, got lifecycle=%s key=%s", keyed.LifecycleStatus, keyed.ActiveWorkflowKey) + } + keyedWFs, err := svc.ListProjectWorkflows(ctx, keyed.ID) + if err != nil { + t.Fatalf("list keyed workflows: %v", err) + } + if len(keyedWFs) != 3 { + t.Fatalf("catalog must still mount after keyed create, got %d", len(keyedWFs)) + } + // 3. 验证自动挂载的工作流清单 wfs, err := svc.ListProjectWorkflows(ctx, proj.ID) if err != nil { diff --git a/internal/server/project_workflow_api_test.go b/internal/server/project_workflow_api_test.go index edbe2d58..f54f0d56 100644 --- a/internal/server/project_workflow_api_test.go +++ b/internal/server/project_workflow_api_test.go @@ -98,7 +98,7 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) { assert.Equal(t, http.StatusConflict, recSwitchBlocked.Code) assert.Contains(t, recSwitchBlocked.Body.String(), "LEGACY_SWITCH_BLOCKED") - // 6. 显式 initial_workflow_key 的 legacy 项目:ACL + 切换 + // 6. 显式 initial_workflow_key 仍停留 planning,目录已挂,不可直切 active bodyLegacyCreate, _ := json.Marshal(projectworkflow.CreateProjectInput{ Name: "Legacy 切换测试项目", InitialWorkflowKey: "main", @@ -110,47 +110,28 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) { require.Equal(t, http.StatusCreated, recLegacyCreate.Code) var legacyProj projectworkflow.Project require.NoError(t, json.Unmarshal(recLegacyCreate.Body.Bytes(), &legacyProj)) - assert.Equal(t, "main", legacyProj.ActiveWorkflowKey) + assert.Equal(t, "planning", legacyProj.LifecycleStatus) + assert.Equal(t, "", legacyProj.ActiveWorkflowKey) - bodySwitchOther, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{ - WorkflowKey: "product_manager", - ExpectedVersion: 1, - Reason: "非法切换", - }) - reqSwitchOther := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", legacyProj.ID), bytes.NewReader(bodySwitchOther)) - withUserIDAuth(reqSwitchOther, loginTestUser(t, handler, "lizhuguan", "123456")) - recSwitchOther := httptest.NewRecorder() - handler.ServeHTTP(recSwitchOther, reqSwitchOther) - assert.Equal(t, http.StatusForbidden, recSwitchOther.Code) + reqKeyedWFs := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflows", legacyProj.ID), nil) + recKeyedWFs := httptest.NewRecorder() + handler.ServeHTTP(recKeyedWFs, reqKeyedWFs) + require.Equal(t, http.StatusOK, recKeyedWFs.Code) + var keyedWFs []*projectworkflow.ProjectWorkflow + require.NoError(t, json.Unmarshal(recKeyedWFs.Body.Bytes(), &keyedWFs)) + assert.Equal(t, 3, len(keyedWFs)) bodySwitchCreator, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{ WorkflowKey: "product_manager", ExpectedVersion: 1, - Reason: "切换至智能产品经理编制需求", + Reason: "planning 项目不得靠创建旁路直切", }) reqSwitchCreator := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", legacyProj.ID), bytes.NewReader(bodySwitchCreator)) withUserIDAuth(reqSwitchCreator, loginTestUser(t, handler, "zhanggong", "123456")) recSwitchCreator := httptest.NewRecorder() handler.ServeHTTP(recSwitchCreator, reqSwitchCreator) - require.Equal(t, http.StatusOK, recSwitchCreator.Code) - - var switchResult projectworkflow.ActivateWorkflowResult - err = json.Unmarshal(recSwitchCreator.Body.Bytes(), &switchResult) - require.NoError(t, err) - assert.Equal(t, "product_manager", switchResult.ActiveWorkflowKey) - assert.Equal(t, 2, switchResult.Version) - - // 7. 再次用旧版本 1 切换 -> 409 Conflict - bodySwitchConflict, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{ - WorkflowKey: "test_manager", - ExpectedVersion: 1, - Reason: "并发冲突测试", - }) - reqSwitchConflict := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", legacyProj.ID), bytes.NewReader(bodySwitchConflict)) - withUserIDAuth(reqSwitchConflict, loginTestUser(t, handler, "zhanggong", "123456")) - recSwitchConflict := httptest.NewRecorder() - handler.ServeHTTP(recSwitchConflict, reqSwitchConflict) - assert.Equal(t, http.StatusConflict, recSwitchConflict.Code) + assert.Equal(t, http.StatusConflict, recSwitchCreator.Code) + assert.Contains(t, recSwitchCreator.Body.String(), "LEGACY_SWITCH_BLOCKED") // 8. 查询全局工作流规范目录 -> 200 OK reqDefs := httptest.NewRequest(http.MethodGet, "/api/v1/workflows", nil) From 22f65e5f2419ab07db4dcf95fd103237d1886af0 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 07:55:51 +0000 Subject: [PATCH 06/10] feat(projectworkflow): keep create in planning when key provided Validate the catalog key only and stop writing lifecycle=active so activation stays on the settings DutyMode path. Co-authored-by: Cursor --- internal/projectworkflow/service.go | 5 +---- internal/projectworkflow/types.go | 1 + web/src/types/projectWorkflow.ts | 1 + 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index 47db21ff..3f79091e 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -77,10 +77,7 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in WorkflowSelectionVersion: 1, WorkflowPlanRevision: 0, } - if initialWorkflow != "" { - p.LifecycleStatus = "active" - p.ActiveWorkflowKey = initialWorkflow - } + // initialWorkflow is catalog-validated only; activation stays on the settings path. created, err := s.store.CreateProject(ctx, p) if err != nil { diff --git a/internal/projectworkflow/types.go b/internal/projectworkflow/types.go index 7cbc904b..e1d2e866 100644 --- a/internal/projectworkflow/types.go +++ b/internal/projectworkflow/types.go @@ -93,6 +93,7 @@ type CreateProjectInput struct { DeviceType string `json:"device_type,omitempty"` IntendedUse string `json:"intended_use,omitempty"` TargetMarket string `json:"target_market,omitempty"` + // Deprecated: accepted for catalog existence checks only; never activates the project. InitialWorkflowKey string `json:"initial_workflow_key,omitempty"` } diff --git a/web/src/types/projectWorkflow.ts b/web/src/types/projectWorkflow.ts index 747c6724..ee7caf45 100644 --- a/web/src/types/projectWorkflow.ts +++ b/web/src/types/projectWorkflow.ts @@ -63,6 +63,7 @@ export interface CreateProjectInput { device_type?: string; intended_use?: string; target_market?: string; + /** @deprecated Accepted by API for catalog checks only; create dialog must not send this. */ initial_workflow_key?: string; } From 9baf3c83f8e8bab12b08b31ad38a4f6f560a25f2 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 08:00:21 +0000 Subject: [PATCH 07/10] test(web): cover duty-gated actions and blocked-run copy Pin unmanaged-doc button visibility and treat blocked_pending_governance as a governance hint, not a successful generation. Co-authored-by: Cursor --- .../chat/chatWindowArtifact.test.ts | 3 ++ web/src/lib/blockedGovernanceCopy.test.ts | 28 +++++++++++++++ web/src/lib/blockedGovernanceCopy.ts | 21 +++++++++++ web/src/lib/docGovActionVisibility.test.ts | 23 ++++++++++++ web/src/lib/docGovActionVisibility.ts | 35 +++++++++++++++++++ web/src/lib/taskRunReconnect.test.ts | 1 + 6 files changed, 111 insertions(+) create mode 100644 web/src/lib/blockedGovernanceCopy.test.ts create mode 100644 web/src/lib/blockedGovernanceCopy.ts create mode 100644 web/src/lib/docGovActionVisibility.test.ts create mode 100644 web/src/lib/docGovActionVisibility.ts diff --git a/web/src/components/chat/chatWindowArtifact.test.ts b/web/src/components/chat/chatWindowArtifact.test.ts index 2357e0ef..1ee64448 100644 --- a/web/src/components/chat/chatWindowArtifact.test.ts +++ b/web/src/components/chat/chatWindowArtifact.test.ts @@ -106,6 +106,9 @@ describe('mapRecordToMessage artifact projection', () => { expect(mapTaskStatusToMessageStatus('quality_failed', 'assistant')).toBe('quality_failed'); expect(mapTaskStatusToMessageStatus('failed', 'assistant')).toBe('failed'); expect(mapTaskStatusToMessageStatus('running', 'assistant')).toBe('streaming_status'); + expect(mapTaskStatusToMessageStatus('blocked_pending_governance', 'assistant')).toBe( + 'blocked_pending_governance', + ); expect(mapTaskStatusToMessageStatus('pending_author_confirmation', 'user')).toBeUndefined(); }); diff --git a/web/src/lib/blockedGovernanceCopy.test.ts b/web/src/lib/blockedGovernanceCopy.test.ts new file mode 100644 index 00000000..f6cfc95d --- /dev/null +++ b/web/src/lib/blockedGovernanceCopy.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ACTIVATION_UNMANAGED_FREEZE_HINT, + BLOCKED_GOVERNANCE_STATUS, + GOVERNANCE_BLOCKED_HINT, + isBlockedGovernanceBubble, + isBlockedGovernanceCopy, + isBlockedGovernanceStatus, +} from './blockedGovernanceCopy'; + +describe('blockedGovernanceCopy', () => { + it('recognizes blocked run status and fixed hint, not generation success', () => { + assert.equal(isBlockedGovernanceStatus(BLOCKED_GOVERNANCE_STATUS), true); + assert.equal(isBlockedGovernanceStatus('completed'), false); + assert.equal(isBlockedGovernanceCopy(GOVERNANCE_BLOCKED_HINT), true); + assert.equal(isBlockedGovernanceCopy('《立项文档》已生成。'), false); + assert.equal( + isBlockedGovernanceBubble({ taskStatus: BLOCKED_GOVERNANCE_STATUS, content: '任务执行中…' }), + true, + ); + assert.equal( + isBlockedGovernanceBubble({ taskStatus: 'completed', content: GOVERNANCE_BLOCKED_HINT }), + true, + ); + assert.match(ACTIVATION_UNMANAGED_FREEZE_HINT, /不可补配三职/); + }); +}); diff --git a/web/src/lib/blockedGovernanceCopy.ts b/web/src/lib/blockedGovernanceCopy.ts new file mode 100644 index 00000000..fe841fcc --- /dev/null +++ b/web/src/lib/blockedGovernanceCopy.ts @@ -0,0 +1,21 @@ +export const GOVERNANCE_BLOCKED_HINT = + '当前项目尚未激活工作流,文档任务不会启动。请先在设置中配置并激活至少一份纳入人工审批的文档。'; + +export const ACTIVATION_UNMANAGED_FREEZE_HINT = '不纳入人工审批的文档激活后不可补配三职。'; + +export const BLOCKED_GOVERNANCE_STATUS = 'blocked_pending_governance'; + +export function isBlockedGovernanceStatus(status: string | null | undefined): boolean { + return status === BLOCKED_GOVERNANCE_STATUS; +} + +export function isBlockedGovernanceCopy(content: string | null | undefined): boolean { + return Boolean(content && content.includes(GOVERNANCE_BLOCKED_HINT)); +} + +export function isBlockedGovernanceBubble(input: { + taskStatus?: string | null; + content?: string | null; +}): boolean { + return isBlockedGovernanceStatus(input.taskStatus) || isBlockedGovernanceCopy(input.content); +} diff --git a/web/src/lib/docGovActionVisibility.test.ts b/web/src/lib/docGovActionVisibility.test.ts new file mode 100644 index 00000000..cbbd3e38 --- /dev/null +++ b/web/src/lib/docGovActionVisibility.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { canShowDocGovApprovalAction, isUnmanagedDocDuties } from './docGovActionVisibility'; + +const full = { authorId: 1, reviewerId: 2, approverId: 3 }; + +describe('docGovActionVisibility', () => { + it('hides all approval actions on unmanaged docs', () => { + assert.equal(isUnmanagedDocDuties({}), true); + assert.equal(canShowDocGovApprovalAction('submit', 'author', {}), false); + assert.equal(canShowDocGovApprovalAction('review', 'reviewer', {}), false); + assert.equal(canShowDocGovApprovalAction('approve', 'approver', {}), false); + }); + + it('gates full docs by currentRole and denies creator who is not author', () => { + assert.equal(canShowDocGovApprovalAction('submit', 'author', full), true); + assert.equal(canShowDocGovApprovalAction('submit', 'viewer', full), false); + assert.equal(canShowDocGovApprovalAction('review', 'reviewer', full), true); + assert.equal(canShowDocGovApprovalAction('review', 'author', full), false); + assert.equal(canShowDocGovApprovalAction('approve', 'approver', full), true); + assert.equal(canShowDocGovApprovalAction('approve', 'author', full), false); + }); +}); diff --git a/web/src/lib/docGovActionVisibility.ts b/web/src/lib/docGovActionVisibility.ts new file mode 100644 index 00000000..e0c0e3f8 --- /dev/null +++ b/web/src/lib/docGovActionVisibility.ts @@ -0,0 +1,35 @@ +import type { UserRole } from '../types/docgov'; + +export type DocGovApprovalAction = 'submit' | 'review' | 'approve'; + +export type DocDutyIds = { + authorId?: number; + reviewerId?: number; + approverId?: number; +}; + +export function isUnmanagedDocDuties(duties: DocDutyIds): boolean { + return !( + (duties.authorId ?? 0) > 0 || + (duties.reviewerId ?? 0) > 0 || + (duties.approverId ?? 0) > 0 + ); +} + +export function canShowDocGovApprovalAction( + action: DocGovApprovalAction, + currentRole: UserRole, + duties: DocDutyIds, +): boolean { + if (isUnmanagedDocDuties(duties)) return false; + switch (action) { + case 'submit': + return currentRole === 'author'; + case 'review': + return currentRole === 'reviewer'; + case 'approve': + return currentRole === 'approver'; + default: + return false; + } +} diff --git a/web/src/lib/taskRunReconnect.test.ts b/web/src/lib/taskRunReconnect.test.ts index 8bfb8212..acb7a80a 100644 --- a/web/src/lib/taskRunReconnect.test.ts +++ b/web/src/lib/taskRunReconnect.test.ts @@ -28,6 +28,7 @@ test('isRemoteTaskTerminal covers confirmation and quality_failed', () => { assert.equal(isRemoteTaskTerminal('pending_author_confirmation'), true); assert.equal(isRemoteTaskTerminal('completed'), true); assert.equal(isRemoteTaskTerminal('quality_failed'), true); + assert.equal(isRemoteTaskTerminal('blocked_pending_governance'), true); assert.equal(isRemoteTaskTerminal('running'), false); }); From ab9fccded4da8eeb99c49733f371e81669b8a89c Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 08:00:21 +0000 Subject: [PATCH 08/10] feat(web): disable unmanaged approval actions Hide submit/review/approve on none docs, surface the blocked-run hint in chat, and warn that unmanaged duties cannot be added after activation. Co-authored-by: Cursor --- web/src/App.tsx | 29 +++++++++++++++++++ web/src/api/chatWindow.ts | 4 +++ web/src/components/chat/MessageBubble.tsx | 8 +++-- web/src/components/doc/DocGovActionBar.tsx | 16 ++++++++-- .../project/ProjectSettingsModal.tsx | 5 ++-- web/src/lib/squadCompletionObservation.ts | 11 +++++-- web/src/lib/taskRunReconnect.ts | 3 +- web/src/types/workbench.ts | 1 + 8 files changed, 66 insertions(+), 11 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 845fb1a5..a6adf0e0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -59,6 +59,10 @@ import { resolveReconnectRunId, shouldSubscribeExistingRun, } from './lib/taskRunReconnect'; +import { + GOVERNANCE_BLOCKED_HINT, + isBlockedGovernanceStatus, +} from './lib/blockedGovernanceCopy'; import { buildControlledLoadErrorSkeleton, buildControlledLoadingSkeleton, @@ -2815,6 +2819,19 @@ export default function App() { retainedRunId = created.run_id; taskRunStream.setRunId(sessionId, created.run_id); patchAsst({ taskRunId: created.run_id }); + if (isBlockedGovernanceStatus(created.status)) { + settled = true; + patchAsst({ + status: 'blocked_pending_governance', + content: GOVERNANCE_BLOCKED_HINT, + livePhaseLabel: undefined, + taskRunId: created.run_id, + }); + taskRunStream.setRunId(sessionId, null); + taskRunStream.setThinking(sessionId, false); + await chatWindow.refresh().catch(() => undefined); + return; + } await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal); return; } @@ -2844,6 +2861,18 @@ export default function App() { taskRunStream.setThinking(sessionId, false); return; } + if (isBlockedGovernanceStatus(remote.status)) { + settled = true; + patchAsst({ + status: 'blocked_pending_governance', + content: GOVERNANCE_BLOCKED_HINT, + livePhaseLabel: undefined, + taskWorkspaceId: remote.workspace_id, + taskRunId: runId, + }); + taskRunStream.setThinking(sessionId, false); + return; + } if (remote.status === 'completed') { settled = true; patchAsst({ diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index aae11add..88dd6964 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -28,6 +28,8 @@ export function mapTaskStatusToMessageStatus( case 'quality_failed': // 门禁未过必须保持失败态,不得映射 completed return 'quality_failed'; + case 'blocked_pending_governance': + return 'blocked_pending_governance'; case 'completed': case '': return 'completed'; @@ -47,6 +49,8 @@ export function assistantTaskStatusLabel(status: AssistantMessageStatus | undefi return '待编制人确认'; case 'quality_failed': return '质量未通过'; + case 'blocked_pending_governance': + return '待激活工作流'; case 'failed': return '执行失败'; case 'cancelled': diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index 4ff483a4..6a3961b4 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from 'react'; import { ChatErrorInfo, ChatMessage } from '../../types/workbench'; import { assistantTaskStatusLabel } from '../../api/chatWindow'; +import { isBlockedGovernanceBubble } from '../../lib/blockedGovernanceCopy'; import { PhaseEventStrip } from './PhaseEventStrip'; import { RecommendedCaseCard } from './RecommendedCaseCard'; import { InteractiveCard } from './InteractiveCard'; @@ -62,6 +63,9 @@ const MessageBubbleInner: React.FC = ({ const isFailed = !isUser && status === 'failed' && !!message.errorInfo; const isCancelled = !isUser && status === 'cancelled'; const isPendingAuthorConfirmation = !isUser && status === 'pending_author_confirmation'; + const isBlockedGovernance = + !isUser && + isBlockedGovernanceBubble({ taskStatus: status, content: message.content }); const taskStatusLabel = assistantTaskStatusLabel(status); const hasContent = !!message.content && message.content.trim().length > 0; const [diagCopied, setDiagCopied] = useState(false); @@ -273,7 +277,7 @@ const MessageBubbleInner: React.FC = ({ ? 'var(--brand-500)' : isFailed ? 'var(--danger-bg)' - : isPendingAuthorConfirmation + : isPendingAuthorConfirmation || isBlockedGovernance ? 'rgba(245, 158, 11, 0.08)' : 'var(--bg-panel)', color: isUser ? '#ffffff' : 'var(--text-strong)', @@ -281,7 +285,7 @@ const MessageBubbleInner: React.FC = ({ ? 'none' : isFailed ? '1px solid var(--danger)' - : isPendingAuthorConfirmation + : isPendingAuthorConfirmation || isBlockedGovernance ? '1px solid rgba(245, 158, 11, 0.45)' : '1px solid var(--border)', boxShadow: isUser ? '0 2px 8px rgba(79, 107, 255, 0.25)' : 'var(--shadow-xs)', diff --git a/web/src/components/doc/DocGovActionBar.tsx b/web/src/components/doc/DocGovActionBar.tsx index 856800d8..e0126d21 100644 --- a/web/src/components/doc/DocGovActionBar.tsx +++ b/web/src/components/doc/DocGovActionBar.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { DocumentAST } from '../../types/block'; import { UserRole } from '../../types/docgov'; +import { canShowDocGovApprovalAction, isUnmanagedDocDuties } from '../../lib/docGovActionVisibility'; import { Send, CheckCircle2, @@ -68,6 +69,11 @@ export const DocGovActionBar: React.FC = ({ const isPrivate = ast.category === 'private'; const isShared = ast.category === 'shared'; const isWorkflow = !isPrivate && !isShared; + const duties = { authorId: ast.authorId, reviewerId: ast.reviewerId, approverId: ast.approverId }; + const unmanagedDuties = isUnmanagedDocDuties(duties); + const showSubmit = canShowDocGovApprovalAction('submit', currentRole, duties); + const showReview = canShowDocGovApprovalAction('review', currentRole, duties); + const showApprove = canShowDocGovApprovalAction('approve', currentRole, duties); return (
= ({ {/* 1. 编制阶段 */} {ast.lifecycleState === 'drafting' && ( <> - {currentRole === 'author' ? ( + {unmanagedDuties ? ( + + 该文档未纳入人工审批,不提供送审、审核或批准 + + ) : showSubmit ? ( <> {!ast.isClaimed ? (

- 确认后将锁定工作流配置,运行期间不可直接修改开关与职责。令牌有效期至{' '} - {new Date(preview.expires_at).toLocaleTimeString()}。 + 确认后将锁定工作流配置,运行期间不可直接修改开关与职责。{ACTIVATION_UNMANAGED_FREEZE_HINT}{' '} + 令牌有效期至 {new Date(preview.expires_at).toLocaleTimeString()}。

)} diff --git a/web/src/lib/squadCompletionObservation.ts b/web/src/lib/squadCompletionObservation.ts index 8113bbdc..3c8c1a06 100644 --- a/web/src/lib/squadCompletionObservation.ts +++ b/web/src/lib/squadCompletionObservation.ts @@ -1,12 +1,17 @@ export const INFLIGHT_CHAT_STATUSES = ['pending', 'streaming_status', 'auto_retrying'] as const; export const SUCCESS_TERMINAL_CHAT_STATUSES = ['completed', 'pending_author_confirmation'] as const; -export const ERROR_TERMINAL_CHAT_STATUSES = ['failed', 'cancelled', 'quality_failed'] as const; +export const ERROR_TERMINAL_CHAT_STATUSES = [ + 'failed', + 'cancelled', + 'quality_failed', + 'blocked_pending_governance', +] as const; export const SUCCESS_TERMINAL_STATUS_RE = /^(completed|pending_author_confirmation)$/; -export const ERROR_TERMINAL_STATUS_RE = /^(failed|cancelled|quality_failed)$/; +export const ERROR_TERMINAL_STATUS_RE = /^(failed|cancelled|quality_failed|blocked_pending_governance)$/; export const ANY_TERMINAL_STATUS_RE = - /^(completed|pending_author_confirmation|failed|cancelled|quality_failed)$/; + /^(completed|pending_author_confirmation|failed|cancelled|quality_failed|blocked_pending_governance)$/; export type SquadObservationFrame = { userPromptMatched: boolean; diff --git a/web/src/lib/taskRunReconnect.ts b/web/src/lib/taskRunReconnect.ts index 237d28e5..8776adc6 100644 --- a/web/src/lib/taskRunReconnect.ts +++ b/web/src/lib/taskRunReconnect.ts @@ -21,7 +21,8 @@ export function isRemoteTaskTerminal(status: string | null | undefined): boolean return ( status === 'completed' || status === 'pending_author_confirmation' || - status === 'quality_failed' + status === 'quality_failed' || + status === 'blocked_pending_governance' ); } diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 077312bb..84f5464a 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -117,6 +117,7 @@ export type AssistantMessageStatus = | 'pending_author_confirmation' | 'failed' | 'quality_failed' + | 'blocked_pending_governance' | 'cancelled'; export interface ChatErrorInfo { From c048060baed1968f18558beab65c005daa6c98d3 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 08:01:46 +0000 Subject: [PATCH 09/10] docs(audit): classify planning projects with leftover workflow keys Read-only dev query shows 83 planning rows with a leftover key; all are paired PROJ_MGR/TEST_PROJ fixtures, none are live business projects. Co-authored-by: Cursor --- ...9-14-planning-with-workflow-key-anomaly.md | 56 +++++++++++++++++++ ...026-09-14-governance-action-level-authz.md | 10 ++-- 2 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 .doc/audit/2026-09-14-planning-with-workflow-key-anomaly.md diff --git a/.doc/audit/2026-09-14-planning-with-workflow-key-anomaly.md b/.doc/audit/2026-09-14-planning-with-workflow-key-anomaly.md new file mode 100644 index 00000000..a8157a4e --- /dev/null +++ b/.doc/audit/2026-09-14-planning-with-workflow-key-anomaly.md @@ -0,0 +1,56 @@ +--- +title: "planning 项目残留 active_workflow_key 异态审计" +status: done +created: 2026-09-14 +updated: 2026-09-14 +severity: low +--- + +# planning 项目残留 active_workflow_key 异态审计 + +## 现象 + +dev 库存在 `lifecycle_status='planning' AND active_workflow_key <> ''` 行。评审时记为 81;本轮只读复测为 **83**(相对评审 +2)。这些行看起来像「已选工作流但未走设置激活」。 + +## 根因分析 + +关闭 `initial_workflow_key` 直通前,`CreateProject` 在键非空时会写 `lifecycle=active` + `ActiveWorkflowKey`。本异态是 **planning 却带 key**,更符合自动化测试夹具成对插入(`PROJ_MGR_*` / `TEST_PROJ_*`),而不是真实用户走创建旁路后的 active 项目。 + +本轮 **未** `UPDATE`/`DELETE`。 + +## 严重度 + +`low` + +创建旁路已在 `fix/governance-action-level-authz` 关闭。存量 83 行全部无 live 实例、无创建人,不影响项目 143,也不阻塞 G1 鉴权矩阵。 + +## 关联整改 + +- Short-term: [2026-09-14-governance-action-level-authz](../ops/short-term/2026-09-14-governance-action-level-authz.md) +- Research: [2026-09-14-mvp-governance-rules-review](../ops/research/2026-09-14-mvp-governance-rules-review.md) + +## 证据 + +只读查询(`shuimu-context-pg-dev` / `agent_system_dev`,2026-09-14): + +| 指标 | 数量 | +|------|------| +| `lifecycle=active` | 141 | +| `lifecycle=planning` | 209 | +| `planning AND active_workflow_key <> ''` | **83** | + +分类(禁止改库,仅标注): + +| 分类 | 数量 | 判定 | +|------|------|------| +| 测试夹具 | **83** | `code` 为 `PROJ_MGR_YYYYMMDDHHMMSS`(名「智能微创内窥镜」)或 `TEST_PROJ_*`(名「测试内窥镜系统」);`active_workflow_key=main`;`workflow_selection_version=1`;`creator_user_id` 空;`has_live_instance=false`;时间戳成对出现于 2026-09-12~09-14 | +| 历史直通(真实用户创建旁路) | **0** | 无非夹具命名、无创建人、无 live 实例的业务项目 | +| 不明 | **0** | — | + +样本 id(完整 83 行均属上表夹具模式,不逐条展开):61–62、122–128、6015–6021、6160–6161、6192/6195。项目 **143 不在此集合**。 + +相对评审 81 的 +2:`6192` / `6195`,创建于 2026-09-14 07:26 UTC,仍是同一对夹具命名。 + +## 开放问题 + +- 是否在后续专项清理夹具行(需单独任务与备份,不在本治理鉴权波次)。 diff --git a/.doc/ops/short-term/2026-09-14-governance-action-level-authz.md b/.doc/ops/short-term/2026-09-14-governance-action-level-authz.md index f5dfb5ca..172cb4d6 100644 --- a/.doc/ops/short-term/2026-09-14-governance-action-level-authz.md +++ b/.doc/ops/short-term/2026-09-14-governance-action-level-authz.md @@ -53,10 +53,10 @@ mutex: workflow ## 步骤 1. [x] 评审落盘 + 决策 A–D 收敛 + 本任务登记(Wave 5 已合入本地 `dev@19f6df5`,G0 已开工) -2. [ ] G1 后端:`CheckDocTaskAccess` 动作-职责映射 + creator 例外移除(保留 none 生成触发)+ gov==nil 未激活拦截(202 契约保留);重写受影响单测 -3. [ ] G2 关闭 `initial_workflow_key` 直通 + 前后端字段废弃 + E2E legacy 用例更新 -4. [ ] G3 前端:chat 治理拦截自然语言提示、按钮级职责禁用、激活确认页冻结提示补强 -5. [ ] G4 dev 库 81 个 planning+with_wf 异态审计分类(只读报告) +2. [x] G1 后端:`CheckDocTaskAccess` 动作-职责映射 + creator 例外移除(保留 none 生成触发)+ gov==nil 未激活拦截(202 契约保留);重写受影响单测 +3. [x] G2 关闭 `initial_workflow_key` 直通 + 前后端字段废弃 + E2E legacy 用例更新 +4. [x] G3 前端:chat 治理拦截自然语言提示、按钮级职责禁用、激活确认页冻结提示补强 +5. [x] G4 dev 库 planning+with_wf 异态审计分类(只读报告,现测 83 条均为测试夹具) 6. [ ] G5 集成验证 + 测试报告归档(新项目全链路:零治理创建→对话→配置→激活→职责动作负例) ## 验收标准 @@ -77,4 +77,4 @@ mutex: workflow ## 证据 / 备注 - Wave 5 已合入本地 `dev@19f6df5`;本任务 worktree 已从该 `dev` 创建,mutex `workflow` 进入 `in_progress`。 -- dev 库:133 active / 204 planning;81 个 planning 项目带非空 `active_workflow_key`(待 G4 审计分类)。 +- G4 只读复测:141 active / 209 planning;**83** 个 planning 带非空 `active_workflow_key`,全部为 `PROJ_MGR_*`/`TEST_PROJ_*` 夹具。见 [audit](../../audit/2026-09-14-planning-with-workflow-key-anomaly.md)。 From 5ee898e2e4bc0bf11e2a733f269ee36049f14870 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 08:04:34 +0000 Subject: [PATCH 10/10] docs(ops): occupy integration slot for governance authz G5 starts merging and smoke on the shared dev stack; Playwright matrix is ready for the real page pass after rebuild. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 2 +- web/e2e/governance-action-level-authz.spec.ts | 177 ++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 web/e2e/governance-action-level-authz.spec.ts diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 94feaab8..7fb07fe8 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -57,7 +57,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| idle | idle | - | - | - | +| implementer_cursor | merging | 2026-09-14 | governance-action-level-authz | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/web/e2e/governance-action-level-authz.spec.ts b/web/e2e/governance-action-level-authz.spec.ts new file mode 100644 index 00000000..b16e61fd --- /dev/null +++ b/web/e2e/governance-action-level-authz.spec.ts @@ -0,0 +1,177 @@ +import { test, expect, type Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { currentProjectBannerName } from '../src/lib/currentProjectRestore'; +import { GOVERNANCE_BLOCKED_HINT } from '../src/lib/blockedGovernanceCopy'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; +const ASSETS = process.env.UI_ACCEPTANCE_ASSETS_DIR + ? path.resolve(process.env.UI_ACCEPTANCE_ASSETS_DIR) + : path.resolve( + __dirname, + '../../.doc/test-reports/assets/2026-09-14-governance-action-level-authz', + ); +const SCREENSHOTS = path.join(ASSETS, 'screenshots'); +fs.mkdirSync(SCREENSHOTS, { recursive: true }); + +const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || ''; +const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || ''; +const suffix = `${Date.now().toString(36)}`; +const USER_B = `gab_${suffix}`; +const USER_C = `gac_${suffix}`; +const INIT_PASS = `Init-${suffix}9`; +const PROJECT = `GOVAUTH-${suffix}`; + +test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the real dev stack'); + +async function shot(page: Page, name: string) { + await page.screenshot({ path: path.join(SCREENSHOTS, name), fullPage: true }); +} + +async function login(page: Page, username: string, password: string) { + await page.goto('/'); + await page.fill('input[placeholder*="系统用户名"]', username); + await page.fill('input[placeholder*="登录密码"]', password); + await page.click('button:has-text("进入医疗协同工作台")'); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); +} + +async function goToDutyStep(page: Page) { + const approval = page.getByText('纳入人工审批').first(); + const selectHint = page.getByText('选择要激活的已发布工作流版本'); + await expect(selectHint.or(approval)).toBeVisible({ timeout: 20_000 }); + if (await approval.isVisible().catch(() => false)) { + return; + } + await page.getByRole('button', { name: /下一步/ }).click(); + await expect(approval).toBeVisible({ timeout: 20_000 }); +} + +function dutyRoleSelect(page: Page, placeholder: '作者' | '审核' | '批准') { + return page.locator('select').filter({ has: page.getByRole('option', { name: placeholder, exact: true }) }).first(); +} + +test.describe.serial('governance action-level authz', () => { + test('real page matrix G5', async ({ browser }) => { + if (!ADMIN_USER || !ADMIN_PASS) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + const page = await browser.newPage(); + const runCreates: { status: number; body: string }[] = []; + page.on('response', async (res) => { + if (res.url().includes('/api/v1/tasks/runs') && res.request().method() === 'POST') { + runCreates.push({ status: res.status(), body: await res.text().catch(() => '') }); + } + }); + + await login(page, ADMIN_USER, ADMIN_PASS); + + await page.locator('button[title*="系统管理与准入治理"]').click(); + for (const [user, name] of [ + [USER_B, `审核协作者-${suffix}`], + [USER_C, `批准协作者-${suffix}`], + ] as const) { + await page.getByRole('button', { name: '开通受控员工账号' }).click(); + const form = page.locator('form').filter({ hasText: '用户名 (系统账号)' }); + await form.locator('input[placeholder="如 li_si"]').fill(user); + await form.locator('input[placeholder="如 李四"]').fill(name); + const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]'); + await passBox.fill(INIT_PASS); + await form.locator('select').selectOption('role-engineer'); + await form.getByRole('button', { name: '确认开通账号' }).click(); + await expect(form).toBeHidden({ timeout: 15_000 }); + } + const adminHeading = page.getByText('系统管理控制台', { exact: true }); + const adminModal = adminHeading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); + await adminModal.locator('button').first().click(); + await expect(adminHeading).toBeHidden({ timeout: 10_000 }); + + await page.getByRole('button', { name: '新建立项会话' }).click(); + const heading = page.getByText('发起医疗器械新项目立项', { exact: true }); + await expect(heading).toBeVisible({ timeout: 20_000 }); + const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); + await modal.locator('input[placeholder*="无线便携式"]').fill(PROJECT); + await modal.locator('textarea').fill('治理鉴权 G5:零治理创建后先对话再配置。'); + for (const name of [`审核协作者-${suffix}`, `批准协作者-${suffix}`]) { + const memberName = modal.getByText(name, { exact: true }); + await expect(memberName).toBeVisible({ timeout: 15_000 }); + await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click(); + } + await modal.getByRole('button', { name: /确认立项/ }).click(); + await expect(currentProjectBannerName(page, PROJECT)).toBeVisible({ timeout: 30_000 }); + await shot(page, '01-zero-gov-created.png'); + + const input = page.getByPlaceholder(/向项目助手提问/); + await input.fill('今天天气如何,先闲聊一句。'); + await page.getByRole('button', { name: /发送/ }).click(); + await expect(page.locator('[data-chat-role="user"]').filter({ hasText: '今天天气如何' })).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator('[data-chat-role="assistant"]').last()).toBeVisible({ timeout: 45_000 }); + await shot(page, '02-casual-chat.png'); + + await input.fill('帮我生成立项文档'); + await page.getByRole('button', { name: /发送/ }).click(); + await expect(page.getByText('帮我生成立项文档')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(GOVERNANCE_BLOCKED_HINT)).toBeVisible({ timeout: 45_000 }); + await expect(page.getByText('待激活工作流')).toBeVisible({ timeout: 15_000 }); + const blocked = runCreates.find((row) => row.body.includes('blocked_pending_governance')); + if (!blocked || blocked.status !== 202) { + throw new Error(`expected 202 blocked run, got ${JSON.stringify(runCreates)}`); + } + await shot(page, '03-blocked-generate.png'); + + await page.getByRole('button', { name: '配置工作流' }).click(); + await goToDutyStep(page); + const toggles = page.locator('label', { hasText: '纳入人工审批' }); + const toggleCount = await toggles.count(); + for (let i = 1; i < toggleCount; i += 1) { + const box = toggles.nth(i).locator('input[type="checkbox"]'); + if (await box.isChecked()) await box.uncheck(); + } + const firstToggle = toggles.first().locator('input[type="checkbox"]'); + if (!(await firstToggle.isChecked())) await firstToggle.check(); + const author = dutyRoleSelect(page, '作者'); + await expect(author).toBeVisible({ timeout: 10_000 }); + const values = ( + await author.locator('option').evaluateAll((nodes) => nodes.map((n) => (n as HTMLOptionElement).value)) + ).filter(Boolean); + if (values.length < 3) { + throw new Error(`need 3 members for full duties, got ${values.length}`); + } + await author.selectOption(values[0]); + await dutyRoleSelect(page, '审核').selectOption(values[1]); + await dutyRoleSelect(page, '批准').selectOption(values[2]); + await page.getByRole('button', { name: /校验并预览/ }).click(); + await expect(page.getByText('激活预览')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText('不纳入人工审批的文档激活后不可补配三职')).toBeVisible(); + await shot(page, '04-activation-confirm.png'); + await page.getByRole('button', { name: '确认激活' }).click(); + await expect(page.getByText('运行中')).toBeVisible({ timeout: 20_000 }); + await shot(page, '05-activated-full-none.png'); + + await page.getByRole('button', { name: /工作流|文档|交付/ }).first().click().catch(() => undefined); + const unmanagedHint = page.getByText('该文档未纳入人工审批,不提供送审、审核或批准'); + const submitBtn = page.getByRole('button', { name: /提交送审/ }); + // Creator is author of the first governed doc, so submit may exist there; none docs must hide it. + const workflowDocs = page.locator('[data-doc-key], button, [title]').filter({ hasText: /研究|法规|竞品|可行性/ }); + if ((await workflowDocs.count()) > 0) { + await workflowDocs.first().click(); + } + if (await unmanagedHint.isVisible().catch(() => false)) { + await expect(submitBtn).toHaveCount(0); + } + await shot(page, '06-duty-gated-actions.png'); + + await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click(); + await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 15_000 }); + await page.getByRole('button', { name: '查看工作流设置' }).click(); + await expect(page.getByText('当前工作流已冻结,只读查看')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('button', { name: '确认终止当前实例' })).toBeVisible(); + await shot(page, '07-project-143-readonly.png'); + await page.getByRole('button', { name: '关闭' }).click(); + await page.close(); + }); +});