merge: fix/governance-action-level-authz

G1–G4 duty-action authz, blocked pre-activation runs, and create-time
planning stay; G5 smoke follows on the shared dev stack.
This commit is contained in:
weichen
2026-09-14 08:06:37 +00:00
36 changed files with 1086 additions and 118 deletions
@@ -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,仍是同一对夹具命名。
## 开放问题
- 是否在后续专项清理夹具行(需单独任务与备份,不在本治理鉴权波次)。
+6 -5
View File
@@ -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,16 +47,17 @@
| 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/<slug>` 或 `fix/<slug>`,**禁止** `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。
## 集成槽位
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| idle | idle | - | - | - |
| implementer_cursor | merging | 2026-09-14 | governance-action-level-authz | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -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 进行中。该更新属实施分支职责,本评审不跨分支代改,仅在此记录提醒收敛时同步。
@@ -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. [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 集成验证 + 测试报告归档(新项目全链路:零治理创建→对话→配置→激活→职责动作负例)
## 验收标准
- [ ] 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`。
- 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)。
+1 -1
View File
@@ -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` |
+4 -34
View File
@@ -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 {
@@ -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)
}
+107
View File
@@ -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: "当前用户不是该文档职责人",
}
}
@@ -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)
}
}
+32 -15
View File
@@ -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 == "" {
+14 -4
View File
@@ -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)
}
}
+1 -4
View File
@@ -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 {
+18
View File
@@ -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 {
+1
View File
@@ -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"`
}
@@ -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
}
+10
View File
@@ -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
+13 -32
View File
@@ -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)
+25 -10
View File
@@ -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)
}
}
@@ -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())
}
}
}
+70 -2
View File
@@ -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
@@ -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();
});
});
+29
View File
@@ -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({
+4
View File
@@ -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':
+6 -2
View File
@@ -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<MessageBubbleProps> = ({
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<MessageBubbleProps> = ({
? '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<MessageBubbleProps> = ({
? '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)',
@@ -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();
});
+13 -3
View File
@@ -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<DocGovActionBarProps> = ({
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 (
<div
@@ -402,7 +408,11 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
{/* 1. 编制阶段 */}
{ast.lifecycleState === 'drafting' && (
<>
{currentRole === 'author' ? (
{unmanagedDuties ? (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
该文档未纳入人工审批,不提供送审、审核或批准
</span>
) : showSubmit ? (
<>
{!ast.isClaimed ? (
<button
@@ -469,7 +479,7 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
>
<Lock size={12} /> 文档已锁定
</span>
{currentRole === 'reviewer' ? (
{showReview ? (
<>
<button
onClick={() => openReject('review')}
@@ -522,7 +532,7 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
{/* 3. 批准阶段 */}
{ast.lifecycleState === 'approving' && (
<>
{currentRole === 'approver' ? (
{showApprove ? (
<>
<button
onClick={() => openReject('approve')}
@@ -36,6 +36,7 @@ import {
needsThreeMembersForDuties,
toggleHumanApproval,
} from '../../lib/projectSettingsDuties';
import { ACTIVATION_UNMANAGED_FREEZE_HINT } from '../../lib/blockedGovernanceCopy';
import type { ProjectSettingsIssue } from '../../api/projectSettingsError';
interface ProjectMemberOption {
@@ -582,8 +583,8 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
)}
</div>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', margin: 0 }}>
确认后将锁定工作流配置,运行期间不可直接修改开关与职责。令牌有效期至{' '}
{new Date(preview.expires_at).toLocaleTimeString()}。
确认后将锁定工作流配置,运行期间不可直接修改开关与职责。{ACTIVATION_UNMANAGED_FREEZE_HINT}{' '}
令牌有效期至 {new Date(preview.expires_at).toLocaleTimeString()}。
</p>
</div>
)}
+28
View File
@@ -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, /不可补配三职/);
});
});
+21
View File
@@ -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);
}
@@ -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);
});
});
+35
View File
@@ -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;
}
}
+8 -3
View File
@@ -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;
+1
View File
@@ -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);
});
+2 -1
View File
@@ -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'
);
}
+1
View File
@@ -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;
}
+1
View File
@@ -117,6 +117,7 @@ export type AssistantMessageStatus =
| 'pending_author_confirmation'
| 'failed'
| 'quality_failed'
| 'blocked_pending_governance'
| 'cancelled';
export interface ChatErrorInfo {