diff --git a/.doc/adr/2026-09-14-modular-monolith-decoupling.md b/.doc/adr/2026-09-14-modular-monolith-decoupling.md index d337fd64..0153220e 100644 --- a/.doc/adr/2026-09-14-modular-monolith-decoupling.md +++ b/.doc/adr/2026-09-14-modular-monolith-decoupling.md @@ -2,7 +2,7 @@ ## 状态 -提议(2026-09-14;架构与实施计划已修订,等待用户确认后进入首个实现里程碑) +已接受(2026-09-14 提议;2026-09-18 确认——Phase 1 纵向绞杀与 Phase 2 持久事件交付已落地并验收,见 gap-matrix `DOC-RELIABILITY-PILOT` PASS、[Phase 1 计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md)、[Phase 2 修订计划](../remediation/2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md)、[Track E 复盘](../ops/research/2026-09-15-track-e-retrospective.md)) ## 背景 diff --git a/.doc/ops/short-term/2026-09-16-pu8-u9-u12-flags.md b/.doc/archive/ops/2026-09-16-pu8-u9-u12-flags.md similarity index 100% rename from .doc/ops/short-term/2026-09-16-pu8-u9-u12-flags.md rename to .doc/archive/ops/2026-09-16-pu8-u9-u12-flags.md diff --git a/.doc/archive/ops/2026-09-17-docgov-modification-proposal-http.md b/.doc/archive/ops/2026-09-17-docgov-modification-proposal-http.md new file mode 100644 index 00000000..194c28dc --- /dev/null +++ b/.doc/archive/ops/2026-09-17-docgov-modification-proposal-http.md @@ -0,0 +1,48 @@ +--- +title: "docgov 变更提案 HTTP + 前端接线" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/docgov-modification-proposal-http +worktree: .worktrees/feature-docgov-modification-proposal-http +mutex: docgov +--- + +# docgov 变更提案 HTTP + 前端接线 + +## 目标 + +暴露 `POST /api/v1/docgov/proposal`,前端「修改」快捷分支与 `ProposalConfirmCard` 走真实 propose/decide,不再本地假装成功。 + +## 范围 + +- API:`ProposeModification` 路由;decide 补 404/403 映射 +- 前端:`docgov.ts` + `App.tsx` 接线 +- 单测 + 真实 HTTP 证据 +- 关闭 audit 缺口;dev 试点总开关证据归档 + +## 非目标 + +- 晋升 test/main +- 变更提案自动改写受控正文(批准后仍为 UI 演示增补) + +## 步骤 + +1. [x] 实现 propose handler + 路由 +2. [x] 前端 client + App 接线 +3. [x] handler/client 单测 +4. [x] 合入 dev + rebuild + 真实 HTTP +5. [x] push origin/dev;清理 author-only worktree + +## 验收标准 + +- [x] `POST /api/v1/docgov/proposal` 可写库(单测) +- [x] 真实 HTTP claim → propose → decide PASS +- [x] audit 缺口 resolved;CURRENT 认领收敛 + +## 相关链接 + +- Audit: [../../audit/2026-09-17-docgov-modification-proposal-http-gap.md](../../audit/2026-09-17-docgov-modification-proposal-http-gap.md) +- Pilot: [../../test-reports/assets/2026-09-17-author-only-pilot/](../../test-reports/assets/2026-09-17-author-only-pilot/) +- Closeout: [../handoffs/2026-09-17-document-author-only-closeout.md](../handoffs/2026-09-17-document-author-only-closeout.md) diff --git a/.doc/archive/ops/2026-09-17-document-author-only-plan.md b/.doc/archive/ops/2026-09-17-document-author-only-plan.md new file mode 100644 index 00000000..ab44e5e6 --- /dev/null +++ b/.doc/archive/ops/2026-09-17-document-author-only-plan.md @@ -0,0 +1,47 @@ +--- +title: 文档编制人兼任审核批准方案 +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Codex +branch: feature/document-author-only-plan +worktree: .worktrees/feature-document-author-only-plan +mutex: document-duty-policy-plan +--- + +# 文档编制人兼任审核批准方案 + +## 目标 + +基于当前源码形成管理员总授权、项目创建者逐文档选择的详细实施方案,交用户审核。 + +## 范围 + +权限、系统设置、工作流规划和冻结、文档三职、动作鉴权、状态机、前端、迁移与验收。 + +## 非目标 + +本次不修改业务代码、不变更数据库或共享容器、不推送。 + +## 步骤 + +1. [x] 阅读启动链并从本地 dev 建立隔离分支。 +2. [x] 核查实现并记录证据。 +3. [x] 编写方案、检查边界与验收覆盖。 + +## 验收标准 + +- [x] 方案覆盖用户两个权限层级及逐文档持久化。 +- [x] 当前事实与拟议行为分开,列明实施单元与测试场景。 + +## 证据 / 备注 + +基线 dev adb48c8。已 fetch origin/dev,本地 dev 领先 44、落后 0,保留未推送集成成果;主工作区 feature/control-center-server 未切换。worktree 脚本默认从当前 HEAD 创建,故直接指定 dev 创建本 worktree,避免引入他人分支。 + +## 交付 + +- 方案:`.doc/remediation/2026-09-17-document-author-only-plan.md` +- 核查:`.doc/audit/2026-09-17-document-author-only-analysis.md` +- 本任务完成仅指方案交付,业务实现为 planned;R9/R10 待用户审核。 +- 未合入 dev:方案保留在独立分支供审核;未推送、未修改业务代码或容器。 +- 验证:源码与文档一致性检查、git diff --check;未执行业务测试。 diff --git a/.doc/archive/ops/2026-09-17-document-author-only-u1.md b/.doc/archive/ops/2026-09-17-document-author-only-u1.md new file mode 100644 index 00000000..96dd5d44 --- /dev/null +++ b/.doc/archive/ops/2026-09-17-document-author-only-u1.md @@ -0,0 +1,57 @@ +--- +title: "文档编制人兼任:U1 策略存储与总授权" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/document-author-only-plan +worktree: ".worktrees/feature-document-author-only-plan" +mutex: sysadmin + document-duty-policy +--- + +# 文档编制人兼任:U1 策略存储与总授权 + +## 目标 + +落地 `workflow.allow_author_only_approval` 管理员总开关:默认关闭、严格 bool、revision CAS、事务内审计,并向 projectinstance 提供可参与激活事务的窄读取接口。 + +## 范围 + +- Goose 增量迁移(revision 列、种子行、策略审计表) +- `internal/sysadmin` 存储/服务/窄 Policy 接口 +- `admin_system_handlers` 对本 key 的 `expected_revision` 协议 +- 单元 / PG / HTTP 负例测试 + +## 非目标 + +- 逐文档 duty_policy(U2) +- 前端设置页文案(U5) +- 已激活实例紧急熔断 + +## 步骤 + +1. [x] 认领 CURRENT +2. [x] 迁移 00050 +3. [x] sysadmin CAS + 审计 + PolicySource +4. [x] admin handler +5. [x] 测试与提交 + +## 验收标准 + +- [x] 管理员可开关;非管理员 403 +- [x] null/字符串布尔拒绝 +- [x] 并发 CAS 仅一版成功 +- [x] 审计失败回滚设置 +- [x] 窄接口可读 allowed+revision;缺行默认 false;DB 故障不伪装 + +## 相关链接 + +- Plan: `.doc/remediation/2026-09-17-document-author-only-plan.md` U1 +- Checklist: + +## 证据 / 备注 + +- `go test ./internal/sysadmin/ ./internal/server/ -run 'Settings|AuthorOnly|PGSettings|AdminSystem'` PASS +- `go test -short ./internal/sysadmin/ ./internal/server/` PASS +- Key: `workflow.allow_author_only_approval` +- 窄接口: `sysadmin.AuthorOnlyPolicySource` diff --git a/.doc/archive/ops/2026-09-17-document-author-only-u2.md b/.doc/archive/ops/2026-09-17-document-author-only-u2.md new file mode 100644 index 00000000..3fddca29 --- /dev/null +++ b/.doc/archive/ops/2026-09-17-document-author-only-u2.md @@ -0,0 +1,54 @@ +--- +title: "文档编制人兼任:U2 逐文档模式与激活" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/document-author-only-plan +worktree: ".worktrees/feature-document-author-only-plan" +mutex: projectinstance + document-duty-policy +--- + +# 文档编制人兼任:U2 逐文档模式与激活 + +## 目标 + +落地逐文档 `duty_policy=author_only`:规划校验、预览摘要、激活 token 绑定策略 revision,激活事务内行锁串行化关闭总开关。 + +## 范围 + +- DocTaskPlan / PreviewSummary / ProjectSettings 扩展 +- ResolveDocTaskDuty、Save/Preview/Activate 消费 U1 策略 +- Goose 00051 token revision 列 +- app 适配器装配、HTTP 错误码映射 +- 单元测试 + +## 非目标 + +- Heal/文档投影(U3) +- 前端 UI(U5) +- 运行时动作多职责(U4/U6) + +## 步骤 + +1. [x] 类型与解析器 +2. [x] service/store/迁移 +3. [x] app 装配与 handler +4. [x] 测试与提交 + +## 验收标准 + +- [x] 一位成员兼任可存/预览/激活(总开关开) +- [x] 总开关关时 Save 拒绝 +- [x] 预览后 revision 变化旧 token 409 +- [x] 未开模式只填作者仍 partial 拒绝 +- [x] 旧快照缺 duty_policy 时 hash 稳定 + +## 相关链接 + +- Plan: `.doc/remediation/2026-09-17-document-author-only-plan.md` U2 + +## 证据 / 备注 + +- `go vet ./internal/projectinstance/ ./internal/app/ ./internal/server/` +- `go test -short ./internal/projectinstance/ ./internal/server/ ./internal/app/` PASS diff --git a/.doc/archive/ops/2026-09-17-document-author-only-u3.md b/.doc/archive/ops/2026-09-17-document-author-only-u3.md new file mode 100644 index 00000000..1881e45d --- /dev/null +++ b/.doc/archive/ops/2026-09-17-document-author-only-u3.md @@ -0,0 +1,55 @@ +--- +title: "文档编制人兼任:U3 文档绑定与职责投影" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/document-author-only-plan +worktree: ".worktrees/feature-document-author-only-plan" +mutex: docgov + docduty + bizdev + document-duty-policy +--- + +# 文档编制人兼任:U3 文档绑定与职责投影 + +## 目标 + +生成时写入实例治理职责并 `duty_locked`;heal/revoke 跳过锁定行;读侧投影 `duty_policy`。 + +## 范围 + +- Goose 00052:`document_records.duty_locked` / `duty_policy` +- `docgov.LockDocumentDuty` + 列表投影 +- `docduty` heal/revoke `AND NOT duty_locked` +- `bizdev.InstanceDutySource` + `resolveTaskDocumentDuties` 治理分支;跳过 governed 内存 heal +- `app/instance_duty_adapter.go` 装配 + +## 非目标 + +- U4 动作鉴权 / allowed_actions +- U5/U6 前端 +- 合入 `dev` + +## 步骤 + +1. [x] 迁移 +2. [x] docgov / docduty +3. [x] bizdev + app 适配器 +4. [x] 测试与提交 + +## 验收标准 + +- [x] 受治理文档创建后 `duty_locked=true` 且三职来自实例 +- [x] heal/revoke 不清锁定行(SQL 过滤 + 单测/PG 可选) +- [x] 生成后 governed 路径不再用项目默认覆盖内存态 +- [x] 模块边界:bizdev/docduty/docgov 不导入 projectinstance + +## 相关链接 + +- Plan: U3(文档绑定与职责投影一致性) +- 依赖:U1 `3b6def2`、U2 `c03d0a3` + +## 证据 / 备注 + +- `go vet ./internal/docgov/ ./internal/docduty/ ./internal/bizdev/ ./internal/app/ ./internal/projectinstance/` +- `go test -short` 上述包 PASS +- PG:`TEST_DATABASE_URL` 未配置时 `Heal/Revoke SkipsDutyLocked` 自动跳过 diff --git a/.doc/archive/ops/2026-09-17-document-author-only-u4.md b/.doc/archive/ops/2026-09-17-document-author-only-u4.md new file mode 100644 index 00000000..76c08e55 --- /dev/null +++ b/.doc/archive/ops/2026-09-17-document-author-only-u4.md @@ -0,0 +1,47 @@ +--- +title: "文档编制人兼任:U4 动作鉴权与审计" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/document-author-only-plan +worktree: ".worktrees/feature-document-author-only-plan" +mutex: projectinstance + docgov + document-duty-policy +--- + +# 文档编制人兼任:U4 动作鉴权与审计 + +## 目标 + +ABAC 门禁识别 `duty_policy=author_only` 派生职责;审计 Details 携带 duty_policy。 + +## 范围 + +- `duty_action` / `stage_access` / `gate` 改用 `ResolveDocTaskDuty` +- `state_machine` Transition* Details 补 `duty_policy` +- author_only 动作矩阵与阶段可见性测试 + +## 非目标 + +- U5 UI / U6 浏览器闭环 + +## 步骤 + +1. [x] ABAC 三文件 +2. [x] 审计详情 +3. [x] 测试与提交 + +## 验收标准 + +- [x] author_only 规范存储(仅 Author ID)下 A 可 run/edit/submit/review/approve +- [x] 非 A 拒绝;阶段门禁将 author_only 视为 approvalManaged +- [x] 审计 Details 含 duty_policy + +## 相关链接 + +- Plan: U4+U5 实施计划 +- 依赖:U1–U3 + +## 证据 / 备注 + +- `go test -short ./internal/projectinstance/ ./internal/docgov/ ./internal/app/ ./internal/server/` PASS diff --git a/.doc/archive/ops/2026-09-17-document-author-only-u5.md b/.doc/archive/ops/2026-09-17-document-author-only-u5.md new file mode 100644 index 00000000..a71bfaf0 --- /dev/null +++ b/.doc/archive/ops/2026-09-17-document-author-only-u5.md @@ -0,0 +1,48 @@ +--- +title: "文档编制人兼任:U5 系统设置与逐文档 UI" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/document-author-only-plan +worktree: ".worktrees/feature-document-author-only-plan" +mutex: web + document-duty-policy +--- + +# 文档编制人兼任:U5 系统设置与逐文档 UI + +## 目标 + +前端识别 `duty_policy` / `author_only_policy`;项目设置可逐文档开启兼任。 + +## 范围 + +- `projectSettings.ts` 类型 +- `projectSettingsDuties.ts` resolveClientDocTaskDuty + 校验 +- `ProjectSettingsModal` 兼任勾选与预览 +- SettingsSection 不改(白名单通用渲染) + +## 非目标 + +- U6 多职责动作栏 / 浏览器 E2E + +## 步骤 + +1. [x] 类型与 lib +2. [x] UI +3. [x] 测试与提交 + +## 验收标准 + +- [x] author_only 客户端校验通过且不计三人门槛 +- [x] 总开关关闭时兼任勾选禁用并提示 +- [x] 预览展示兼任文档计数 + +## 相关链接 + +- Plan: U4+U5 实施计划 + +## 证据 / 备注 + +- `npx tsc --noEmit` PASS +- `npm test` PASS(含 projectSettingsDuties author_only 用例) diff --git a/.doc/archive/ops/2026-09-17-document-author-only-u6.md b/.doc/archive/ops/2026-09-17-document-author-only-u6.md new file mode 100644 index 00000000..7e598b17 --- /dev/null +++ b/.doc/archive/ops/2026-09-17-document-author-only-u6.md @@ -0,0 +1,55 @@ +--- +title: "文档编制人兼任:U6 多职责动作与真实闭环" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/document-author-only-plan +worktree: ".worktrees/feature-document-author-only-plan" +mutex: web + document-duty-policy +--- + +# 文档编制人兼任:U6 多职责动作与真实闭环 + +## 目标 + +去掉 author-first 动作短路,把认领/送审/审核/批准接到真实 docgov HTTP,完成 author_only 端到端验收。 + +## 范围 + +- `web/src/api/docgov.ts` 四接口客户端 +- `docGovActionVisibility` / `holdsDocDuty` +- `App.tsx` 真实 handler + 快捷指令 +- `duty_policy` 透传与「编制人兼任」徽标 +- 单测 + 真实闭环报告 + +## 非目标 + +- 变更提案 HTTP 路由(另记 audit) +- 合入 `dev` / 推送远端(未授权不推) + +## 步骤 + +1. [x] API 客户端 +2. [x] ID 直判可见性 +3. [x] App 接线 + 驳回批准修正 +4. [x] duty_policy 徽标透传 +5. [x] 单测 / tsc +6. [x] 真实闭环与报告 + +## 验收标准 + +- [x] author_only 下 A 可见三动作;非 A 403 +- [x] 真实 API 闭环写库与审计含 duty_policy +- [x] 浏览器可见「编制人兼任」徽标 +- [x] 测试报告归档 + +## 相关链接 + +- Test report: [../../test-reports/2026-09-17-document-author-only-u6-test-report.md](../../test-reports/2026-09-17-document-author-only-u6-test-report.md) +- Remediation U6: [../../remediation/2026-09-17-document-author-only-plan.md](../../remediation/2026-09-17-document-author-only-plan.md) + +## 证据 / 备注 + +- `npm test` 390 pass;`tsc --noEmit` clean +- assets: `test-reports/assets/2026-09-17-document-author-only-u6/` diff --git a/.doc/archive/ops/2026-09-17-rdev-template-depth-s1-continued.md b/.doc/archive/ops/2026-09-17-rdev-template-depth-s1-continued.md new file mode 100644 index 00000000..a6ac99fb --- /dev/null +++ b/.doc/archive/ops/2026-09-17-rdev-template-depth-s1-continued.md @@ -0,0 +1,57 @@ +--- +title: "rdev 模板深度补强 S1 续批(剩余 46 份)" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/rdev-template-depth-s1-continued +worktree: ".worktrees/feature-rdev-template-depth-s1-continued" +mutex: rdev-templates +--- + +# rdev 模板深度补强 S1 续批(剩余 46 份) + +## 目标 + +将剩余 46 份 `rd_*` 模板按写作规范做同等深度补强,lint allowlist 扩至全 60 份,抽样真 PI 打样收口。 + +## 范围 + +- 批1:6 份评审记录 +- 批2:阶段06 硬件/工艺 8 份 +- 批3:阶段06 软件/检验/技术要求+清单 9 份 +- 批4:阶段08 验证 12 份 +- 批5:阶段10–13 转换/确认 11 份 +- lint allowlist + 抽样真 PI + 文档同步 + +## 非目标 + +- S2/S3、P2 前端、P3 三样本全量 E2E、P4 键名空间化 +- 晋升 test/main + +## 步骤 + +1. [x] 批1 评审记录 6 份 +2. [x] 批2 硬件/工艺 8 份 +3. [x] 批3 软件/检验/清单 9 份 +4. [x] 批4 验证 12 份 +5. [x] 批5 转换/确认 11 份 +6. [x] 抽样真 PI + 文档同步(合入 push 见 merge 步骤) + +## 验收标准 + +- [x] 46 份无「按本阶段专业结构写」;lint allowlist 60/60 +- [x] 抽样 1–2 份真 PI PASS(`rd_planning_review` + `rd_design_vv`,项目 6446) +- [x] FEATURE-MATRIX / 写作规范 / backlog 已更新 + +## 相关链接 + +- Backlog: [.doc/backlog/2026-09-17-rdev-phase2-backlog.md](../../backlog/2026-09-17-rdev-phase2-backlog.md) +- 写作规范: [.doc/system-design/project-workflow/08-rdev模板写作规范.md](../../system-design/project-workflow/08-rdev模板写作规范.md) +- 首批报告: [.doc/test-reports/2026-09-17-rdev-template-depth-test-report.md](../../test-reports/2026-09-17-rdev-template-depth-test-report.md) +- 续批报告: [.doc/test-reports/2026-09-17-rdev-template-depth-s1-continued-test-report.md](../../test-reports/2026-09-17-rdev-template-depth-s1-continued-test-report.md) + +## 证据 / 备注 + +- 资产:`.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/` +- 附带:`fix(projectinstance): allocate next instance_no for planning drafts` diff --git a/.doc/archive/ops/2026-09-17-rdev-template-depth.md b/.doc/archive/ops/2026-09-17-rdev-template-depth.md new file mode 100644 index 00000000..90b06db2 --- /dev/null +++ b/.doc/archive/ops/2026-09-17-rdev-template-depth.md @@ -0,0 +1,54 @@ +--- +title: "rdev 模板深度补强 S0 + S1 首批" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/rdev-template-depth +worktree: ".worktrees/feature-rdev-template-depth" +mutex: rdev-templates +--- + +# rdev 模板深度补强 S0 + S1 首批 + +## 目标 + +修复 rdev leader prompt 错配,并为阶段 01–05 的 14 份 rdev 模板补齐 ISO 13485 专业章节与逐节写作指令,以 dev 真实 PI 打样验证收口。 + +## 范围 + +- S0.1 B1:`rdev_leader.md` + InstructionsFile 注入链 +- S0.2:`rd_design_input_spec` 样例 + 写作规范 + lint +- S0.3:dev 真实 PI 打样 +- S1 首批:阶段 01–05 共 14 份模板(分 3 批) + +## 非目标 + +- overlay 机制本体、R1–R7 二期、per-stage 步骤定制、模型配置化、manifest 元数据、前端(P2)、键名空间化(P4)、阶段 06–13 模板 + +## 步骤 + +1. [x] S0.1 leader prompt 注入链(`6a09062`) +2. [x] S0.2 样例模板 + 规范 + lint(含于 `eb09b21`) +3. [x] S0.3 真实 PI 打样(项目 6424 intake + 6425 `rd_design_input_spec`) +4. [x] S1 批 1–3(`eb09b21`) +5. [x] 文档与门禁 + +## 验收标准 + +- [x] rdev leader AGENTS.md 含「设计控制」且不含「商务对接负责人」「CDO」 +- [x] intake leader 回归仍含「商务对接负责人」 +- [x] 14 份模板 lint 绿灯;真 PI 终稿五节实质论证 +- [x] `go vet` + `go test -short` 全绿 + +## 相关链接 + +- Test report: [.doc/test-reports/2026-09-17-rdev-template-depth-test-report.md](../../test-reports/2026-09-17-rdev-template-depth-test-report.md) +- Assets: [.doc/test-reports/assets/2026-09-17-rdev-template-depth/](../../test-reports/assets/2026-09-17-rdev-template-depth/) +- Backlog: [.doc/backlog/2026-09-17-rdev-phase2-backlog.md](../../backlog/2026-09-17-rdev-phase2-backlog.md) +- 写作规范: [.doc/system-design/project-workflow/08-rdev模板写作规范.md](../../system-design/project-workflow/08-rdev模板写作规范.md) + +## 证据 / 备注 + +- 依赖 `feature/rdev-core`(尚未合 dev);合入前需 rebase 丢弃已进 dev 的 engineering-dashboard 重复提交。 +- 真 PI:`run_1789635054471569201`(intake ~220s);`run_1789635960880707641`(DI ~450s,`stage=RD03_design_input`)。 diff --git a/.doc/archive/ops/2026-09-17-rdev-workflow-integration.md b/.doc/archive/ops/2026-09-17-rdev-workflow-integration.md new file mode 100644 index 00000000..6fbd8a89 --- /dev/null +++ b/.doc/archive/ops/2026-09-17-rdev-workflow-integration.md @@ -0,0 +1,70 @@ +--- +title: "研发工作流(rdev)接入:13 阶段 / 60 文档任务" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: fix/docgov-stage-dynamic → feature/rdev-core +worktree: "-" +mutex: workflow-def +--- + +# 研发工作流(rdev)接入:13 阶段 / 60 文档任务 + +## 目标 + +在现有 5 个正式工作流外新增医疗第 4 工作流「研发设计控制流程」(key: `rdev`),完整承载参考项目 13 phase / 60 文档任务的 ISO 13485 DHF 设计控制链,并通过真实 PI 验收;随后完成全工作流任务键命名空间化(P4)。 + +## 范围 + +- 方案:[07-研发工作流rdev接入-设计与实施计划](../../system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md)(P0–P4 五期;§8 四项决策已确认) +- P0:docgov Stage 去硬编码 + stage key 透传 +- P1:rdev.v2.json manifest(13×60 一次到位)+ 60 份 rd_ 前缀模板 + catalog/seed/registry + squad kind 编排 + main-completed precheck + 齐套测试 + stub PG 全链路 +- P2:前端激活规划/切换/前置 Banner + intent 文档名匹配 + prior docs 注入与血缘 +- P3:真实 PI Agent E2E(rd_proposal / rd_planning_review / rd_sw_plan 三样本)+ CI 门禁扩四 +- P4:现有工作流(main/pm/tm + 软件两盘点)任务键 `bd_`/`pm_`/`tm_`(`sw_`)空间化,alias 兼容式改名(D12),独立分支独立验收 + +## 非目标 + +- 二期增强全部移入 backlog:[2026-09-17-rdev-phase2-backlog](../../backlog/2026-09-17-rdev-phase2-backlog.md)(N/A 不适用页生成、UR/RS 结构化门禁、kind 专用门禁、depends_on 精确血缘、DMR 归档、sw_ 键盘点、complexity regulatory 注入) +- 云盘 canonical DHF/DMR 归档(本仓库无云盘模块) +- 软件域(non_medical_software)开放 rdev + +## 步骤 + +1. [x] 用户确认方案 §8 四项决策(2026-09-17:名称「研发设计控制流程」/ 60 任务一次到位 / N/A 规划期关闭 + backlog / rd_ 前缀并扩展到现有工作流键名空间化) +2. [x] P0 分支 `fix/docgov-stage-dynamic`(StageKey 透传;已叠入 `feature/rdev-core`) +3. [x] P1 分支 `feature/rdev-core`(manifest/模板/seed/编排/precheck/齐套测试) +4. [ ] P2 分支 `feature/rdev-web-governance`(前端 + intent + 血缘)— **骨架已定,待独立认领** +5. [ ] P3 真实 PI 验收 + 报告归档 + FEATURE-MATRIX 更新 — **骨架已定,待独立认领** +6. [ ] P4 分支 `feature/workflow-key-namespace`(D12 alias 兼容式改名 + 双路径回归)— **骨架已定,待独立认领** + +## 验收标准 + +- [x] `go vet ./...` && `go test -short` 相关包 PASS(P0+P1) +- [x] 齐套断言:manifest 60 任务 ↔ `templates/docs/rd_*.md` 存在非空;rd_ 键与既有任务键不相交 +- [x] precheck:main 未 completed 时 rd 任务启动被拒且文案含 CDO +- [ ] PG 集成:rdev 实例 planning→live→推进(`TestPG_RDevActivateWithNAClose`,需 `TEST_DATABASE_URL`) +- [ ] web `npm test && npm run build` PASS;Playwright 抽检激活/推进(P2) +- [ ] 真实 PI + MiniMax 三样本 E2E 报告归档 `.doc/test-reports/`(P3) +- [ ] P4:旧 live 实例(旧键)+ 新实例(新键)双路径回归 PASS;全工作流任务键集合无交集 + +## 相关链接 + +- 设计方案: [07-研发工作流rdev接入-设计与实施计划](../../system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md) +- 参考调研: [2026-09-17-rdev-workflow-reference-analysis](../research/2026-09-17-rdev-workflow-reference-analysis.md) +- 二期 backlog: [2026-09-17-rdev-phase2-backlog](../../backlog/2026-09-17-rdev-phase2-backlog.md) +- Checklist: - +- Handoff: - +- Test report: - + + +## 证据 / 备注 + +- 参考项目:`.refs/weichen-agent-system`(configs/workflows/rdev.yaml 60 stage、templates/docs 60 模板、squads/overlays 配套) +- 关键约束:WF-DEF-06(无流程级依赖,precheck 先例 test_manager);doc_id `doc_proj{pid}_{taskKey}` 平铺命名 → rd_ 前缀 + +## 合入说明(2026-09-17) + +- P0+P1 已 `--no-ff` 合入本地 `dev`(含 template-depth S0+S1)。 +- P2/P3/P4 仍 planned,待独立认领。 diff --git a/.doc/ops/short-term/2026-09-17-w4-multi-intent-u9-u12.md b/.doc/archive/ops/2026-09-17-w4-multi-intent-u9-u12.md similarity index 100% rename from .doc/ops/short-term/2026-09-17-w4-multi-intent-u9-u12.md rename to .doc/archive/ops/2026-09-17-w4-multi-intent-u9-u12.md diff --git a/.doc/audit/2026-09-17-adhoc-verification-script-empty-path-join.md b/.doc/audit/2026-09-17-adhoc-verification-script-empty-path-join.md new file mode 100644 index 00000000..9f84280b --- /dev/null +++ b/.doc/audit/2026-09-17-adhoc-verification-script-empty-path-join.md @@ -0,0 +1,45 @@ +--- +title: "临时验证脚本 Path 空字符串拼接导致跨任务取证串味" +status: done +created: 2026-09-17 +updated: 2026-09-17 +severity: low +--- + +# 临时验证脚本 Path 空字符串拼接导致跨任务取证串味 + +## 现象 + +S0.3 真 PI 打样归档时,`rd_design_input_spec-final.md` 一度被写成无关客户的商务对标报告(「掌上无线超声系统 × GE Vscan / Philips Lumify」),而非设计输入规格书终稿。 + +## 根因分析 + +产品侧小队/工作区创建正常:失败的 `rd_*` 请求因 main-completed 硬 precheck 拒绝,`task_runs.WorkspaceID` 如实为空。 + +缺陷在**本地临时取证脚本**(`.doc/test-reports/assets/2026-09-17-rdev-template-depth/run_s0_*.py`)的兜底逻辑: + +1. `ws_id = pi_result.get("workspace_id") or ""` 在响应无该字段时得到空串; +2. Python `pathlib`:`Path(bizdev_root) / ""` **坍缩为父目录本身**(无报错); +3. 随后 `root.rglob("output.md")` 扫遍共享 `data/dev/workspaces/bizdev/` 全树; +4. 「取最长文本」启发式选中历史商务任务终稿并误归档。 + +证据:会话内 `task_runs` 失败记录 `WorkspaceID=""`;`Path('/a/b') / '' == Path('/a/b')` 可复现;误归档文件内含 `adhoc_GEVsca_...` 工作区路径。 + +## 严重度 + +`low` — 仅本地取证脚本;未污染产品库、未改小队/黑板创建逻辑;同轮已用精确工作区路径重归档并随测试报告提交干净终稿。 + +## 关联整改 + +- 无需产品代码改动。 +- 经验红线:临时验证脚本禁止「空路径拼接 + 全树 `rglob` + 取最大值」组合;`work_dir`/`WorkspaceID` 为空时必须失败退出,不得扩大搜索范围。 + +## 证据 + +- 正确终稿:`.doc/test-reports/assets/2026-09-17-rdev-template-depth/rd_design_input_spec-final.md` +- 测试报告:`.doc/test-reports/2026-09-17-rdev-template-depth-test-report.md` +- 工作区 ID 生成:`internal/squad/workspace.go`(`ws_%s_%d` + UnixNano) + +## 开放问题 + +- 无。 diff --git a/.doc/audit/2026-09-17-docgov-modification-proposal-http-gap.md b/.doc/audit/2026-09-17-docgov-modification-proposal-http-gap.md new file mode 100644 index 00000000..915d6dc6 --- /dev/null +++ b/.doc/audit/2026-09-17-docgov-modification-proposal-http-gap.md @@ -0,0 +1,32 @@ +--- +title: "docgov 变更提案缺少 HTTP 路由" +status: resolved +created: 2026-09-17 +updated: 2026-09-17 +severity: medium +--- + +# docgov 变更提案缺少 HTTP 路由 + +## 现象 + +引擎侧存在 `docgov.ProposeModification` / `DecideModificationProposal`,但服务端此前仅注册 `POST /api/v1/docgov/proposal/decide`;前端 `App.tsx`「变更方案提议」(`pendingProposal`)为本地模拟。 + +## 根因分析 + +生命周期三接口与 claim 已注册;变更提案 **propose** 路径从未暴露为 HTTP。 + +## 严重度 + +`medium` — 不阻塞编制→审核→批准主链;阻塞「认领后小队修改须提案确认」的真实治理。 + +## 处置(resolved) + +- 新增 `POST /api/v1/docgov/proposal` → `ProposeModification` +- 前端 `proposeModification` / `decideModificationProposal` + App 接线 +- 单测:`TestServer_DocGovProposeAndDecideHTTP`、`web/src/api/docgov.test.ts` +- 任务:`.doc/ops/short-term/2026-09-17-docgov-modification-proposal-http.md` + +## 证据 + +- 合入后真实 HTTP 证据见 `.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/` diff --git a/.doc/audit/2026-09-17-document-author-only-analysis.md b/.doc/audit/2026-09-17-document-author-only-analysis.md new file mode 100644 index 00000000..a340f40f --- /dev/null +++ b/.doc/audit/2026-09-17-document-author-only-analysis.md @@ -0,0 +1,45 @@ +--- +title: 文档编制人兼任模式源码核查 +status: done +created: 2026-09-17 +updated: 2026-09-17 +severity: medium +--- + +# 文档编制人兼任模式源码核查 + +## 结论 + +当前 dev 基线 adb48c8 不支持本需求。已有标准三人审批、可选免审批、系统设置白名单可复用,但必须补齐逐文档模式、职责来源和前端多职责。 + +## 根因与证据 + +1. `internal/projectinstance/duty_mode.go`:partial 拒绝,full 要求三人分离。 +2. `internal/projectinstance/service.go`:创建者规划权限与激活后冻结已存在。新需求需在这条链上增加总授权,不能只改 UI。 +3. `internal/docduty/store.go`:HealProjectDocuments 按项目默认职责回填受控文档。`internal/docgov/project_docs.go` 在列表读取时调用;`internal/bizdev/service.go` 在生成后调用。新增逐文档策略必须阻止该链覆盖实例职责。 +4. `web/src/lib/currentDocRole.ts` 优先返回 author;`docGovActionVisibility.ts` 按单角色显示动作。同人三职会缺审核和批准入口。 +5. `internal/server/handlers.go` 三个动作端点固定使用相应 action role,并以登录身份调用引擎;`internal/docgov/state_machine.go` 可复用逐步流转。 +6. `internal/sysadmin/settings.go` 有白名单 bool 设置,但没有本需求 key;存储只保留最后修改人时间,新策略需独立版本与审计。 + +以上为静态源码核查,未查询真实用户数据,未执行功能测试,未声称线上已复现或已修复。完整源码映射与实施单元见方案。 + +## 关联方案 + +[文档编制人兼任审核批准](../remediation/2026-09-17-document-author-only-plan.md) + +## 文档检查 + +已检查两级权限、逐文档独立性、full/none 兼容、前后端职责来源、状态与锁、并发授权撤销、成员退出、迁移及回滚。修正一处 docflow 路径为 internal/app/docflow。方案明确区分已知事实与建议口径。 + +ce-doc-review 未运行:当前工具目录未提供可调用的 skill invocation 入口;按 ce-plan 的 skill_unreachable 分支记录,不能把本地自检当作独立文档审查通过。 + +```yaml +fixes_applied: 0 +proposed_fixes_count: 0 +decisions_count: 0 +fyi_count: 0 +skipped_reason: skill_unreachable +skipped_detail: No callable skill invocation interface in this session; local source and consistency checks only. +``` + +上方计数仅为未运行技能的占位,不代表产品决策已解决。开放问题仍为 R9、R10;用户未答复时保持方案建议,业务实现不得自行视为授权。 diff --git a/.doc/audit/2026-09-17-system-health-check.md b/.doc/audit/2026-09-17-system-health-check.md new file mode 100644 index 00000000..6d42da41 --- /dev/null +++ b/.doc/audit/2026-09-17-system-health-check.md @@ -0,0 +1,180 @@ +--- +title: "系统全面体检:任务收口、缺陷与未启用功能审计" +status: done +created: 2026-09-17 +updated: 2026-09-17 +severity: high +--- + +# 系统全面体检(2026-09-17) + +> 范围:① 规划未实现 / 需变更 / 实施中任务;② 现存与潜在 bug;③ 已实现但未启用或未测试的功能。 +> 方法:文档 SSOT 盘点(CURRENT / FEATURE-MATRIX / gap-matrix / short-term / long-term / backlog / ADR / handoff)+ Go 全量 vet/build/test + 前端 test/build + 特性开关与环境配置交叉核对。只读审计,未实施任何修改。 +> 基线验证:`go vet ./...` 与 `go build ./...` 全绿;`go test -short -count=1 ./...` 62 包全 ok;`web` npm test(380+32)与 build 通过。 + +--- + +## 一、任务状态盘点 + +### 1.1 实施中(in_progress) + +| # | 任务 | 位置 | 剩余工作 | +|---|------|------|----------| +| T1 | partial/deferred 全量收口总控 | `.doc/ops/short-term/2026-09-16-partial-deferred-completion.md`(owner Cline) | W5(SWBOOT-10 真实现 + 真实 PI + UI 二次验收)、W6(FAV shadow→live 放量)、W7(D7 按 ADR 整改,待合规签字)、逐行升 done | +| T2 | Control Center 个人中控台 | `.doc/ops/short-term/2026-09-17-control-center-server.md`(owner Kimi) | 后端 P0–P6 已签;**唯一剩余:前端 SPA 七页人工浏览器走查** | +| T3 | 纯软件项目自举 W5b | `.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md` | SWBOOT-10 受控执行器(真 patch/merge)未启动,需独立安全评审,当前 fail-closed stub | + +### 1.2 已规划未开始 + +| # | 任务 | 位置 | +|---|------|------| +| T4 | 首次加载性能 U1/U3–U7 | `.doc/remediation/2026-09-17-initial-load-performance-plan.md`(仅 U2 done:主 JS 传输 2.82 MB→~624 KB) | +| T5 | 选择性模块解耦 M4–M6 | `.doc/ops/long-term/2026-09-14-selective-module-decoupling.md`(planned,owner=TBD) | +| T6 | 产品 backlog:B5 报价复核、A5/A6 小队降级与 trace、A7 中文注释迁移、D5 KB 按环境隔离评估 | `.doc/backlog/chat-agent-p6-backlog.md`(均无任务文件) | +| T7 | `TestReconcile_LegacyRepair_PG` 工程测试债 | gap-matrix DOC-CONTENT-CANONICAL-HASH-DRIFT 行(需隔离 PG,本机无) | +| T8 | IAM 收尾开放项:410 退役产品拍板、R13 全量清单勾选 | handoff `2026-09-16-phase3-leftover-closeout.md` | + +### 1.3 部分完成(partial,剩余关闭条件) + +| 行 | 剩余关闭条件 | +|----|--------------| +| 纯软件项目自举 | SWBOOT-10 非 stub(W5b)+ 真实 PI 全链路 + UI 二次验收 | +| FAV | W6 shadow 已合;live 未放量(staging→artifact→publication 各 ≥7 天观察窗) | +| Control Center | 前端人工走查 PASS 后升 done | +| 首次加载性能 | U1/U3–U7 | +| 文档可靠性试点 | M1 结论「停止扩面」;M2(U5–U12)明确禁止开工,真实 PI/浏览器验收未勾 | + +### 1.4 deferred 待决策 + +- **D7 对话数据治理**:ADR `2026-09-17-d7-chat-data-retention.md` 仍 `pending-compliance-signoff`;W7 盘点未发现违规;签字前禁止物理删除。 +- **`SEMANTIC_SUMMARY_REPLACE`**:决策默认不启用;启用须另开授权 + fail-closed 灰度。 +- **`MULTI_INTENT_PLAN_TIER` 升 confirm**:W4 证据闭环,升级需独立授权;永不默认 full。 + +### 1.5 文档漂移 / 矛盾(文档债) + +| # | 漂移 | 涉及文件 | +|---|------|----------| +| D-1 | PU8 四份契约文件仍 `status: in_progress`,但 U9–U12 已升 done、报告已归档 | `.doc/ops/long-term/2026-09-16-pu8-u9/u10/u11/u12-*.md` | +| D-2 | ADR modular-monolith-decoupling 仍「提议,等待用户确认」,但 Phase 1/2 已落地并被当作已确认结论引用 | `.doc/adr/2026-09-14-modular-monolith-decoupling.md` | +| D-3 | audit/INDEX.md 与 remediation/INDEX.md 末行称首次加载性能「方案未实施」,与 U2 已验收矛盾 | 两个 INDEX.md(**本审计已顺手修正为准确表述**) | +| D-4 | handoff `2026-09-15-conversation-intent-dual-tab.md` 的「未完成」已被后续 PASS 覆盖但未回标 | 该 handoff | +| D-5 | backlog D3/D7 条目与 W2 done、W7/ADR 草案并存,backlog 未同步 | `.doc/backlog/chat-agent-p6-backlog.md` | +| D-6 | PROJECT.md §9「剩余 blocker」表述与 gap-matrix「门禁已签、production_ready=yes」口径冲突 | `PROJECT.md` | + +--- + +## 二、Bug 清单 + +### 2.1 确认 bug(有明确证据) + +**后端** + +| ID | 描述 | 证据 | 严重度 | +|----|------|------|--------| +| B-1 | **SSE 聊天流被全局 WriteTimeout=120s 强杀**:`server.go:350` 的 WriteTimeout 是绝对期限;`handleChatStream`(`sse.go:129`)从不调用 `extendSSEWriteDeadline`、无心跳,该机制只接到了 run-events 订阅端点。任何 >120s 的聊天回合(长 PI 推演常见)必断流 | `internal/server/server.go:350`、`internal/server/sse.go:46-64,129` | high | +| B-2 | **`POST /tasks/run/stream` 同样无 deadline 延长/心跳**,且断线后无法续订:`sse.go:390` 写死 `Subscribe(created.RunID, 0)`,不接受 `after_seq` | `internal/server/sse.go:305-424,390` | high | +| B-3 | `handleRunTaskStream` **静默吞 JSON 解析错误**:`_ = json.NewDecoder(r.Body).Decode(&req)`,畸形 body 以空请求走默认 `TaskKey="intake"`,可能误启动 intake 任务 | `internal/server/sse.go:315,362-364` | medium | +| B-4 | `handleRunTaskStream` **Flush 200 SSE 头之后才做会失败的操作**(CreateAndStart/Subscribe),失败时往 SSE 流里写 JSON 错误体;同文件 chat 路径明确「授权必须在 Flush 前」,task 路径未对齐 | `internal/server/sse.go:355-360,383-393` | medium | + +**前端** + +| ID | 描述 | 证据 | 严重度 | +|----|------|------|--------| +| B-5 | **会话窗口缓存 key 不一致**:读/初次写带 `userId` 前缀,但 `loadOlder`/`loadFullDay`/`jumpToDate`/`loadDayForAnchor`/`appendLiveMessage` 等 7 处后续写入不带前缀 → 刷新/切会话恢复到旧窗口;且 LRU 驱逐只用无前缀 key,带前缀缓存永不驱逐,localStorage 只增不减 | `web/src/hooks/useChatWindow.ts:84,100,136,235,271,315,365,416,439,471`、`web/src/data/storage.ts:164-176` | high | +| B-6 | **mount 时 IndexedDB 恢复用陈旧快照整体覆盖 deliverables**:捕获 mount 闭包、非函数式 set,resolve 前新增的 deliverable 被静默丢弃(旁边已有 `deliverablesRef` 未用) | `web/src/App.tsx:326-336` | medium | +| B-7 | `useTaskRunStream` 返回**伪 ref**(每渲染新建对象、快照值非 live),且全仓库无消费方——误导性 API | `web/src/hooks/useTaskRunStream.ts:96` | low | + +### 2.2 高风险隐患 + +| ID | 描述 | 证据 | +|----|------|------| +| R-1 | **聊天会话无归属校验**:`getOrCreateSession` 仅凭客户端传入 `session_id` 返回既有内存会话,随后直接覆盖 `sess.UserID`;非项目会话(ProjectID==0)无任何授权关卡,内存 Messages/Facts 可进入他人 Prompt 与 SSE。缓解:session ID 为纳秒时间戳不易猜 | `internal/server/server.go:394-415`、`handlers.go:181`、`sse.go:187`、`stage_access_helpers.go:251-253` | +| R-2 | **18 个非测试文件 `rows.Next()` 循环后未查 `rows.Err()`**,中途断连静默截断结果集(ctxmgr 历史/窗口/搜索、docgov engine、freedoc、storage/cas、controlcenter tasks、`cmd/document-content-reconcile` 漏检会让 reconcile 漏报文档) | 如 `internal/ctxmgr/chat_history.go`、`internal/docgov/engine.go`、`cmd/document-content-reconcile/main.go:82` | +| R-3 | 事件 dispatcher `Handlers` map **写持锁、读不持锁**(当前仅启动期注册则良性,动态注册即 race) | `internal/events/worker.go:87,154,234` | +| R-4 | `memTurns` 全局幂等表只增不删且跨实例共享,长期运行内存缓增、测试间可能串扰 | `internal/ctxmgr/chat_turns.go:169` | +| R-5 | `internal/docrevision/pg_store.go:68-208` 十余处 `_ = json.Unmarshal(...)`,账本 JSONB 损坏时静默零值化 | 同左 | +| R-6 | 后台 ticker goroutine 无关停通道(通知提醒、vault Drain 错误被丢弃、bizdev 心跳用 `context.Background()`),`Shutdown` 未覆盖 | `notification_reminder_loop.go:15`、`vault.go:63`、`bizdev/service.go:682` | +| R-7 | SSE 重连对 401/404 无 fail-fast:任何错误一律退避重连 40 次(~3 分钟)期间 UI 一直 thinking;`streamChat` 对畸形 data 帧直接抛错中断整条流 | `web/src/api/taskRuns.ts:108-140`、`api/chatStream.ts:122` | +| R-8 | 组件卸载/登出不 abort 任务流,在途 SSE 继续写 localStorage | `web/src/App.tsx:595,2804` | + +### 2.3 环境 / 配置缺陷 + +| ID | 描述 | 证据 | 严重度 | +|----|------|------|--------| +| E-1 | **main 环境 app `ENV=prod` 与 events `ENV=main` 不一致**:事件信封以 `Environment="prod"` 写入,而 `event_consumer_modes` 只种子 dev/test/main 三行 → app 侧 `WriterLive("prod")` 恒 false(in-process 直连不退出),events worker 按 'main' 判定 → **main 存在双写或 durable 管道空转风险**(取决于 main 行 mode)。dev/test 两服务 ENV 一致,无此问题 | `docker-compose.main.yml:90,160`、`internal/app/docflow/commands.go:276`、`goose/00040:55-59`、`internal/events/dispatcher.go:308-313` | **critical(main 专属)** | +| E-2 | **main schema 落后两版(47 vs dev 49)**:00048 给 projects 加 13 列,当前 HEAD 代码直接 SELECT/INSERT 这些列;00049 建 system_settings。若把 dev HEAD 镜像升 main 而不先跑迁移,项目工作流查询/系统设置 API 直接 SQL 报错。**main 晋升顺序必须 events(跑迁移)先于 app** | `goose/00048`、`goose/00049`、`internal/projectworkflow/store.go:76,113` | high(晋升窗口) | +| E-3 | `MULTI_INTENT_PLAN_TIER` 未在任何 compose environment 声明(configs/.env 设了也传不进容器);test/main 连 U9–U12 flag 都未声明 → 容器内锁死 legacy,授权升级时需先改 compose | `docker-compose.*.yml` | medium | +| E-4 | `CURSOR_RUNNER_URL` 三环境 compose 都注入但代码零读取;cursor-agent 容器是 alpine 占位,cursor-cli 后端容器部署不可达 | `docker-compose.*.yml` | low | +| E-5 | `ENABLE_DEV_IMPORT` 不在任何 compose/.env.example;且判断把 `cfg.Env==""` 也算 dev——配置缺失时导入门禁自动放开 | `internal/server/chat_window_api.go:171` | medium | +| E-6 | `SEED_DEMO_USERS` 仅 test compose 显式 false;dev 依赖默认播种(demo 密码 123456),main 靠代码 `Env!="dev/test"` 兜底,主 compose 未显式关闭 | `docker-compose.test.yml:91`、`internal/auth/bootstrap.go:67-116` | low | +| E-7 | `INTENT_V2_DECISION_TIER` 非法值回退 `query`(激进),与 `MULTI_INTENT_PLAN_TIER` 回退 `legacy`(保守)策略不一致 | `internal/intent/flags.go:47-64` | low | +| E-8 | `publisher_gate_test.go:16` 读 `FAV_PUBLISHER_ENABLE`(少 D),与正式变量名不符,该测试门永远走默认分支 | `internal/artifactvault/publisher_gate_test.go:16` | low | +| E-9 | main compose 的 system-db initdb 只挂 00001_baseline.sql,与 dev/test 不一致;新建卷时初始脚本漂移(依赖 events 启动迁移补齐) | `docker-compose.main.yml:23` | low | + +--- + +## 三、已实现但未启用 / 未测试的功能 + +### 3.1 特性开关封存(实现完整,全环境 off) + +| 功能 | 实现位置 | 状态与门禁 | +|------|----------|-----------| +| FAV 事件订阅者 | `internal/app/fav/subscriber.go` | 三重硬门禁:cmd/events 刻意不注册、registry fail-closed、SetMode 拒绝非 off;仅 ObserveShadow 可达 | +| FAV 发布器 | `internal/server/vault.go:18-67` | `FAV_PUBLISHER_ENABLED=false` 三环境显式;且 VAULT_* 三变量 compose 全未声明,容器内即使开 flag 也会静默 return(E-类:实际不可误开,属设计内封存) | +| FAV 降级读 | `internal/docquery/service.go:70` | `FAV_DEGRADED_READ=false` 红线保持 | +| 多意图 PlanTier shadow/auto_read/confirm | `internal/app/intentflow/turn.go` | 容器内锁死 legacy(见 E-3) | +| U9–U12 扩面 | `intentflow/expansion.go`、`mux.go` | 全 false;dev compose 已声明可调,test/main 未声明 | +| 意图 V2 shadow/decision | `internal/intent/shadow_v2.go`、`v2_decision.go` | 三环境显式 false | +| 语义摘要 shadow | `internal/ctxmgr/semantic_summary.go:216` | 默认 off,且仅 AGENT_BACKEND=pi-agent 时挂载 | +| PI change-report 叙事 | `internal/docrevision/narrative.go:68` | 默认 off,仅确定性 fallback(不得冒充完整 PI) | +| artifact-vault CLI | `cmd/artifact-vault/main.go` | 未部署 stub | +| eventmode cutover CLI | `cmd/eventmode/main.go` | 硬门禁仅 dev 可用(`internal/events/cutover.go:13`) | + +### 3.2 死代码 / 机制先行无消费方 + +| # | 项 | 位置 | +|---|----|------| +| X-1 | `SEMANTIC_FALLBACK_ENABLED` 死 flag:无任何业务消费方,仅 sysadmin 展示;三环境 compose 都在设一个没用的变量 | `internal/intent/flags.go:43`、`internal/app/app.go:285` | +| X-2 | `SEMANTIC_SUMMARY_REPLACE` 读取后 `_=` 显式丢弃,刻意永久未接线 | `internal/ctxmgr/semantic_summary.go:257` | +| X-3 | goose 00048 三张新表 `project_source_bindings`/`repo_path_allowlist`/`project_type_workflows` 无代码消费方(swboot 目录硬编码内存版,RepoAllowlist 为 stub) | `internal/swboot/catalog.go:92,118`、`repo_allowlist.go` | +| X-4 | `system_settings` 白名单 6 键全部无业务消费方——可写不生效,纯机制先行 | `internal/sysadmin/settings.go:57-66` | +| X-5 | 前端死组件:`DocumentNotificationList.tsx`(含已读回写,功能完整无挂载点)、`WorkflowPrerequisiteBanner.tsx`、`user/SettingsModal.tsx`(已被 UserSettingsModal 取代);死 API:`api/projectWorkflow.ts:71 activateWorkflow`;`data/storage.ts:141 saveMessages` deprecated no-op | web/src 各文件 | +| X-6 | `useTaskRunStream` 伪 ref(同 B-7) | — | + +### 3.3 测试盲区 + +| # | 盲区 | 说明 | +|---|------|------| +| S-1 | `TestReconcile_LegacyRepair_PG` 从未执行 | 要求隔离 PG 且硬拒绝 5433/5434/5435;canonical-hash 修复链路零自动化验证(gap-matrix 已记测试债) | +| S-2 | 两个「永久跳过」 | `taskstream_test.go:189`(功能已删,测试成死代码应删除);`plan_owner_test.go:125`(重定向跳过) | +| S-3 | 无测试的 cmd 入口 | `cmd/server`、`cmd/events`、`cmd/control-center`、`cmd/artifact-vault`、`cmd/dataset`、`cmd/eventmode`、`cmd/recategorize`、`cmd/document-revision-backfill`(仅 bizdev-cli、document-content-reconcile 有);`internal/controlcenter/{config,store,tasks,webui}`、`internal/observability`、`internal/workflow` 无测试文件 | +| S-4 | **E2E 默认全不跑**:web/e2e 9 个 spec 全部被环境门禁(`RUN_UI_ACCEPTANCE=1`/`DOCREV_E2E_BASE_URL`/`INTENT_DUAL_TAB=1`),默认 `playwright test` 实跑 0 用例;`test:ui-acceptance` script 只覆盖 1 个 spec,其余 8 个无 script 入口 | web/e2e/、web/package.json:11 | +| S-5 | 409 信封处理只覆盖部分消费方:projectMembers、freeformDoc、createTaskRun、chatSessions upsert 等 409 只能裸报错(与 FEATURE-MATRIX hardening backlog 一致) | `web/src/api/projectMembers.ts`、`freeformDoc.ts:177`、`taskRuns.ts`、`chatSessions.ts` | +| S-6 | 前端 bundle 大 chunk 未分割(mermaid/katex/xlsx/docx 各成 >500 kB chunk,已有 precompress 缓解) | vite build 警告 | + +--- + +## 四、结论与优先级 + +**总体判断**:发布门禁与 FEATURE-MATRIX 的 `done/partial/deferred` 口径与代码实况基本一致,未发现「stub 冒充验收」类违规;质量门禁(vet/test/build)全绿。主要风险集中在三处: + +1. **E-1(main ENV 不一致 → 双写/空转)是唯一 critical 级发现**,且只在 main 环境存在, dev/test 的「healthy」不能证明 main 事件链路正确。 +2. **SSE 120s 断流(B-1/B-2)直接影响长任务可用性**,是用户可感知的现存 bug。 +3. **封存面(FAV/多意图 tier/U9–U12/语义摘要/PI 叙事)实现完整但容器内不可调或硬门禁封存**,红线达标,但授权放量时会发现 compose 层缺失(E-3)与若干死接线(X-1/X-2)。 + +详细整改顺序见 [remediation 方案](../remediation/2026-09-17-system-health-remediation-plan.md)。 + +## 关联整改 + +- Remediation: [2026-09-17-system-health-remediation-plan.md](../remediation/2026-09-17-system-health-remediation-plan.md) + +## 证据 + +- `go vet ./...` / `go build ./...` / `go test -short -count=1 ./...`(62 包 ok)/ `cd web && npm test && npm run build`:2026-09-17 全绿。 +- 文中所有 `文件:行号` 引用均为 2026-09-17 dev 分支工作区实测。 + +## 开放问题 + +- E-1 的修复取向(app 改 `ENV=main` vs 事件信封/consumer mode 键改用部署名)需架构决策。 +- D7 合规签字、`MULTI_INTENT_PLAN_TIER` 升 confirm、FAV live 放量均需用户显式授权,不在本整改自动范围内。 +- configs/.env.dev/.env.test/.env.main 实际文件未读取(含密钥);若偏离 example 模板,E-3/E-4/E-6 结论需复核。 diff --git a/.doc/audit/INDEX.md b/.doc/audit/INDEX.md index d5d83e91..e8200a2d 100644 --- a/.doc/audit/INDEX.md +++ b/.doc/audit/INDEX.md @@ -473,4 +473,5 @@ Day 4-5: Phase C 完善与发布准备(20h) *本索引最后更新于 2026-09-12 15:05 UTC* -- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-audit.md)(三入口重启首访;方案未实施)。 +- 2026-09-17:[系统全面体检](2026-09-17-system-health-check.md)(任务收口 / 缺陷 / 未启用功能;配套 [整改方案](../remediation/2026-09-17-system-health-remediation-plan.md) Wave0–7)。 +- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-audit.md)(三入口重启首访;U2 已验收,主 JS ~624 KB;U1/U3–U7 待实施)。 diff --git a/.doc/backlog/2026-09-17-rdev-phase2-backlog.md b/.doc/backlog/2026-09-17-rdev-phase2-backlog.md new file mode 100644 index 00000000..21fb791f --- /dev/null +++ b/.doc/backlog/2026-09-17-rdev-phase2-backlog.md @@ -0,0 +1,69 @@ +# rdev 研发工作流二期增强 Backlog + +> 来源:[07-研发工作流rdev接入-设计与实施计划](../system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md) §8 决策 3——首期 N/A 采用「激活规划期关闭」,以下增强项不阻塞首期交付,**用户要求记录以便随时续推进**。 +> 创建:2026-09-17。每项建议独立 short-term 任务认领。 + +## 优先级排序(建议) + +### R1 N/A 不适用页生成模式(对齐参考项目行为) + +- 现状:skippable 任务在激活规划期由创建者关闭 = 不适用; +- 目标:保留任务但可生成「不适用说明页」(完整论述理由而非一句话),仍走审核/批准,再进入下一阶段(参考项目 rdev_software overlay 语义); +- 涉及:manifest 不适用模式标记、bizdev 执行 N/A 生成路径、squad N/A 编排、前端规划 UI「保留但声明不适用」选项; +- 验收:软件类任务声明不适用 → 生成完整不适用页 → 审批通过 → 阶段可推进。 + +### R2 UR/RS 结构化质量门禁(复用 PM requirements.json 模式) + +- 范围:`rd_user_needs`、`rd_system_reqs`(及 `pm_system_reqs`)输出结构化需求集合;确定性门禁:ID 唯一连续、不随机抽取、缺验证准则拒绝、正文/JSON 不一致拒绝; +- 参考先例:`quality.system_reqs.v2`。 + +### R3 review_minutes / upload_index 专用确定性门禁 + +- review_minutes(6 个评审记录):问题-决议-行动项闭环检查(拒绝空洞「同意通过」、行动项无责任人/期限); +- upload_index(7 个附件清单):附件索引表存在性、缺失影响分析存在性检查;禁止伪造附件内容靠 Accept 纪律 + 门禁抽查「待上传后更新」结论。 + +### R4 depends_on 精确血缘与 stale 传播 + +- 现状:rd 任务 prior docs 全量注入 + 摘要级血缘; +- 目标:per-task upstream 声明表(对齐参考项目 rdev.yaml depends_on:rd_planning_review ← rd_proposal+rd_design_plan、rd_verification_review ← rd_design_vv+rd_risk_report_v 等),上游新版本批准 → 下游 stale 且不能完成; +- 参考先例:test_manager 对 approved SR 的 stale 传播(projectworkflow/service.go)。 + +### R5 DMR 双记录归档 + +- 参考项目 `records: [dhf, dmr]` 的设计输出/转换文档在本仓库无对应物;需评估文档「记录归属」字段扩展 + 归档/导出按 DHF/DMR 分类(本仓库无云盘,先落 docgov 元数据与导出分类)。 + +### R6 软件工作流任务键 `sw_` 前缀盘点(并入 P4 或其后) + +- `software_bootstrap`/`software_delivery`(executor `software.doc.v1`)任务键盘点并纳入统一命名空间规约(D12 的遗留尾巴)。 + +### R7 高复杂度 regulatory 步注入策略 + +- 现状:D7 中 regulatory 按任务清单静态注入;目标:接 `complexity_tier`(complexity_high/medium 分层)动态注入,对齐参考项目 `when: complexity_tier=high`。 + +## 依赖关系 + +- R2/R3 可并行;R4 依赖 manifest 落地后的 upstream 声明表;R1 独立;R5/R6/R7 低优先级。 + +--- + +## 模板深度与运行时续批(S1 续批 / S2 / S3) + +> 来源:Kimi 分析方案 + [2026-09-17-rdev-template-depth](../archive/ops/2026-09-17-rdev-template-depth.md) 收口。S0 + S1 首批(14 份)已交付;以下不阻塞本轮合并候选。 + +### S1 续批 — 剩余模板专业章节 + +- **状态**:✅ **done**(2026-09-17)— 46 份全部同等深度补强;allowlist 60/60;真 PI `rd_planning_review` + `rd_design_vv` PASS +- **证据**:[测试报告](../test-reports/2026-09-17-rdev-template-depth-s1-continued-test-report.md);[short-term](../ops/short-term/2026-09-17-rdev-template-depth-s1-continued.md) + +### S2 — 运行时编排与配置化 + +- per-stage 步骤覆盖(相对默认 organize→engineer→draft→review 的阶段差异) +- regulatory 步接 `complexity_tier`(与 R7 可合并认领) +- 模型去硬编码(配置/环境驱动) +- manifest hint + aliases 元数据补齐 + +### S3 — overlay 与质量门禁扩面 + +- 轻量 overlay 机制(模板管结构;overlay/运行时管纪律与粒度下限) +- R2/R3 专用确定性门禁落地 +- P3:三样本真 PI E2E 报告 + CI 四工作流门禁挂钩 diff --git a/.doc/backlog/chat-agent-p6-backlog.md b/.doc/backlog/chat-agent-p6-backlog.md index 4f56087a..7cd0b9d6 100644 --- a/.doc/backlog/chat-agent-p6-backlog.md +++ b/.doc/backlog/chat-agent-p6-backlog.md @@ -5,6 +5,6 @@ - **B5** 报价模块接入轻量复核与输入范围校验(`internal/quote/`) - **A5/A6** 小队工作流支持非关键步骤降级;并行 trace 序号改单次加锁读-算-写(`internal/squad/`) - **A7** 将中文注释中的已知限制迁移为正式 issue -- **D3** 摘要引擎替换为语义摘要(依赖模型接入,需单独评估)(`internal/ctxmgr/summarizer.go`) +- **D3**(已实现,shadow)摘要引擎语义摘要能力已落地:`SEMANTIC_SUMMARY_SHADOW` 默认 off,见 [FEATURE-MATRIX「语义摘要(D3)」行](../status/FEATURE-MATRIX.md)、[PU6 契约](../ops/long-term/2026-09-16-pu6-semantic-summary-contract.md)(`status: done`)、[W2 真 PI 报告](../test-reports/2026-09-17-w2-semantic-summary-shadow-test-report.md)。`SEMANTIC_SUMMARY_REPLACE` 仍未接线(红线,需独立授权)。本行状态转入 FEATURE-MATRIX 常态跟踪,不再作为独立 backlog 开放项 - **D5** 知识库按环境隔离方案评估(当前三环境共享 KB 为 intentional 架构) -- **D7** 对话数据留存期限与敏感字段加密(先对齐合规再定技术方案) +- **D7** 对话数据留存期限与敏感字段加密——草案 ADR [2026-09-17-d7-chat-data-retention.md](../adr/2026-09-17-d7-chat-data-retention.md)(`compliance: pending-compliance-signoff`);[W7 只读盘点](../audit/2026-09-17-d7-data-inventory.md) 未发现基线违规,见 [报告](../test-reports/2026-09-17-w7-d7-inventory-test-report.md)。签字前禁止按 365/180/30 物理删除;本行继续跟踪合规签字进度 diff --git a/.doc/ops/2026-09-17-author-only-pilot-toggle.md b/.doc/ops/2026-09-17-author-only-pilot-toggle.md new file mode 100644 index 00000000..138ddbc8 --- /dev/null +++ b/.doc/ops/2026-09-17-author-only-pilot-toggle.md @@ -0,0 +1,22 @@ +--- +title: "dev 试点:开放编制人兼任总开关" +status: done +created: 2026-09-17 +updated: 2026-09-17 +--- + +# dev 试点:开放编制人兼任总开关 + +## 结论 + +在运行中的 `agent-dev`(`:8080`,schema 52)经 admin CAS 将 `workflow.allow_author_only_approval` 设为 `true`(revision `0 → 1`)。**未**改默认迁移值;**未**晋升 test/main;test/main 仍保持默认 off。 + +## 证据(脱敏) + +- [before-enable.json](../../test-reports/assets/2026-09-17-author-only-pilot/before-enable.json) +- [put-enable.txt](../../test-reports/assets/2026-09-17-author-only-pilot/put-enable.txt) +- [after-enable.json](../../test-reports/assets/2026-09-17-author-only-pilot/after-enable.json) + +## 关联 + +- author-only 收口:[../handoffs/2026-09-17-document-author-only-closeout.md](../handoffs/2026-09-17-document-author-only-closeout.md) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 14123c1f..dbc81ad3 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,19 +1,36 @@ # 当前焦点 -**更新**: 2026-09-17 -**production_ready**: yes(门禁 B1–B4 已签;dev/test `schema_version=48`;main `schema_version=47`;goose 00048 已在 test 迁移。不把 fallback change report 写成完整 PI) +**更新**: 2026-09-18 +**production_ready**: yes(门禁 B1–B4 已签;dev schema 52;test `schema_version=48`;main `schema_version=52`、`env=main`。不把 fallback change report 写成完整 PI) ## 焦点 -1. **partial/deferred 全量收口**:W1–W4 **done**;W5a **done**;W6 **shadow done**;W7 **盘点 done**。test 已至 `rc-v1.0.0-beta.4`(含 optional-duty null FK) -2. **运维**:nightly `DATA_DIR=dev` 已对齐;KB 宿主机分层加载 / fail-closed **done** -3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`(升 `confirm` 须 W4 闭环后独立授权);`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 顺序 staging→artifact→publication、每级观察窗证据驱动(下限 ≥7 天)、`FAV_DEGRADED_READ=false`;flag 默认 off;项目 143 只读 +1. **系统体检整改**:Wave 0–7 **done**(Wave 7 编排确认见 [决策记录](./short-term/2026-09-18-wave7-roadmap-orchestration.md)) +2. **已裁决(supersede)**:`feature/control-center-server` 取代 `engineering-dashboard`;合并前门禁进行中,见 [任务](./short-term/2026-09-17-control-center-server.md) +3. **rdev 模板深度 / document-author-only**:已合入 `origin/dev`;未晋升 test/main +4. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 待合规签字 ## 并行认领 | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| -| Kimi | 2026-09-17-control-center-server | feature/control-center-server | - | controlcenter | in_progress | +| Cursor-Auto | 2026-09-17-control-center-server | feature/control-center-server | - | controlcenter | in_progress | +| Kimi | 2026-09-18-user-manual | feature/user-manual | .worktrees/feature-user-manual | docs | done | +| Cursor-Auto | 2026-09-18-wave7-roadmap-orchestration | docs/wave7-roadmap-orchestration | - | roadmap-orchestration | done | +| Cursor-Auto | 2026-09-18-wave6-doc-drift | docs/wave6-doc-drift | - | doc-drift | done | +| Cursor-Auto | 2026-09-18-wave5-deadcode-decisions | fix/wave5-deadcode-decisions | - | deadcode-cleanup | done | +| Cursor-Auto | 2026-09-18-wave4-hardening-r1-r8 | fix/wave4-hardening-r1-r8 | - | hardening | done | +| Cursor-Auto | 2026-09-18-wave3-test-debt-blindspots | fix/wave3-test-debt-blindspots | - | test-debt | done | +| Cursor-Auto | 2026-09-18-wave2-config-flag-align | fix/wave2-config-flag-align | - | infra | done | +| Cursor | 2026-09-18-wave1-main-env-fix | fix/wave1-main-env-fix | - | infra | done | +| Cursor | 2026-09-18-wave0-p0-bug-fixes | fix/wave0-p0-sse-chat-cache-bugs | - | chat + web | done | +| Kimi | 2026-09-17-system-health-check(只读审计,未实施) | - | - | audit | done | +| Cursor | 2026-09-17-docgov-modification-proposal-http | feature/docgov-modification-proposal-http | - | docgov | done | +| Cursor | 2026-09-17-document-author-only-closeout | feature/document-author-only-plan | - | document-duty-policy | done | +| Cursor | 2026-09-17-rdev-template-depth-s1-continued | feature/rdev-template-depth-s1-continued | .worktrees/feature-rdev-template-depth-s1-continued | rdev-templates | done | +| Cursor | 2026-09-17-rdev-workflow-integration | feature/rdev-core | - | workflow-def | done | +| Cursor | 2026-09-17-rdev-template-depth | feature/rdev-template-depth | .worktrees/feature-rdev-template-depth | rdev-templates | done | +| Codex | 2026-09-17-document-author-only-plan | feature/document-author-only-plan | - | document-duty-policy-plan | done | | Cursor | 2026-09-17-initial-load-perf-u2 | feature/initial-load-perf-u2 | - | web + static-delivery | done | | Codex | 2026-09-17-initial-load-performance-plan | feature/initial-load-performance-plan | .worktrees/feature-initial-load-performance-plan | loading-performance-audit | done | | Kimi | 2026-09-17-system-admin-console | feature/system-admin-console | - | web + sysadmin | done | @@ -126,7 +143,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | - | +| - | idle | 2026-09-18 | feature/user-manual 已合入 dev@6c2fbc4,dist 交付物补录 fix/user-manual-dist 已合入(纯文档;go vet / go test -short PASS,dev 健康检查 200) | - | **phase**: `idle` | `merging` | `smoke` | `e2e` @@ -136,9 +153,9 @@ | 环境 | 端口 | schema | 状态 | |------|------|--------|------| -| dev | 8080 | **49** | healthy(`pi-agent` typed-result-v1;events worker `:8088`;notify/advance/projection live、fav off;health+ready PASS;goose 00049 system_settings 已迁移;demo 6001–6010 retired) | -| test | 8081 | **48** | healthy(`rc-v1.0.0-beta.4`;pi-agent `:18866`;events `:8089`;notify/advance/projection live、fav off;health+ready PASS;goose 00048 已迁移) | -| main | 8000 | **47** | healthy(app + events + runner + DB;与 dev/test 对齐) | +| dev | 8080 | **52** | healthy(含 author_only goose 00050–00052;`pi-agent`;events `:8088`;**试点** `workflow.allow_author_only_approval=true`) | +| test | 8081 | **52** | healthy(Wave 2 rebuild 后 schema 与 dev/main 对齐;pi-agent `:18866`;events `:8089`) | +| main | 8000 | **52** | healthy(`env=main` app+events 对齐;events 先 healthy 后启 app;consumer modes 全 off;Wave 1 已验证) | ## SSOT @@ -150,6 +167,7 @@ | 连续性审计/计划 | [audit](../audit/2026-09-16-feature-matrix-production-readiness-audit.md) · [remediation](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md) · [Wave 0 任务](./short-term/2026-09-16-feature-matrix-wave0-calibration.md) | | 测试报告 | [test-reports/](../test-reports/) | | D7 盘点(W7) | [audit](../audit/2026-09-17-d7-data-inventory.md) · [报告](../test-reports/2026-09-17-w7-d7-inventory-test-report.md) · ADR 仍 `pending-compliance-signoff` | +| Wave 7 编排确认 | [决策记录](./short-term/2026-09-18-wave7-roadmap-orchestration.md) | | Agent 手册 | [AGENTS.md](../../AGENTS.md) | | 意图双标签 busy 交接 | [./handoffs/2026-09-15-conversation-intent-dual-tab.md](./handoffs/2026-09-15-conversation-intent-dual-tab.md) | | 受控正文 hash 漂移 W1–W6 | [../ops/handoffs/2026-09-14-controlled-content-canonical-hash.md](../ops/handoffs/2026-09-14-controlled-content-canonical-hash.md) | diff --git a/.doc/ops/handoffs/2026-09-15-conversation-intent-dual-tab.md b/.doc/ops/handoffs/2026-09-15-conversation-intent-dual-tab.md index fb55f941..7a9771e9 100644 --- a/.doc/ops/handoffs/2026-09-15-conversation-intent-dual-tab.md +++ b/.doc/ops/handoffs/2026-09-15-conversation-intent-dual-tab.md @@ -65,3 +65,13 @@ curl -sf http://127.0.0.1:8080/ready - CURRENT: [CURRENT.md](../CURRENT.md) - Task: [../short-term/2026-09-13-conversation-intent-runtime-state.md](../short-term/2026-09-13-conversation-intent-runtime-state.md) - Test report: [../../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md](../../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md) + +## 回标(2026-09-18,Wave 6 文档漂移修复) + +上方「未完成」与「认领 / 槽位状态」两节是 2026-09-15 交接时刻的快照,**已在同一提交(`8a8f64a`)内被后续证据覆盖**: + +- 浏览器双标签刷新恢复 / busy 租约可见性:项目 6275、`sess-p6275-u10` 真实 PI + Docker 截图(`dual-tab-live.py`、`live.json`、`window-before-refresh.json`、`window-after-refresh.json`、`screenshots/tab-b-after-reload.png` 等,均在同一提交归档)。 +- `CHAT-INTENT-STATE` 已在 [gap-matrix](../../remediation/gap-matrix.md) 标记 **PASS**(「双标签活验项目 6275 / `sess-p6275-u10`」)。 +- mutex 已释放:CURRENT.md 当前无该任务 `in_progress` 行;集成槽位 holder 为 `idle`。 + +**结论**:本 handoff 无待办事项,`status: closed` 生效,仅为历史记录保留原文,不删除。 diff --git a/.doc/ops/handoffs/2026-09-17-document-author-only-closeout.md b/.doc/ops/handoffs/2026-09-17-document-author-only-closeout.md new file mode 100644 index 00000000..85c4860d --- /dev/null +++ b/.doc/ops/handoffs/2026-09-17-document-author-only-closeout.md @@ -0,0 +1,40 @@ +--- +title: "document-author-only U1–U6 收口交接" +status: done +created: 2026-09-17 +updated: 2026-09-17 +from: Cursor +to: integrator +--- + +# document-author-only U1–U6 收口交接 + +## 结论 + +实现与验收任务已全部收口:U1–U6 short-term 已迁入 `archive/ops/`;FEATURE-MATRIX / remediation DoD(除发布末条)已更新;U6 报告已归档。 + +**远端**:已 `git-push-dev.sh` 推送 `origin/dev`(含 author-only merge);**未**晋升 test/main。 + +**dev 试点**:`workflow.allow_author_only_approval=true`(revision 1);证据见 [pilot assets](../../test-reports/assets/2026-09-17-author-only-pilot/) 与 [ops 笔记](../2026-09-17-author-only-pilot-toggle.md)。 + +## 分支 + +- `feature/document-author-only-plan` — 已合入并清理 worktree/本地分支 +- 续作:`feature/docgov-modification-proposal-http`(变更提案 HTTP) + +## 合入 `dev`(已完成) + +- `merge --no-ff feature/document-author-only-plan` → 后续推送至 `origin/dev@ba92a8b` 一带 +- 冒烟:`go vet` / `go test -short` 相关包 PASS;`./scripts/env-service.sh dev rebuild`;`GET /health` → `schema_version=52`、`status=ok` +- 总开关默认迁移值仍为 off;**dev 运行态**已 CAS 试点开启;**未**晋升 test/main + +## 明确不在本期(已转入续作) + +- ~~变更提案 HTTP 路由~~ → [short-term](../short-term/2026-09-17-docgov-modification-proposal-http.md) / [audit resolved](../../audit/2026-09-17-docgov-modification-proposal-http-gap.md) +- test/main 晋升仍另授 + +## 证据 + +- [U6 报告](../../test-reports/2026-09-17-document-author-only-u6-test-report.md) +- [方案 DoD](../../remediation/2026-09-17-document-author-only-plan.md) +- [pilot](../../test-reports/assets/2026-09-17-author-only-pilot/) diff --git a/.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md b/.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md index 6f40884e..f7dd95f6 100644 --- a/.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md +++ b/.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md @@ -63,6 +63,8 @@ L1(SWBOOT-00…09 契约/迁移/目录钩子 + SWBOOT-10 stub)**已合入 `d L1 已合 `dev`。W5a 从 `dev` 开 `feature/w5a-software-bootstrap-ui-pi`(mutex `workflow-design`)。W5b 受控执行器未启动,SWBOOT-10 保持 fail-closed stub。 +**Wave 5 X-3(2026-09-18)**:goose 00048 三表(`project_types` / `compliance_profiles` / `project_type_workflows`;另含零读者的 `project_source_bindings` / `repo_path_allowlist`)消费方接线评估结论 = **deferred → SWBOOT-10/W5b**。不改 schema;API 继续硬编码目录。决策落点:[wave5-deadcode-decisions.md#x-3](../short-term/2026-09-18-wave5-deadcode-decisions.md#x-3)。不阻塞 W5b 立项。 + ## 相关链接 - [详细设计](../../system-design/2026-09-14-software-project-bootstrap-workflow.md) diff --git a/.doc/ops/long-term/2026-09-16-pu8-u10-mutate-contract.md b/.doc/ops/long-term/2026-09-16-pu8-u10-mutate-contract.md index 7ef8fc5f..e18fc313 100644 --- a/.doc/ops/long-term/2026-09-16-pu8-u10-mutate-contract.md +++ b/.doc/ops/long-term/2026-09-16-pu8-u10-mutate-contract.md @@ -1,8 +1,8 @@ --- title: "PU8-U10 mutate — 获批后任务契约" -status: in_progress +status: done created: 2026-09-16 -updated: 2026-09-17 +updated: 2026-09-18 owner: Cursor branch: feature/w4-u9-segment-live mutex: intent-plan diff --git a/.doc/ops/long-term/2026-09-16-pu8-u11-docgov-contract.md b/.doc/ops/long-term/2026-09-16-pu8-u11-docgov-contract.md index 27bb9d7b..f936aa33 100644 --- a/.doc/ops/long-term/2026-09-16-pu8-u11-docgov-contract.md +++ b/.doc/ops/long-term/2026-09-16-pu8-u11-docgov-contract.md @@ -1,8 +1,8 @@ --- title: "PU8-U11 docgov action — 获批后任务契约" -status: in_progress +status: done created: 2026-09-16 -updated: 2026-09-17 +updated: 2026-09-18 owner: Cursor branch: feature/w4-u9-segment-live mutex: intent-plan diff --git a/.doc/ops/long-term/2026-09-16-pu8-u12-workflow-run-control-contract.md b/.doc/ops/long-term/2026-09-16-pu8-u12-workflow-run-control-contract.md index c2ec7ce0..7998f42b 100644 --- a/.doc/ops/long-term/2026-09-16-pu8-u12-workflow-run-control-contract.md +++ b/.doc/ops/long-term/2026-09-16-pu8-u12-workflow-run-control-contract.md @@ -1,8 +1,8 @@ --- title: "PU8-U12 workflow/run-control — 获批后任务契约" -status: in_progress +status: done created: 2026-09-16 -updated: 2026-09-17 +updated: 2026-09-18 owner: Cursor branch: feature/w4-u9-segment-live mutex: intent-plan diff --git a/.doc/ops/long-term/2026-09-16-pu8-u9-segment-proposal-contract.md b/.doc/ops/long-term/2026-09-16-pu8-u9-segment-proposal-contract.md index e1a129a7..e0016dbe 100644 --- a/.doc/ops/long-term/2026-09-16-pu8-u9-segment-proposal-contract.md +++ b/.doc/ops/long-term/2026-09-16-pu8-u9-segment-proposal-contract.md @@ -1,8 +1,8 @@ --- title: "PU8-U9 segment proposal — 获批后任务契约" -status: in_progress +status: done created: 2026-09-16 -updated: 2026-09-17 +updated: 2026-09-18 owner: Cursor branch: feature/w4-u9-segment-live mutex: intent-plan diff --git a/.doc/ops/research/2026-09-17-rdev-workflow-reference-analysis.md b/.doc/ops/research/2026-09-17-rdev-workflow-reference-analysis.md new file mode 100644 index 00000000..cf1971be --- /dev/null +++ b/.doc/ops/research/2026-09-17-rdev-workflow-reference-analysis.md @@ -0,0 +1,93 @@ +--- +title: "参考项目 weichen-agent-system 研发工作流(rdev)文档关系与阶段组成梳理" +created: 2026-09-17 +status: done +confidence: high +--- + +# 调研:参考项目 weichen-agent-system 研发工作流(rdev)文档关系与阶段组成梳理 + +## 结论 + +参考项目 `.refs/weichen-agent-system` 中的「研发流程」(`key: rdev`)是与商务对接(bizdev)、测试经理(test_manager)、产品经理(product_manager)并列的第 4 个工作流,`kind: followon`、`after: bizdev`:商务对接末阶段 CDO 报价批准后才可启动。rdev 共 **60 个阶段(文档任务)**,按 **13 个 phase(01–13)** 组织成 ISO 13485 设计控制链(策划 → 输入 → 输出 → 验证 → 转换 → 确认),每阶段恰好对应 `templates/docs/.md` 一份自描述模板,并有 overlays(工作流级 `rdev.md` + 阶段簇 `rdev_phNN.md` + 文档类型 `kind_*.md`)与 `configs/squads/rdev.yaml` 五步小队编排三层配套。 + +## 证据 + +| 项 | 路径/命令 | 结果 | +|----|-----------|------| +| 工作流定义 | `.refs/weichen-agent-system/configs/workflows/rdev.yaml` | 1473 行;`key: rdev` `kind: followon` `after: bizdev`;60 个 stage | +| 阶段齐套校验 | `.refs/weichen-agent-system/scripts/e2e-rdev.sh` | 脚本内置断言 `want 60 rdev stages`,逐 stage 校验 `templates/docs/.md` 存在且非空,末模板须含「签署」 | +| 小队编排 | `.refs/weichen-agent-system/configs/squads/rdev.yaml` | `default_steps`:organizer → engineer(+regulatory 可选并行)→ writer → leader;关键 stage 覆盖专属 steps | +| 任务叠加 | `.refs/weichen-agent-system/configs/overlays/rdev.md`、`rdev_ph01…ph13.md`、`rdev_software.md`、`kind_review_minutes.md`、`kind_upload_index.md`、`kind_design_record.md` | 工作流级 / 阶段簇 / 文档类型三层叠加,`### role:` 按专员过滤 | +| 研发负责人人设 | `.refs/weichen-agent-system/configs/agents/rdev_leader.md` | 「研发/设计控制小队负责人…按 ISO 13485 把本阶段做成可追溯的 DHF 记录」 | +| 插件 | `.refs/weichen-agent-system/plugins/rdev/plugin.go` | 不注册生成意图,启动走生命周期 `start_workflow`(说法「启动研发流程」) | +| 官方说明 | `.refs/weichen-agent-system/docs/workflows.md` §研发流程、`docs/handoff.md` §研发流程(followon) | 约 60 阶段、每阶段一份 Word(RD-…)、不适用须完整理由并仍走审核/批准 | +| Canonical 归档 | `docs/cloud-drive.md` | `archive/dhf/{NN-阶段名}/{nn-产出}`;`records` 含 `dmr` 且已批准再拷 `archive/dmr/` | + +## 研发工作流的文档关系(五层结构) + +```text +configs/workflows/rdev.yaml ← 阶段 SSOT:key/标题/phase/records/artifact_kind/docx 元数据/fields/depends_on/skippable + │ 一一对应(stage key = 模板文件名) + ▼ +templates/docs/.md ← 自描述模板:文件编号 RD-…、记录归属 DHF(/DMR)、阶段 NN、 + │ 结论/适用性/正文/追溯表/材料摘要/待确认清单/签署 固定骨架 + ▼ +configs/squads/rdev.yaml ← 小队编排:default_steps + 个别 stage 覆盖(五步编制流水线) + ▼ +configs/overlays/*.md ← 任务叠加(注入 TASK 的阶段级撰写指导,多层): + ① rdev.md(工作流级全员质量红线) + ② rdev_phNN.md(阶段簇,ph01–ph13) + ③ kind_review_minutes / kind_upload_index / kind_design_record(文档类型) + ④ rdev_software.md(软件类阶段专项)+ 个别 stage 特例 overlay + ▼ +plugins/rdev/plugin.go ← 运行时入口:启动走生命周期 start_workflow(followon 门禁), + 不注册生成意图;启动时从 bizdev 拷贝已填字段(产品类型/预期用途/目标市场等) +``` + +- **模板即文档**:每份模板头部自带「文件编号 / 记录归属 / 版本 / 阶段」元数据,与 rdev.yaml 中 stage 的 `docx.code`(RD-…)和 `phase` 一致;`scripts/e2e-rdev.sh` 强制校验 60 个 key 与模板一一对应。 +- **记录归属双轨**:`records: [dhf]`(设计历史文件)为主;设计输出/转换类文档 `records: [dhf, dmr]`(同时属器械主记录),批准后额外拷贝到 `archive/dmr/`。 +- **追溯链**:每份模板含固定「追溯」表(本文件 ID ↔ 上游输入 ↔ 下游验证/确认),需求类要求 UR→RS 追溯主表(见 `kind_design_record.md`)。 +- **评审依赖**:评审记录类 stage 用 `depends_on` 显式声明上游(如 `verification_review` depends_on `design_vv`、`risk_report_v`)。 +- **不适用机制**:每 stage 都有 `applicability` / `na_rationale` 字段;软件类/动物试验/临床等 15 个 stage 标 `skippable: true` + `skip_hint`,不适用仍须生成完整不适用页并走审核/批准。 + + +## 工作流阶段组成(13 个 phase / 60 个 stage) + +| Phase | 阶段簇 overlay | Stage(key → 文档) | 数量 | +|-------|--------|--------------------|------| +| 01 策划 | rdev_ph01 | `proposal` 项目建议书、`design_plan` 设计开发计划任务书 | 2 | +| 02 策划评审 | rdev_ph02 | `planning_review` 策划评审记录(depends: proposal, design_plan) | 1 | +| 03 设计输入 | rdev_ph03 | `user_needs` 用户需求规格书(UN)、`regs_stds` 法规和标准清单、`adverse_events` 同类产品不良事件、`system_reqs` 系统需求规格书(SR)、`risk_plan` 风险管理计划、`hazard_analysis` 危险分析、`sw_plan`*/`sw_reqs`*/`sw_config`* 软件三件套、`design_input_spec` 设计输入规格书、`design_input_list` 设计输入清单、`system_design` 系统设计方案 | 12 | +| 04 需求评审 | — | `req_review` 需求评审记录(depends: user_needs, system_reqs) | 1 | +| 05 输入阶段评审 | — | `input_phase_review` 阶段3和4评审记录(depends: design_input_spec/list, req_review) | 1 | +| 06 设计输出 | rdev_ph06 | `drawings_3d` 图纸3D、`product_bom` BOM、`ee_bom`*/`pcba`*/`electrical_sys`* 电气三件套、`sw_arch`*/`sw_ui`* 软件、`purchase_spec` 采购规范、`process_flow` 工艺流程、`packaging` 包装、`ifu` 说明书、`incoming_iqc`/`ipqc`/`fqc` 三级检验、`tech_req` 产品技术要求、`risk_report` 风险管理报告、`design_output_list` 设计输出清单 | 17 | +| 07 输出评审 | — | `output_review` 阶段6评审(depends: design_output_list) | 1 | +| 08 验证 | rdev_ph08 | `prototype_photos` 样机照片、`design_vv` 设计验证方案、`shelf_life` 货架寿命、`lifetime` 使用期限、`biocompatibility` 生物学、`cleaning` 清洁消毒、`emc_safety`* EMC安规、`risk_report_v` 验证风险报告、`sw_release`*/`sw_unit_test`*/`sw_sys_test`* 软件三件套、`qualified_suppliers` 合格供方 | 12 | +| 09 验证评审 | — | `verification_review`(depends: design_vv, risk_report_v) | 1 | +| 10 转换 | — | `dmr_list` DMR文件清单、`pack_ship` 包装运输、`risk_report_t` 转换风险报告 | 3 | +| 11 转换评审 | — | `transfer_review`(depends: dmr_list, pack_ship, risk_report_t) | 1 | +| 12 确认 | — | `validation_plan` 设计确认方案、`use_error` 使用错误、`animal_study`* 动物试验、`sw_quality`* 软件质量、`clinical`* 临床试验报告、`risk_report_val` 确认风险报告、`user_test` 用户测试 | 7 | +| 13 设计确认报告 | — | `design_validation_report` 设计确认报告(末阶段,批准后项目 done) | 1 | + +合计 60,与 `e2e-rdev.sh` 的 `want 60 rdev stages` 断言一致。(`*` = `skippable: true`,实测 grep 共 15 个:软件 9 个(sw_plan/sw_reqs/sw_config/sw_arch/sw_ui/sw_release/sw_unit_test/sw_sys_test/sw_quality)+ 电气 3 个(ee_bom/pcba/electrical_sys)+ emc_safety + animal_study + clinical) + +**artifact_kind 三类**(grep 实测):`document`(常规成文,47 个)、`review_minutes`(评审记录,6 个)、`upload_index`(附件清单/上传索引,7 个,只建索引不伪造内容)。各 kind 有独立 overlay 质量要求。 + +## 小队与治理机制 + +- **小队编制**:复用整理(organizer)/技术(engineer)/撰写(writer)/法规(regulatory)/检索(searcher)专员,种子化「研发流程负责人」(rdev_leader);市场与财务不进研发小队。 +- **默认五步**:organizer(抽材料事实+回填字段+blackboard facts/gaps/implications)→ engineer(技术内容与追溯,可并行)→ regulatory(仅 complexity_tier=high,可选并行)→ writer(按模板成稿)→ leader(二审完整性/粒度/不适用理由/有无编造,不达标退回)。 +- **门禁**:启动前必须 bizdev CDO 已批准(e2e 脚本专门验证「CDO 前启动应被拒绝」);启动时从商务对接拷贝产品类型/预期用途/目标市场等字段。 +- **状态机**:与 bizdev 同一套编制/审核/批准 FSM;末阶段批准后项目标 `done`。 + +## 开放问题 + +- 本仓库(Agent-System)当前尚无 rdev 工作流模块;若要移植,需评估 60 stage × 模板 × overlay × 小队步骤的落地范围与分期(bizdev 已有移植先例)。 +- 参考项目 `records: [dhf, dmr]` 双归属与云盘 canonical 归档机制在本仓库尚无对应物,移植时需决策是否引入。 +- `complexity_tier=high` 才注入 regulatory 步;本仓库复杂度分层(complexity_high/medium overlay)如何对齐需另调研。 + +## 建议动作 + +- 若立项移植 rdev:在 `ops/short-term/` 新建任务,按「工作流定义 → 模板 → 小队 → overlays → 生命周期门禁」顺序分批实施,先做 phase 01–03(策划/输入)切片。 +- 移植时保留「stage key = 模板文件名」一一对应约束,并复刻 e2e 齐套校验(60 stage、模板非空、末模板含签署)。 diff --git a/.doc/ops/short-term/2026-09-18-user-manual.md b/.doc/ops/short-term/2026-09-18-user-manual.md new file mode 100644 index 00000000..134faca6 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-user-manual.md @@ -0,0 +1,58 @@ +--- +title: "系统使用说明书(含真实截图,md/word/pdf/html 四格式)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Kimi +branch: feature/user-manual +worktree: .worktrees/feature-user-manual +mutex: docs +--- + +# 系统使用说明书(含真实截图,md/word/pdf/html 四格式) + +## 目标 + +产出覆盖「从 0 进入系统 → 登录/注册 → 管理员角色权限配置 → 日常使用 → 审批全流程」的终端用户说明书,配 dev 环境真实操作截图(含真实 PI 对话),交付 Markdown / HTML / PDF / Word 四种格式于 `docs/user-manual/`。 + +## 范围 + +- dev 环境(127.0.0.1:8080)Playwright 实操截图约 30 张(登录、工作台、立项、对话、文档、审批链、管理台 8 分组) +- `docs/user-manual/manual.md` 十章中文说明书(内容以代码为准) +- `docs/user-manual/scripts/`(截图与格式生成脚本,可重跑) +- `docs/user-manual/dist/`(html/pdf/docx 产物) + +## 非目标 + +- 不合入 dev、不 push(用户未要求) +- 不改动任何产品代码 +- 不覆盖 control-center/web(空壳)与 engineering-dashboard(工程内部) + +## 步骤 + +1. [x] 分支 + 登记 +2. [x] 环境预检(健康、登录凭证、中文字体、chromium) +3. [x] 截图脚本 `capture-screenshots.mjs` 并采集 +4. [x] 真实 PI 对话截图 +5. [x] 三责审批链 + 阶段门禁截图(zhanggong/lizhuguan/wangzong) +6. [x] 撰写 manual.md +7. [x] build-formats.mjs 生成 html/pdf/docx(pandoc 缺失时 .doc 兜底) +8. [x] 校验(图片引用、PDF 渲染抽查、docx 媒体嵌入) +9. [x] 提交 + 更新 CURRENT + +## 验收标准 + +- [x] 四种格式文件齐备且可打开 +- [x] manual.md 图片引用全部存在,无孤图 +- [x] PDF 中文无乱码、图片正常嵌入 +- [x] 说明书章节覆盖:进入系统/登录/注册/管理员配置角色权限/项目使用/对话/文档/审批/FAQ + +## 相关链接 + +- Checklist: - +- Handoff: - +- Test report: - + +## 证据 / 备注 + +- 演示账号(dev 种子,密码 123456):zhanggong / lizhuguan / wangzong / guest;管理员 admin(密码取 configs/.env.dev) diff --git a/.doc/ops/short-term/2026-09-18-wave0-p0-bug-fixes.md b/.doc/ops/short-term/2026-09-18-wave0-p0-bug-fixes.md new file mode 100644 index 00000000..fd5b1010 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave0-p0-bug-fixes.md @@ -0,0 +1,55 @@ +--- +title: "Wave 0:P0 确认 bug 修复(SSE/缓存/run-stream)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor +branch: fix/wave0-p0-sse-chat-cache-bugs +worktree: "-" +mutex: chat + web +--- + +# Wave 0:P0 确认 bug 修复(SSE/缓存/run-stream) + +## 目标 + +修复体检确认的 P0 bug:SSE 120s 断流(B-1/B-2)、run/stream 解析与时序(B-3/B-4)、前端会话窗口缓存 key 不一致(B-5)、IndexedDB 恢复覆盖(B-6)。 + +## 范围 + +- `internal/server/sse.go`:chat/run stream 心跳 + WriteDeadline;Decode 400;CreateAndStart/Subscribe 前置 Flush +- `web/src/hooks/useChatWindow.ts` + `web/src/data/storage.ts`:缓存 key / LRU 一致 +- `web/src/App.tsx` + `web/src/data/fileStore.ts`:恢复合并 +- 对应单测与测试报告 + +## 非目标 + +- E-1(main ENV 不一致)及 Wave 1–7 其余项 +- FAV / MULTI_INTENT_PLAN_TIER / SEMANTIC_SUMMARY_REPLACE 放量 + +## 步骤 + +1. [x] 从 dev 开 `fix/wave0-p0-sse-chat-cache-bugs`,CURRENT 认领 +2. [x] B-1/B-2 心跳与 deadline +3. [x] B-3/B-4 run/stream +4. [x] B-5 缓存 key +5. [x] B-6 恢复合并 +6. [x] 单测 + 全量回归 + 合入 dev 冒烟 + +## 验收标准 + +- [x] `go vet ./...`、`go test -short ./...`、`cd web && npm test && npm run build` 全绿 +- [x] 畸形 body → 400;Flush 前失败非 200 SSE +- [x] 缓存 key / LRU 单测 PASS;恢复合并不丢新增 deliverable +- [x] dev rebuild + health/ready;真 PI 聊天心跳证据(见测试报告) + +## 相关链接 + +- Audit: [../../audit/2026-09-17-system-health-check.md](../../audit/2026-09-17-system-health-check.md) +- Remediation: [../../remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Test report: [../../test-reports/2026-09-18-wave0-p0-bug-fixes-test-report.md](../../test-reports/2026-09-18-wave0-p0-bug-fixes-test-report.md) + +## 证据 / 备注 + +- Commits:`64efaa3` docs · `54244ed` server · `2620d80` web;合入 `dev` merge `810fb96` +- Assets:`.doc/test-reports/assets/2026-09-18-wave0-p0-bug-fixes/chat-stream-long.sse.txt` diff --git a/.doc/ops/short-term/2026-09-18-wave1-main-env-fix.md b/.doc/ops/short-term/2026-09-18-wave1-main-env-fix.md new file mode 100644 index 00000000..1c65e415 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave1-main-env-fix.md @@ -0,0 +1,53 @@ +--- +title: "Wave 1:main 环境 ENV/schema 修正(E-1/E-2/E-5)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor +branch: fix/wave1-main-env-fix +worktree: "-" +mutex: infra +--- + +# Wave 1:main 环境 ENV/schema 修正(E-1/E-2/E-5) + +## 目标 + +统一 main 环境 app/events 的 `ENV=main` 语义,用 compose 依赖强制 events 先迁移再启 app,并关闭 `Env==""` 视同 dev 的导入门禁漏洞。 + +## 范围 + +- `docker-compose.main.yml`:app `ENV: main`;`depends_on.events` healthy;events 补生产校验所需口令 +- `configs/.env.main`(本机)/ `.env.main.example`:`ENV=main` +- `internal/server/chat_window_api.go`:去掉空 Env 视同 dev +- 回归测试 + 真实 main rebuild 验证 + +## 非目标 + +- Wave 2+;不改 `event_consumer_modes` 的 mode 值;不触碰 FAV / MULTI_INTENT / SEMANTIC_SUMMARY_REPLACE + +## 步骤 + +1. [x] 从本地 `dev` 开分支并 CURRENT 认领 +2. [x] E-1 ENV 对齐 +3. [x] E-2 depends_on events +4. [x] E-5 空 Env fail-closed +5. [x] 单测 + 合入 + main rebuild 冒烟 + +## 验收标准 + +- [x] app/events health 均报 `env=main` +- [x] main schema 已升(health `schema_version=52`,goose `version_id=52`) +- [x] `event_consumer_modes` 中 main 四行仍为 `off` +- [x] 导入端点空 Env → 403;compose ENV 对齐单测 PASS + +## 相关链接 + +- Audit: [../../audit/2026-09-17-system-health-check.md](../../audit/2026-09-17-system-health-check.md) +- Remediation: [../../remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Test report: [../../test-reports/2026-09-18-wave1-main-env-fix-test-report.md](../../test-reports/2026-09-18-wave1-main-env-fix-test-report.md) + +## 证据 / 备注 + +- 合入本地 `dev`:`merge: fix/wave1-main-env-fix` + events secrets unblock +- Assets:`.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/` diff --git a/.doc/ops/short-term/2026-09-18-wave2-config-flag-align.md b/.doc/ops/short-term/2026-09-18-wave2-config-flag-align.md new file mode 100644 index 00000000..626037c7 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave2-config-flag-align.md @@ -0,0 +1,57 @@ +--- +title: "Wave 2:配置与灰度入口对齐(E-3/E-4/E-6/E-7/E-9)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor-Auto +branch: fix/wave2-config-flag-align +worktree: "-" +mutex: infra +--- + +# Wave 2:配置与灰度入口对齐 + +## 目标 + +三环境 compose 显式声明封存 flag(值保持 legacy/false);移除死 `CURSOR_RUNNER`;main 显式 `SEED_DEMO_USERS=false`;`INTENT_V2_DECISION_TIER` 非法值 fail-closed 到 `none`;E-9 仅记录评估结论。 + +## 范围 + +- E-3 / A-4:三环境补 `MULTI_INTENT_PLAN_TIER` + U9–U12 + SHADOW/NARRATIVE 声明 +- E-4:删除 `CURSOR_RUNNER_URL` 与 `cursor-agent` 占位服务 +- E-6:main `SEED_DEMO_USERS: "false"` +- E-7:`parseV2DecisionTier` default → `none` +- E-9:评估记录,不改 initdb + +## 非目标 + +- 不升 `MULTI_INTENT_PLAN_TIER`;不接线 `SEMANTIC_SUMMARY_REPLACE` +- 不改 FAV modes;不改 E-9 initdb 挂载 +- Wave 3+ 范围项 + +## 步骤 + +1. [x] Sonnet 5 产出实施计划(默认决策:删 cursor-agent;不补 REPLACE;E-9 不改代码) +2. [x] E-7 代码 + 单测 +3. [x] E-3 / E-4 / E-6 compose +4. [x] compose flag 声明回归测试 +5. [x] go vet/test;三环境 rebuild + env 抽检 + 冒烟 +6. [x] 合入本地 dev;文档闭环 + +## 验收标准 + +- [x] 三环境 `docker compose config` 可见目标 flag;`CURSOR_RUNNER` 零匹配 +- [x] 容器内 env 抽检 PASS;health/ready PASS +- [x] `go test -short ./...` 全绿 +- [x] test-report 归档;remediation Wave 2 done + +## 相关链接 + +- Remediation: [.doc/remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Audit: [.doc/audit/2026-09-17-system-health-check.md](../../audit/2026-09-17-system-health-check.md) +- Test report: [.doc/test-reports/2026-09-18-wave2-config-flag-align-test-report.md](../../test-reports/2026-09-18-wave2-config-flag-align-test-report.md) + +## 证据 / 备注 + +计划作者:[Wave 2 Sonnet 计划](5b8c8c73-c3e4-4947-b0c4-91822859ff4a) +commits:`07fc0ec` `e0aabec` `55a539c` `8d97dd6` + docs closeout diff --git a/.doc/ops/short-term/2026-09-18-wave3-test-debt-blindspots.md b/.doc/ops/short-term/2026-09-18-wave3-test-debt-blindspots.md new file mode 100644 index 00000000..2777196b --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave3-test-debt-blindspots.md @@ -0,0 +1,46 @@ +--- +title: "Wave 3:测试债与盲区(S-1/S-2/S-4/S-5)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor-Auto +branch: fix/wave3-test-debt-blindspots +worktree: "-" +mutex: test-debt +--- + +# Wave 3:测试债与盲区 + +## 目标 + +关闭 `TestReconcile_LegacyRepair_PG` 工程测试债;清理永久跳过死代码;统一 E2E 脚本入口;推广 409/结构化错误信封复用。 + +## 范围 + +- S-1 / S-2 / S-4 / S-5(按 Sonnet 计划默认决策) + +## 非目标 + +- 不升 MULTI_INTENT_PLAN_TIER;不接线 REPLACE;不改 FAV;项目 143 只读 +- 不改后端 httpx 信封整体迁移;不统一 INTENT_DUAL_TAB + +## 步骤 + +1. [x] Sonnet 5 计划 +2. [x] S-1 隔离 PG 5436 跑一次并归档 +3. [x] S-2 删死跳过 + plan_owner 说明 +4. [x] S-4 e2e scripts + 退役 document-revision.spec +5. [x] S-5 信封复用 + 单测 +6. [x] 文档闭环 + 合入本地 dev + +## 验收标准 + +- [x] reconcile PG PASS 证据 +- [x] playwright --list = 29 tests / 8 files +- [x] npm test / go test -short 全绿 +- [x] gap-matrix 测试债关闭;remediation Wave 3 done + +## 相关链接 + +- Plan agent: [Wave 3 Sonnet 计划](51987049-b338-41a7-84f4-2c97e3655d85) +- Test report: [.doc/test-reports/2026-09-18-wave3-test-debt-blindspots-test-report.md](../../test-reports/2026-09-18-wave3-test-debt-blindspots-test-report.md) diff --git a/.doc/ops/short-term/2026-09-18-wave4-hardening-r1-r8.md b/.doc/ops/short-term/2026-09-18-wave4-hardening-r1-r8.md new file mode 100644 index 00000000..d543aa6e --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave4-hardening-r1-r8.md @@ -0,0 +1,49 @@ +--- +title: "Wave 4:隐患加固(R-1~R-8)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor-Auto +branch: fix/wave4-hardening-r1-r8 +worktree: "-" +mutex: hardening +--- + +# Wave 4:隐患加固(R-1~R-8) + +## 目标 + +按 Wave 4 Sonnet 计划默认决策关闭体检高风险隐患 R-1~R-8。 + +## 范围 + +- R-1~R-8 全项(含 D-1 扩大范围:upsert + COALESCE) + +## 非目标 + +- D-4/D-5;不升 tier;不接线 REPLACE;不改 FAV;项目 143 只读;不 push + +## 步骤 + +1. [x] R-1c/R-1a/R-1b + Memory/PG 负例 +2. [x] R-2 rows.Err() +3. [x] R-3 RWMutex / R-4 memTurns / R-5 hydrate / R-6 Shutdown +4. [x] R-7/R-8 前端 + 单测 +5. [x] 全量验收 + test-report + 合入本地 `.worktrees/dev-rdev-merge` dev + +## 验收标准 + +- [x] R-1~R-8 按计划验收项 PASS +- [x] `go vet` / `go build` / `go test -short ./...` / `npm test` / `npm run build` +- [x] 相关 `-race` 绿 +- [x] 文档闭环 + +## 相关链接 + +- Remediation: [.doc/remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Test report: [.doc/test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md](../../test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md) +- Assets: [.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/](../../test-reports/assets/2026-09-18-wave4-hardening-r1-r8/) + +## 证据 / 备注 + +基线 tip:`32932e4`。合入本地 `dev` 见 merge commit。 diff --git a/.doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md b/.doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md new file mode 100644 index 00000000..e22b5018 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md @@ -0,0 +1,98 @@ +--- +title: "Wave 5:死代码与机制收口(X-1~X-5)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor-Auto +branch: fix/wave5-deadcode-decisions +worktree: "-" +mutex: deadcode-cleanup +--- + +# Wave 5:死代码与机制收口(X-1~X-5) + +## 目标 + +按系统体检整改方案 Wave 5,对 X-1~X-5 逐项决策并收口:删除真死 flag/组件、封存 REPLACE、标记 00048 deferred、保留 system_settings 二期键。 + +## 范围 + +- X-1~X-5 全项(含级联删除 `patchNotification`) + +## 非目标 + +- X-6(`useTaskRunStream` 伪 ref);不接线 REPLACE;不升 MULTI_INTENT_PLAN_TIER;不改 FAV;项目 143 只读;不改 goose 00048/00049 schema;不 push + +## 步骤 + +1. [x] 决策记录落盘(本文件「决策记录」段) +2. [x] X-1 删除 `SEMANTIC_FALLBACK_ENABLED` + 回归测试 +3. [x] X-2 封存注释回链 +4. [x] X-3 00048 deferred 注释 + long-term +5. [x] X-4 system_settings 6 键注释 +6. [x] X-5 删除死组件/死 API +7. [x] 全量验收 + test-report + 合入本地 `.worktrees/dev-rdev-merge` `dev` + +## 验收标准 + +- [x] X-1~X-5 每项有决策记录(本文件) +- [x] `go vet ./...` / `go test -short ./...` / `cd web && npm test && npm run build` +- [x] remediation Wave 5 行改 done;勾选「X-1~X-5 每项有决策记录」 +- [x] 文档闭环 + merge `--no-ff` + +## 相关链接 + +- Remediation: [.doc/remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Audit: [.doc/audit/2026-09-17-system-health-check.md](../../audit/2026-09-17-system-health-check.md) +- Test report: [.doc/test-reports/2026-09-18-wave5-deadcode-decisions-test-report.md](../../test-reports/2026-09-18-wave5-deadcode-decisions-test-report.md) +- Assets: [.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/](../../test-reports/assets/2026-09-18-wave5-deadcode-decisions/) + +## 决策记录 + +### X-1:`SEMANTIC_FALLBACK_ENABLED` — 删除 + +- **现状证据**:字段仅在 `internal/intent/flags.go` 解析、`internal/app/app.go` 注入 sysadmin 视图、`internal/sysadmin/features.go` 目录展示;全仓无业务消费方。三环境 compose + `.env.example` 均声明该死变量。 +- **决策**:删除(不接线)。 +- **理由**:零读者、无设计文档描述预期语义;接线需独立产品设计,保留只会继续污染配置面。 +- **影响范围**:`flags.go` / `app.go` / `features.go` / 三 compose / `.env.example`;新增 `TestComposeNoDeadSemanticFallbackConfig` 防复活。 +- **验收**:`go vet ./...`;`go test ./internal/intent/... ./internal/app/... ./internal/sysadmin/... ./internal/config/...`;`rg SEMANTIC_FALLBACK_ENABLED` 除 `.doc/` 历史外无匹配。 + +### X-2:`SEMANTIC_SUMMARY_REPLACE` — 维持封存 + +- **现状证据**:env 可读但 `semantic_summary.go` 明确 `_ = SemanticSummaryReplaceEnabled()` 且不替换 `sum.Narrative`;负例 `TestSemanticSummary_ShadowOKKeepsDeterministic` 已覆盖 `Replace: true`。 +- **决策**:维持不接线;补强注释回链本决策记录。 +- **理由**:与 CURRENT 红线及 remediation Wave 5 一致;启用需独立授权 + fail-closed 灰度设计。 +- **影响范围**:仅 `summary_flags.go` / `semantic_summary.go` 注释。 +- **验收**:`go test ./internal/ctxmgr/... -run Semantic`;diff 仅注释。 + +### X-3:goose 00048 三表 — deferred → SWBOOT-10/W5b + +- **现状证据**:`project_types` / `compliance_profiles` / `project_type_workflows` 已建表有种子,但 API 走硬编码 `swboot.ProjectTypesCatalog()` / `ComplianceProfilesCatalog()`;`project_source_bindings` / `repo_path_allowlist` 零运行时读写;`RepoAllowlist` 仅 stub + 单测。 +- **决策**:标记 deferred,归 W5b/SWBOOT-10;**不改 schema**。 +- **理由**:FEATURE-MATRIX 明确 SWBOOT-10 仍 stub;DB 化消费方与受控执行器需独立安全评审。 +- **影响范围**:`catalog.go` / `repo_allowlist.go` 注释;long-term 软件自举文档追加一行。 +- **验收**:`go test ./internal/swboot/...`;diff 仅注释与 long-term 文档。 + +### X-4:`system_settings` 6 未接线键 — 保留待二期 + +- **现状证据**:白名单 7 键中仅 `workflow.allow_author_only_approval` 有消费方;其余 6 键(`ui.maintenance_notice`、`ui.read_only_mode`、`chat.default_session_role`、`tasks.default_auto_repair`、`kb.default_search_limit`、`notifications.stage_advance_reminder`)零业务读取。FEATURE-MATRIX 系统管理台「白名单项接消费方」列为二期。 +- **决策**:保留白名单 + 逐键注释说明预期消费方;不删。 +- **理由**:删除需改写多处白盒测试且收益低;机制先行符合一期设计,二期再接线。 +- **影响范围**:仅 `internal/sysadmin/settings.go` 注释。 +- **验收**:`go test ./internal/sysadmin/...`;diff 仅注释。 + +### X-5:前端死组件/死 API — 删除 + +- **现状证据**(`web/` 全目录扫描,排除自身): + - `DocumentNotificationList` / `WorkflowPrerequisiteBanner` / `SettingsModal`:仅自身文件命中;`App` 使用 `UserSettingsModal`。 + - `activateWorkflow`(`api/projectWorkflow.ts`):仅定义处;实际用 `activateWorkflowInstance`。 + - `saveMessages`:已 `@deprecated` no-op,仅定义处。 + - 级联:`patchNotification` 唯一调用方为 `DocumentNotificationList`。 +- **决策**:删除上述 3 组件 + `activateWorkflow`(及类型)+ `saveMessages` + `patchNotification`;同步 import。后端 `PATCH /api/v1/notifications/{id}` 保留。 +- **理由**:真死代码;删除降低误挂载与维护噪音;后端路由未经本波审计故不删。 +- **影响范围**:见删除清单;无 barrel / 无专用单测。 +- **验收**:`cd web && npm test && npm run build`;`rg` 无残留(`activateWorkflowInstance` 除外)。 + +## 证据 / 备注 + +基线 tip:`a12c947`(Wave 4 已合入本地 `.worktrees/dev-rdev-merge` `dev`)。 diff --git a/.doc/ops/short-term/2026-09-18-wave6-doc-drift.md b/.doc/ops/short-term/2026-09-18-wave6-doc-drift.md new file mode 100644 index 00000000..ccac8a95 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave6-doc-drift.md @@ -0,0 +1,51 @@ +--- +title: "Wave 6:文档漂移修复(D-1~D-6)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor-Auto +branch: docs/wave6-doc-drift +worktree: "-" +mutex: doc-drift +--- + +# Wave 6:文档漂移修复(D-1~D-6) + +## 目标 + +按系统体检整改方案 Wave 6,逐项关闭文档漂移 D-1~D-6,使 SSOT(audit/INDEX、remediation/INDEX、FEATURE-MATRIX、PROJECT.md、backlog、ADR、handoff、PU8 契约)口径一致。 + +## 范围 + +- D-1(含附带的 short-term 归档)、D-2、D-3(复核)、D-4、D-5、D-6 + +## 非目标 + +- Wave 5(X-1~X-5)及其涉及的任何代码;不升 tier;不接线 REPLACE;不改 FAV;项目 143 只读;不删除 D7 对话数据 + +## 步骤 + +1. [x] D-1 契约状态回标 + 归档两份 short-term 任务文件 +2. [x] D-2 ADR 状态改 accepted +3. [x] D-3 复核 INDEX 末行(无改动) +4. [x] D-4 handoff 回标闭环 +5. [x] D-5 backlog D3/D7 同步 +6. [x] D-6 PROJECT.md §9 对齐 +7. [x] remediation/CURRENT 收口 + test-report + 合入本地 `dev` + +## 验收标准 + +- [x] D-1~D-6 每项均有落盘证据或复核记录 +- [x] `go build ./...` / `go vet ./...` / `npm run build` 全绿(无代码改动,防误伤) +- [x] remediation Wave 6 行改 done;勾选「D-1~D-6 全部回链关闭」 +- [x] CURRENT 焦点/认领表/集成槽位三处一致 + +## 相关链接 + +- Remediation: [../../remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Audit: [../../audit/2026-09-17-system-health-check.md](../../audit/2026-09-17-system-health-check.md) +- Test report: [../../test-reports/2026-09-18-wave6-doc-drift-test-report.md](../../test-reports/2026-09-18-wave6-doc-drift-test-report.md) + +## 证据 / 备注 + +基线 tip:`2ce2625`(`merge: fix/wave5-deadcode-decisions`)。mutex=`doc-drift`,未复用 `architecture-docs`。FEATURE-MATRIX 交叉检查无改动(D3 done/shadow、D7 deferred、U9–U12 done、`production_ready=yes` 与本次回标一致)。 diff --git a/.doc/ops/short-term/2026-09-18-wave7-roadmap-orchestration.md b/.doc/ops/short-term/2026-09-18-wave7-roadmap-orchestration.md new file mode 100644 index 00000000..079301fc --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave7-roadmap-orchestration.md @@ -0,0 +1,100 @@ +--- +title: "Wave 7:既有 roadmap 收口(编排确认)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor-Auto +branch: docs/wave7-roadmap-orchestration +worktree: "-" +mutex: roadmap-orchestration +--- + +# Wave 7:既有 roadmap 收口(编排确认) + +## 目标 + +对照 FEATURE-MATRIX 剩余工作列,为 T1/W5、T1/W6、T1/W7、T2、T4、T5/T6/T8 各写一条编排确认;不改变任何能力实现状态。 + +## 范围 + +- 六条编排确认文案落盘(见「确认记录」) +- remediation Wave 7 行改 done 并回链本记录 +- CURRENT 焦点改为 Wave 0–7 done,并记录 Control Center 双实现发现 + +## 非目标 + +- 不实施 SWBOOT-10;不升 FAV live / 不改 `FAV_DEGRADED_READ`;不按 D7 物理删除数据;不升 `MULTI_INTENT_PLAN_TIER`;不接线 `SEMANTIC_SUMMARY_REPLACE`;项目 143 只读 +- 不改 FEATURE-MATRIX 状态列;不改 gap-matrix 发布门禁 +- **不补 Wave 6 D-5**:D-1~D-6 已在 `a88ed85` PASS,backlog D3/D7 已同步;本波不改 `.doc/backlog/chat-agent-p6-backlog.md`,不把 Wave 6 再标一遍 +- 不决定 Control Center 两套实现的取舍:不合并、不删除 `feature/control-center-server` + +## 步骤 + +1. [x] 跳过步骤 0(Wave 6 D-5 已完成) +2. [x] 六条编排确认落盘(本文件) +3. [x] remediation Wave 7 行改 done + 回链本记录 +4. [x] CURRENT 焦点改为「Wave 0–7 done」+ 记录 Control Center 分支冲突发现 + +## 验收标准 + +- [x] 六项编排确认均含:当前状态、关闭条件、是否需用户授权、建议下一触发点 +- [x] 无一项被伪装为已实现;FAV/D7/tier 升级仍标注「需用户显式授权」 +- [x] Wave 6 保持已关闭,未重复改写 + +## 相关链接 + +- Remediation: [../../remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Audit: [../../audit/2026-09-17-system-health-check.md](../../audit/2026-09-17-system-health-check.md) +- 总控: [../../remediation/2026-09-16-partial-deferred-completion-plan.md](../../remediation/2026-09-16-partial-deferred-completion-plan.md) +- Test report: [../../test-reports/2026-09-18-wave7-roadmap-orchestration-test-report.md](../../test-reports/2026-09-18-wave7-roadmap-orchestration-test-report.md) + +## 确认记录 + +### T1/W5:SWBOOT-10 + +- **当前状态**:SWBOOT-00~09(契约、迁移、目录、UI、docs_only 真实 PI)已合入 `dev`([long-term](../long-term/2026-09-14-software-project-bootstrap-implementation.md)、[W5a 报告](../../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md))。SWBOOT-10(受控工程执行:repo allowlist、worktree、patch/test/merge 分段批准)仍为 **stub only**,保持 fail-closed,归 W5b,本轮未启动。goose 00048 三张关联表(`project_source_bindings` / `repo_path_allowlist` / `project_type_workflows`)已在 Wave 5 X-3 标记 deferred。 +- **关闭条件**:独立安全评审通过 + SWBOOT-10 非 stub 真实实现 + 真实 PI 全链路证据 + UI 二次验收(见 [partial-deferred-completion-plan §W5](../../remediation/2026-09-16-partial-deferred-completion-plan.md))。 +- **是否需用户授权**:**需要**。独立安全评审本身即用户授权门槛;红线明确「不实施 SWBOOT-10 真实执行器」。 +- **建议下一触发点**:用户就 W5b 受控执行器单独立项、批准安全评审范围后,从 `dev` 开 `feature/swboot-10-w5b`(mutex `workflow-design`)按 long-term 拆分实施。本波不动代码,fail-closed stub 原样保持。 + +### T1/W6:FAV live 放量 + +- **当前状态**:W6 shadow 已合入 `dev`([short-term](2026-09-17-w6-fav-subscriber-shadow.md)、[报告](../../test-reports/2026-09-17-w6-fav-subscriber-shadow-test-report.md)):`ObserveShadow` 只计算 intended outcome、不写业务副作用;负例覆盖 degraded read 拒绝 / `HandleLease` / `Register(fav)` fail-closed。三环境 compose 既有声明为 `FAV_DEGRADED_READ` 默认 false、`FAV_PUBLISHER_ENABLED` 默认 false。**live 未放量**。本波未改 compose。 +- **关闭条件**:按固定顺序 staging → artifact → publication 逐 consumer live,每级 shadow → drain → live,观察窗 ≥7 天或 ≥N 终态样本(取更严);`FAV_DEGRADED_READ` 全程保持 false;崩溃/重启幂等证据;**禁止整支 merge** `feature/final-artifact-vault`(见 [partial-deferred-completion-plan §7.4](../../remediation/2026-09-16-partial-deferred-completion-plan.md))。 +- **是否需用户授权**:**需要**。红线明确「不升 FAV live / 不改 `FAV_DEGRADED_READ`」。 +- **建议下一触发点**:用户批准 staging 级观察窗起点后,从 `dev` 开独立 flag 分支(mutex `events`),先跑 staging ≥7 天观察窗并归档证据报告,再评估升 artifact/publication。本波不推进放量。 + +### T1/W7:D7 对话数据治理 + +- **当前状态**:ADR 草案([2026-09-17-d7-chat-data-retention.md](../../adr/2026-09-17-d7-chat-data-retention.md),`compliance: pending-compliance-signoff`)已落地;只读数据分类盘点已完成([audit](../../audit/2026-09-17-d7-data-inventory.md),`status: done`),**未发现**当前基线违反已确认合规/安全约束,因此**未**写入 gap-matrix blocker。合规签字前禁止按 365/180/30 天草案数字执行物理删除或备份清除。Wave 6 已把该现状同步进 backlog D7 行,本波不改该文件。 +- **关闭条件**:合规签字或改数 → ADR 状态改 `accepted`/`signed` → 按签字口径落地整改(soft-delete 写路径 → 观察窗 → 物理清理;备份二次批准 + delete manifest)→ FEATURE-MATRIX D7 行升 `done`;若盘点期间发现违规则立即升 gap-matrix(本轮未触发)。 +- **是否需用户授权**:**需要**。合规签字是产品/法务决策,非工程可自行拍板;红线明确「不按 D7 物理删除数据」。 +- **建议下一触发点**:用户/合规负责人对 ADR 草案数字表签字或提出修改意见;签字后开独立 short-term 任务按 ADR 决策落地。 + +### T2:Control Center 前端 SPA 七页人工走查 + +- **当前状态**:2026-09-17 审计结论已过期,不能按「仅剩前端走查」收口。FEATURE-MATRIX「个人工程中控台」行已通过 `engineering-dashboard`(CLI `serve`/`export`,四轴模型)达到 **done** 一期,已合入 `dev`([报告](../../test-reports/2026-09-17-engineering-dashboard-test-report.md))。审计 T2 所指「后端 P0–P6 已签,仅剩前端 SPA 七页人工走查」实为 Kimi 的 `feature/control-center-server`:自身 11 commits(P0 skeleton → P6 design spec + LAN bind + agent-integration),**从未合入 `dev`**。2026-09-18 相对 `dev@a88ed85` 实测:merge-base `adb48c8`,落后 **136** commits(研究快照曾记 128,以本次 `rev-list` 为准),缺失 Wave 0–6。提交 `b5bacee`(`refactor(control-center): remove legacy engineering-dashboard, superseded by control-center`)试图删除已合入并验收的 `engineering-dashboard`,与 canonical 实现冲突。该分支的任务声明与 CURRENT 认领只存在于分支本身,未反映在 `dev` 主线。实现范围(pgvector 文档向量库、P3 SPA、P5 定期审计 + LLM 复核)超出已批准设计一期边界。 +- **关闭条件**:FEATURE-MATRIX 该行**已经 done**(通过 engineering-dashboard)。若要转向「复活/合并 Kimi 实现」,需先有独立决策与重新规划,不属于「仅剩一次前端走查」。 +- **是否需用户授权**:**是**。授权对象是「两套实现如何取舍」,不是「批准做前端走查」。本波只读编排确认,不能代为废弃或合并该分支。 +- **建议下一触发点**:请用户明确二选一:(a) 维持现状——`engineering-dashboard` 为唯一 canonical 实现,`feature/control-center-server` 由用户决定归档或保留(推荐:已 done 且范围经批准);或 (b) 要求评估合并该分支的增量能力(SPA / pgvector / 定期审计)。触发后再开对应短期任务。本波只记录事实与建议,**不合并、不删除**该分支。 + +### T4:首次加载性能 U1/U3–U7 + +- **当前状态**:仅 U2(静态资源交付)done——自托管字体、构建期 br/gzip、`/assets/*` 404 修正;主 JS 传输 2.82 MB → ~624 KB,登录冷缓存可见 ~205ms([报告](../../test-reports/2026-09-17-initial-load-perf-u2-test-report.md))。U1(完整性能基线)、U3(登录壳按需加载)、U4(初始化扇出收敛)、U5(认证请求去重)、U6(列表/租约按证据优化)、U7(重启后首访链路)**均未开始**。 +- **关闭条件**:按 [方案](../../remediation/2026-09-17-initial-load-performance-plan.md) Definition of Done——R1–R6 逐项有前后对比 + 真实 UI 证据;未覆盖项明确列残余,不因 health PASS 关闭;完成后更新任务/CURRENT/FEATURE-MATRIX/test-report。 +- **是否需用户授权**:不需要额外专项授权(方案已 planned 且范围已批准);需要工程排期认领(估算 U1–U5 约 3.5–7 人日,U6 另需 2–4 日,U7 另需 0.5–1.5 日,非承诺)。 +- **建议下一触发点**:任一 Agent 从 `dev` 开分支认领 U1(P0,是后续单元的前置依赖)即触发;建议顺序 U1 → U2(done)/U3 与 U7 → U4/U5 → 按数据选 U6。 + +### T5/T6/T8:backlog 类(按收益触发) + +- **T5 选择性模块解耦 M4–M6**:**当前状态** `planned`,owner=TBD,未开始([long-term](../long-term/2026-09-14-selective-module-decoupling.md))。触发条件中「M1–M3 test report 全部 PASS」已满足(文档可靠性试点 / 可靠事件核心均 done),但「同类缺陷触达文件数和运行故障率表明继续解耦具有收益」证据尚未出现,暂不触发。**关闭条件**与是否授权见该文档触发条件段。**是否需用户授权**:无需专项授权,按收益证据 + owner 认领即可开工。**建议下一触发点**:出现同类缺陷证据或 owner 主动认领。 +- **T6 产品 backlog(B5/A5/A6/A7/D5)**:[backlog](../../backlog/chat-agent-p6-backlog.md) 仍列 B5(报价复核)、A5/A6(小队降级 + trace)、A7(注释迁移)、D5(KB 按环境隔离评估)为纯 backlog,无任务文件、无 owner。**D3、D7 已由 Wave 6 同步**(D3 标已实现 shadow 并回链;D7 回链 ADR 草案与只读盘点,合规签字前不整改)。本波不改该文件。**关闭条件**:开放项按产品收益单独排期开短期任务。**是否需用户授权**:开始评估不需要专项授权;D3 REPLACE 灰度、D7 整改分别仍受各自红线约束。**建议下一触发点**:产品侧提出具体收益诉求;无固定时间表。 +- **T8 IAM 收尾开放项**:410 退役策略已冻结为「保留,非缺口」([决策](../../audit/2026-09-16-bug11-410-retire-decision.md),gap-matrix BUG-11 PASS);R13 数据安全清单 7 项全部 PASS,仅「完整红队注入矩阵」标记为开放增强、不挡基线;IAM 全路由 inventory 仍标记「开放」([handoff](../handoffs/2026-09-16-phase3-leftover-closeout.md)),fail-closed 核心已签。均为**加固类,非发布 blocker**。**是否需用户授权**:不需要专项授权,可按运维遥测随时另开 audit/任务。**建议下一触发点**:410 路由遥测证明零流量后可另开退役任务;或需要更强出站安全保证时另开红队 audit。 + +## 新发现(需用户后续裁决,不阻塞本波收口) + +- **Control Center 双实现冲突**:`feature/control-center-server`(Kimi,P0–P6 SPA)未合入 `dev`。相对 `dev@a88ed85` 落后 136 commits(merge-base `adb48c8`),自身 11 commits,其中 `b5bacee` 删除已合入的 `engineering-dashboard`。建议维持 `engineering-dashboard` 为唯一 canonical 实现;是否归档或评估合并该分支,待用户裁决。本波不删除、不合并该分支。 + +## 证据 / 备注 + +基线 tip:`a88ed85`(`merge: docs/wave6-doc-drift`)。mutex=`roadmap-orchestration`。零 Go/TS/SQL/compose 改动。FEATURE-MATRIX 状态列未改。 diff --git a/.doc/remediation/2026-09-17-document-author-only-plan.md b/.doc/remediation/2026-09-17-document-author-only-plan.md new file mode 100644 index 00000000..ca610524 --- /dev/null +++ b/.doc/remediation/2026-09-17-document-author-only-plan.md @@ -0,0 +1,347 @@ +--- +title: 文档编制人兼任审核批准 - Plan +type: feat +date: 2026-09-17 +status: planned +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# 文档编制人兼任审核批准 - Plan + +## Goal Capsule + +在系统管理员授权后,项目创建者可以为项目工作流中的每份文档单独启用「编制人兼任审核、批准」,只选择一位编制人即可完成该文档的全部人工职责。 + +本次交付为设计方案,尚未授权业务实现。已确认的需求是两级授权和逐文档配置。R9(激活前配置/激活后冻结)与 R10(关总开关后存量按冻结继续、不提供紧急熔断)已于 2026-09-17 审核批准,并补强 Heal 三入口投影稳定性为硬性验收项。实现与发布须分别提供源码、数据库、真实产品 PI 和浏览器证据。 + +基线为本地 `dev@adb48c8`,2026-09-17 源码核查;不代表正在运行的镜像已具备本能力。远端已 fetch,本地 dev 领先 origin/dev 44 个提交、落后 0。本方案不修改正式工作流定义,不将兼任等同于免审批。 + +--- + +## Product Contract + +### Summary + +新增管理员总开关,默认关闭;新增逐文档职责模式,默认沿用当前规则。创建者开启某份文档的兼任模式后,只填写编制人,审核人与批准人由服务端派生为同一人。仍执行送审、审核和批准,保留每一步身份、意见和时间。 + +### Problem Frame + +当前系统仅允许可选文档三职全空或三职完整指定,且完整指定时要求三人不同。用户需要小团队或个人也能执行完整的文档治理流程,并由管理员控制这一例外是否可用。 + +当前权限、文档人员投影和前端角色识别使用不同数据路径。因此仅放宽三职分离校验不足以支持本需求,还必须保证配置、生成、查看、修改、审批和阶段推进使用同一份职责来源。 + +### Requirements + +**权限与配置** + +- R1. 仅系统管理员可开启或关闭系统级「允许编制人兼任审核、批准」权限,默认关闭。 +- R2. 仅权威项目创建者可为自己创建的项目逐文档选择兼任模式;普通成员、项目角色管理员和其他项目创建者不能设置。系统管理员如果不是该项目创建者,也不因此获得逐文档配置权。 +- R3. 每份文档独立选择,管理员开启总权限不自动修改任何项目或文档;新文档、新实例和复制产生的文档默认不继承兼任授权。 +- R4. 兼任文档必须指定一名有效、在任的项目成员为编制人;可以不是创建者。审核与批准由该人承担,不能再指定其他审核人或批准人。 + +**审批与可见性** + +- R5. 兼任模式属于纳入人工审批:保留送审→审核→批准三个动作、现有内容锁和驳回要求;不得生成完成后自动批准或通过阶段门禁。 +- R6. 未开启兼任的文档继续遵守当前三职分离与可选免审批规则;必需文档不得借兼任模式省去编制人或审批。 +- R7. 设置页、冻结快照、文档详情、操作按钮、签署展示与日志一致显示兼任关系;同一个人能在正确状态执行对应职责动作。 +- R8. 兼任不授予系统管理员、创建者或其他成员代审权,也不扩大跨项目读取、非当前阶段写入和已结束实例写入权限。 + +**生命周期与持久化(已审核批准,2026-09-17)** + +- R9. 首期仅在工作流激活前配置;激活时冻结该文档模式和职责,运行中的实例不直接改规划。已激活的存量项目不自动转换。若用户要求运行中转换,必须先扩展本方案的受控职责变更单元,不可悄悄放开现有 PUT。 +- R10. 管理员关闭总开关后,禁止新启用、保存仍含兼任的规划和激活兼任规划;允许清除兼任后保存。已激活的兼任实例按冻结授权继续执行,历史日志不变。界面必须解释该开关是「授权新配置」,不是紧急停用所有存量审批;本期不提供针对已激活实例的紧急撤销/熔断能力。 +- R11. 模式、编制人、授权依据、配置版本及各步骤日志需持久化,刷新、重登和服务重启不得丢失。 +- R12. 编制人离任或账号停用后,不得继续执行三项职责;阻断尚未完成动作并展示原因,不自动找替代人,不重写历史签署。 + +### Permission Matrix + +| 操作 | 系统管理员但非项目创建者 | 当前项目创建者 | 普通项目成员 | 其他项目用户 | +|---|---|---|---|---| +| 修改系统总开关 | 允许 | 仅同时为系统管理员时 | 禁止 | 禁止 | +| 查看本项目可用模式 | 沿用当前项目读取权限 | 允许 | 沿用项目读取权限 | 禁止 | +| 规划期逐文档启停兼任 | 禁止 | 总开关开启才可启用;可关闭 | 禁止 | 禁止 | +| 指定编制人 | 禁止 | 规划期按 R4 | 禁止 | 禁止 | +| 送审、审核、批准 | 仅自己是该文档指定职责人时 | 同左 | 同左 | 禁止 | +| 已激活实例直接改职责 | 禁止 | 禁止(R9) | 禁止 | 禁止 | + +### Mode Matrix + +「文档启用」「纳入人工审批」「编制人兼任」是不同含义,不能复用一个开关。 + +| 文档状态/配置 | 输入 | 有效职责 | 审批与门禁 | +|---|---|---|---| +| 文档禁用 | 不设置职责 | 无 | 沿用 skipped_disabled | +| 可选文档免审批 | 三职全空,兼任关闭 | none | 沿用既有免审批规则 | +| 标准人工审批 | 三职齐全且三人不同 | full | 保留正常人工审批 | +| 兼任人工审批 | 兼任开启,仅编制人 A | author_only;有效三职为 A/A/A | 与标准审批同样要求最终批准 | +| 未开兼任却只填 A | 缺审核和批准 | partial | 拒绝 | +| 标准模式填 A/A/A | 三职同人 | 非法 | 继续拒绝职责分离违规 | +| 兼任开启但无 A | 缺编制人 | 非法 | 拒绝 | +| 兼任开启又填不同审核人 B | 矛盾输入 | 非法 | 拒绝,不能静默吞掉 B | + +### User Flows + +1. 管理员进入「系统管理台→系统设置→工作流治理」,开启总授权;页面显示保存结果、修改人和修改时间。 +2. 创建者进入项目设置,展开某个阶段,在文档行开启「由编制人兼任审核、批准」。只保留编制人选择器,其余两项显示「同编制人(自动承担)」。其他文档保持原配置。 +3. 保存并预览:摘要列出标准审批、兼任审批、免审批的份数,列出每份兼任文档及编制人。确认后冻结。 +4. 编制人完成内容后送审,在审核中以审核人身份确认,在批准中以批准人身份确认;日志展示同一人对应三个职责,而非三个虚构人员。 +5. 页面刷新、重新进入项目后仍显示同样配置;最终批准后才按现有阻塞条件满足阶段门禁。 + +### Scope Boundaries + +本期覆盖所有通过项目工作流实例管理的文档,不针对某个任务名写白名单;freeform 文档与未绑定工作流的旧文档不提供此开关。不新增批量全选、不改项目成员全局 `document_duty` 为多角色,不自动改已发布工作流模板或历史签署。 + +**生命周期口径(已审核批准,2026-09-17):** R9 首期仅激活前配置、激活后冻结职责;R10 关闭总开关后禁止新启用/保存仍含兼任的规划/激活兼任规划,已激活实例按冻结授权继续执行。总开关是「授权新配置」,**不提供**已激活实例的紧急撤销/熔断能力;若业务需要合规级紧急止损,应作为独立后续方案,不在本期 Definition of Done 范围内。 + +--- + +## Planning Contract + +### Current Implementation Evidence + +以下为源码确认事实,不是运行验收结果。 + +| 位置 / 符号 | 当前行为 | 对本需求的影响 | +|---|---|---| +| `internal/projectinstance/types.go`:DocTaskPlan / DocDuty | 仅 enabled、approval_configurable 和三职 ID,无逐文档兼任标记 | 增加显式职责模式 | +| `internal/projectinstance/duty_mode.go` | none/partial/full;partial 拒绝;三职重复报 SEPARATION_OF_DUTY_VIOLATION | 不能只填作者或仅关闭分离校验 | +| `internal/projectinstance/validator.go`:validateActivationPlan / previewSummary | 激活至少 countFullDutyDocs>0;预览统计 full/none | 必须把合法兼任计为受治理文档 | +| `internal/projectinstance/service.go` | 创建者可保存/预览/激活;active 后 ErrPlanFrozen;已有 revision、plan hash、token | 复用规划流程,补全授权版本和事务检查 | +| `internal/projectworkflow/service.go`:IsProjectCreator | 权威项目创建者检查 | 不使用 UI 当前角色或项目 manager 代替 | +| `internal/projectinstance/store.go`:sync 子实例逻辑 | 从规划写 project_doc_task_instances 和三职 assignment | 兼任需派生三项实际职责,不能只存作者而漏审批归属 | +| `internal/projectinstance/duty_action.go` / `stage_access.go` / `gate.go` | 按动作校验单一职责;阶段可见性有 full 判定;门禁依赖 approved | 校验与阶段可见性都需认识新模式 | +| `internal/sysadmin/settings.go` / `store.go` | 白名单设置、DB 持久化;无该 key;只记录最后修改人时间 | 可复用管理入口,新增策略消费与历史审计 | +| `internal/server/admin_system_handlers.go` | 所有系统设置写入 requireSystemAdmin | 复用该入口,不开放管理列表给普通成员 | +| `internal/docduty/store.go`:HealProjectDocuments | 按项目成员默认职责扫描并回填所有 task_key 非空文档 | 可能覆盖逐文档三职,必须区分实例绑定与旧默认 | +| `internal/bizdev/service.go` / `internal/docgov/project_docs.go` | 生成后、列表读取时调用 HealProjectDocuments | 重开页面即可触发回填,必须验证刷新后仍一致 | +| `internal/docduty/revoke.go` | 成员职责变化撤销审核/批准槽位,随后参与 heal | 不能把项目默认职责变化误当成逐文档兼任变更 | +| `internal/server/handlers.go`:三个 DocGov 决议 handler | 使用登录身份及动作固定 author/reviewer/approver;先执行工作流鉴权 | 服务端已有按动作身份模型,应保留 | +| `internal/docgov/state_machine.go` | drafting/reviewing/approving/approved;检查对应职责 ID;无额外三人不同判断 | 复用状态机,测试身份一致和锁定边界 | +| `web/src/lib/currentDocRole.ts` | author 命中后立即返回,不支持同时三职 | A/A/A 会一直被当成 author | +| `web/src/lib/docGovActionVisibility.ts` / `DocGovActionBar.tsx` | 按单一 currentRole 显示送审/审核/批准 | 必须改为动作能力或多职责判断 | +| `web/src/lib/projectSettingsDuties.ts` / `ProjectSettingsModal.tsx` | 重复服务端 full/none 校验,人工审批默认要求至少三人 | 单成员兼任会被前端提前挡住 | +| `deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql` | assignment 唯一键是任务实例+职责,无跨职责用户唯一限制 | 同一用户可有三条职责记录,不必拆用户身份 | +| `deploy/postgres/goose/00049_2026-09-17-system-settings.sql` | system_settings 已存在 | 采用增量迁移,不重建设置机制 | + +相关历史边界参见 `.doc/test-reports/2026-09-14-governance-action-level-authz-test-report.md` 与 `.doc/test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md`。它们说明已有验收意图,本次未重跑其测试。 + +IDE 所列 rdev 设计、short-term 和 backlog 当前位于 `feature/rdev-template-depth` 的独立工作区,不在本方案 dev 基线;已读取作兼容输入。rdev 的关闭文档/N/A、阶段前置、任务键命名空间化保持原语义。本方案按实例+阶段+任务身份绑定;待 rdev 合入后增加对应回归,不宣称其已在 dev 发布。 + +### Key Technical Decisions + +- KTD1. 新增 `duty_policy`,值为 `separated` / `author_only`,属于 DocTaskPlan 的逐文档配置。缺省按 separated 解读;none/partial/full 仍是职责完整度,不能把「免审批」和「兼任」混为一谈。统一提供按任务解析模式、有效职责、是否受治理的领域能力;原 ClassifyDocDuty 保留给旧调用,治理调用迁到新解析器。(R3–R6) +- KTD2. 配置快照保存用户意图:author_only 的 duties 规范形式只含 author_user_id。服务端通过唯一解析器派生有效 A/A/A,写三条 document_duty_assignments,并投影到 document_records 的三个 ID/名称。读接口区分配置职责与有效职责,客户端不得把有效 A/A/A 原样误写成标准配置。(R4、R11) +- KTD3. 新设置 key 建议 `workflow.allow_author_only_approval`,类型严格 bool,默认 false,无环境变量绕过,保存无需重启。projectinstance 通过窄的策略读取接口消费,由 app 装配注入;不从领域服务调用 HTTP,不让 projectinstance 成为 sysadmin 设置表的第二写入 owner。(R1) +- KTD4. 配置写入、预览和激活都复核总授权;真正激活必须在事务内对授权版本复核,并与关闭总开关串行化。token 绑定 plan hash、项目/规划版本、授权 revision。总开关 false→true 后旧 token 仍作废,不能以「当前又是 true」放行。(R10、R11) +- KTD5. 实例绑定文档的职责权威是冻结 DocTaskPlan,经解析生成 assignments 和文档投影。项目成员 document_duty 只供旧无实例文档的默认分配。heal/revoke/生成/通知不得再次用项目默认人员覆盖实例文档。(R3、R7、R12) +- KTD6. 运行时按具体动作检查有效职责和当前在任状态;状态机仍按 author/reviewer/approver 记录实际动作角色。前端采用服务端动作能力集驱动按钮,可同时拥有多个职责,保留 viewer fail-closed,不新增一个可以绕过所有检查的万能角色。(R5、R7、R8) +- KTD7. 遵守 R9 的冻结边界;未知 mode、缺失授权来源或绑定不唯一时拒绝写动作。历史缺字段只代表旧 separated,不自动解释为兼任;不能全库 A/A/A 回填。(R6、R9、R11) + +### Data Model + +以下是设计契约,字段落位按 owner 边界实现,不是现有字段清单。 + +| 数据 | 建议新增/扩展 | 用途与兼容 | +|---|---|---| +| system_settings | 目标 key;monotonic revision(默认 0) | 总权限 CAS、token 失效;目标行在迁移时初始化 false,避免无行锁问题 | +| 系统设置审计 | 仅本策略必需的 before/after、revision、actor、时间、request ID | 与设置更新同一事务提交;不把整站审计改造扩大到本期 | +| DocTaskPlan JSON | duty_policy,可选且旧值缺省 separated | 逐文档意图,纳入 plan hash;兼容 old hash 的序列化须有固定样本 | +| workflow instance 授权快照 | policy key、revision、允许状态、activated_by/at | 记录激活时获准的依据;由服务端生成,拒绝客户端伪造 | +| activation token | policy revision(新字段或令牌存储的版本化元数据) | 防预览后管理员撤销授权;旧 token 含兼任无 revision 必须重新预览 | +| document_records | doc_task_instance_id 可空外键 | 对新实例创建文档强制绑定;旧文档仅唯一匹配才回填,不修改历史职责 | +| document_duty_assignments | 复用三角色,兼任三行 user_id 相同 | 无需放宽角色唯一约束;author 必须有效且正数 | +| 文档视图 | duty_policy、effective_duties、allowed_actions、blocked_reason | 以服务端计算值展示;读参数不能切换权限 | +| 文档审计 details | duty_policy、instance/task ID、授权 revision | 送审/审核/批准均记录真人身份及该次 action role | + +不预占 Goose 序号:实施时根据 dev 与并行 rdev 分支最新迁移取下一个空闲序号。迁移需覆盖 PG store 与 memStore 的字段 round-trip。兼容旧数据时禁止把 NULL 改成 0 外键。 + +### High-Level Technical Design + +职责解析链: + +```mermaid +flowchart TB + A[管理员总授权] --> B[创建者逐文档配置] + B --> C[保存与预览校验] + C --> D[事务内复核授权并激活] + D --> E[冻结实例配置与授权依据] + E --> F[统一解析有效三职] + F --> G[职责表与文档人员投影] + F --> H[动作鉴权和阶段可见性] + H --> I[前端能力与操作按钮] + G --> J[文档状态机与审计] +``` + +兼任保留原审批状态机: + +```mermaid +stateDiagram-v2 + drafting --> reviewing: A以编制身份送审 + reviewing --> approving: A以审核身份通过 + reviewing --> drafting: A审核驳回并填写原因 + approving --> approved: A以批准身份通过 + approving --> drafting: A批准驳回 + approving --> reviewing: 现有配置允许时退回审核 +``` + +新授权和冻结授权的判断路径: + +```mermaid +flowchart TB + A[请求] --> B{规划配置或激活?} + B -->|是| C{权威创建者且planning?} + C -->|否| X[拒绝] + C -->|是| D{含author_only?} + D -->|否| E[原职责规则] + D -->|是| F{总授权有效且版本一致?} + F -->|否| X + F -->|是| G[检查作者与输入一致性] + B -->|运行时动作| H[查冻结策略及实例任务绑定] + H --> I[检查成员有效性 当前阶段 状态和租约] + I --> J[按动作职责执行] +``` + +激活事务与设置撤销都以策略行锁形成先后顺序,再按统一顺序获取项目职责锁和实例相关行锁。策略 owner 提供事务兼容读取/锁定适配;不能照搬在事务外重查布尔值的方式。原则:关闭先提交则激活失败;激活先提交则成为 R10 中可继续的存量实例。并行成员移除与激活也必须在同一项目职责锁语义下判定,不能在事务开始前检查后直接信任。 + +### API Contract + +| 接口 | 变更 | 服务端强制条件 | +|---|---|---| +| GET/PUT `/api/v1/admin/system/settings[/{key}]` | 注册总开关;目标 key 增加 expected_revision,返回新 revision | requireSystemAdmin;严格拒绝 null、字符串布尔;审计与值同事务 | +| GET `/api/v1/projects/{id}/settings` | 返回精简 governance_policy capability、revision 与 blocked_reason | 保留项目访问校验;不泄露其他系统设置 | +| PUT `/api/v1/projects/{id}/workflow-plan` | 每个文档可传 duty_policy;兼任只传 author ID;沿用规划版本 | 创建者、planning、总授权、有效成员、输入无矛盾 | +| 激活 preview / activate 现有接口 | 预览分列兼任文档;token 绑定策略 revision | 保存成功不等于已获激活授权;提交时重新事务校验 | +| 文档详情和项目文档列表 | 返回模式、有效三职与允许动作 | 统一来源;任务身份、实例身份须匹配 | +| docgov 送审/审核/批准接口 | URL不变,增加模式审计上下文 | 登录用户+对应 action,不信任请求的 user_id/user_name | + +建议新增错误码:`AUTHOR_ONLY_POLICY_DISABLED`(403)、`AUTHOR_ONLY_POLICY_CHANGED`(409)、`AUTHOR_ONLY_AUTHOR_REQUIRED` / `AUTHOR_ONLY_DUTY_CONFLICT` / `UNKNOWN_DUTY_POLICY`(400 validation issue)、`DUTY_BINDING_AMBIGUOUS`(409)、`DUTY_ASSIGNEE_INACTIVE`(403)。总权限存储不可用返回 503;缺失目标配置采用默认关闭,但数据库故障不能伪装成正常缺项。保留已有 PLAN_FROZEN、PLAN_REVISION_CONFLICT 与 SEPARATION_OF_DUTY_VIOLATION。 + +`author_only` 写入审核/批准 ID:省略或 0 为规范输入;若为与作者相同的正数,可规范化为省略以兼容回显,但若不同则拒绝。关闭兼任时清除派生审核/批准,要求重新选择符合分离规则的人员;旧选择可仅作为未保存草稿提示,不自动恢复为真实授权。 + +### UX Details + +系统设置开关说明写明 R10,并展示预计受影响的待激活兼任文档数量及存量继续执行说明。保存失败保留用户输入和错误,不在本地假装开启成功。多管理员同时修改返回版本冲突,不自动覆盖。 + +项目设置按文档行提供兼任选项;总权限关闭时禁用并解释来源;非创建者只读。兼任只能在文档已启用且纳入人工审批时选择。兼任文档仅需一位成员;混合方案只要仍存在标准人工审批文档,才继续提示至少三位有效人员。 + +总开关在页面打开期间被关闭时,保存失败展示定位到文档的原因,提供关闭兼任选项;不丢弃整份未保存规划,不自动重试激活。预览和冻结视图同时展示「角色指派数」和「实际人数」,避免 A/A/A 被误认为三个人。 + +文档正文签署区显示真实姓名及兼任标记,但未发生审核/批准时不得显示签署时间或声称已通过。按钮随状态变化,审阅阶段即使当前人也是作者也不能编辑锁定正文;驳回后恢复编辑。查看/认领按当前状态或明确动作记录相应角色,不能打开一次页面就伪造三次履责。 + +### Risks and Compatibility + +1. **回填覆盖(硬性门禁,非偶然安全):** `HealProjectDocuments` / `ApplySlotRevocations` 与 `ConfigSnapshot` 是独立职责写路径,当前不区分实例绑定文档。实现必须在 heal/revoke 增加「实例绑定文档跳过项目默认覆盖」显式分支,禁止依赖「只填缺失槽位、碰巧不覆盖」的偶然安全。U3 必须对三入口(文档生成、列表 GET、成员变更)及 author 被误判非在任时的 healSlot 分别证明投影不变;未通过不得开放 UI。 +2. **历史身份混淆:** 不以 project_id+task_key 跨实例盲绑;旧文档匹配多实例则保持原记录并报可定位问题。新生成文档必须携带 task instance ID,无法绑定应停止受控写入。 +3. **成员退出:** runtime 复核在任资格覆盖 author_only;保留冻结快照用于历史展示。普通成员默认职责改变不得影响冻结文档;真实退出/停用应阻断动作。暂不新增运行中换人 UI。 +4. **并发审批:** 同一人可打开多个标签,服务端仍需保证状态比较/版本检查;重复请求不能重复创建批准事件、版本封存或推进通知。延迟到达的审核请求不能作用于后续修订版本。 +5. **通知与事件:** 审核待办发给同一人是有效业务,不可因发送者=接收者一概过滤。按事件+接收者去重,不把审核与批准两项待办合并成一个动作。 +6. **回滚:** 关闭开关只停止新配置,不能安全地把已存在兼任实例交给完全不识别新模式的旧二进制。上线后若需回退代码,保留模式识别与冻结策略读取兼容补丁;不得通过清空审核/批准人员回滚。 +7. **总开关非紧急熔断(R10):** 关闭总授权不会阻断已激活兼任实例的剩余送审/审核/批准。界面与运维说明必须写明这一点,避免被误解为「关总开关=紧急止损」。存量紧急撤销不在本期范围。 + +--- + +## Implementation Units + +### U1. 策略存储与总授权 + +目标:提供管理员可写、项目治理可可靠消费的总授权。覆盖 R1、R10、R11,采用 KTD3、KTD4。 + +文件:`internal/sysadmin/settings.go`、`store.go`、`internal/server/admin_system_handlers.go`、`internal/app/` 装配、增量 Goose 迁移;必要时新增 sysadmin 内的策略审计存储文件。 + +实现重点:默认关闭;严格 bool;目标设置 revision 与 CAS;事务内审计;窄接口可参与激活事务。仅本 key 要求的新协议不破坏其他设置键调用。 + +测试场景:管理员开关成功;普通用户伪造 PUT 被拒;null/字符串拒绝;DB断开不放行;并发修改只一个版本成功;审计写失败时设置回滚;重启读回。 + +### U2. 逐文档模式、规划校验与激活 + +依赖 U1。覆盖 R2–R6、R9–R11,采用 KTD1、KTD2、KTD4、KTD7。 + +文件:`internal/projectinstance/{types,duty_mode,validator,service,store,hash,tx,errors}.go`、`internal/server/project_settings_handlers.go`。 + +实现重点:新增模式解析;所有 full-only 分支改为正确的受治理判断;保存、预览、激活一致;任务禁用/免审批清除模式;有效成员检查;规划 hash/授权 revision 和确认 token 一致。保存快照与提升 plan revision 应原子化,失败不能留下可读取的新规划配旧版本。 + +测试场景:一位成员、至少一份合法兼任文档可激活;只填作者但未开模式仍拒绝;混合 full/none/author_only;其他项目创建者、非创建者管理员拒绝;修改总授权后旧 token 拒绝;激活与撤销并发的两种提交顺序;旧 separated snapshot/hash 兼容。 + +### U3. 文档绑定与职责投影一致性 + +依赖 U2;这是开放 UI 前的必要门禁。覆盖 R3、R4、R7、R11、R12,采用 KTD2、KTD5。 + +文件:`internal/docduty/{store,resolve,revoke}.go`、`internal/docgov/{types,engine,project_docs,repository}.go`、`internal/bizdev/service.go`、`internal/projectinstance/store.go`、任务执行请求与 app 适配、增量迁移。 + +实现重点:新文档绑定具体任务实例;受治理实例职责优先;统一有效职责映射用于创建、读取、heal、memStore;legacy fallback 仅在明确无实例时可用。`HealBinding`/`healSlot`/`ApplySlotRevocations` 对已绑定 `doc_task_instance_id` 的文档必须显式跳过项目默认覆盖,不得因 author 被误判非在任而重新分配默认人。旧已批准文档不因新功能读取被改写签署人员。 + +测试场景:同项目两份兼任文档由不同作者承担;第三份标准审批三人;第四份 none;反复 GET/成员默认角色调整/重启后四份职责均不串;未知或歧义绑定阻断;旧无实例文档仍按既有规则;NULL外键正常。**硬性投影稳定性(开放 UI 前门禁):** 已激活 author_only 文档在以下三入口触发 `HealProjectDocuments` 后,`document_records` 与 assignments 投影必须保持 A/A/A——(1) 仅刷新文档列表(`ListWorkflowDocumentsByProject`);(2) 文档生成完成(bizdev);(3) 成员变更后 heal。另测:author 被误判为非在任成员时,healSlot 不得把审核/批准槽位改写为项目默认人;真实离任仍按 R12 阻断写动作且不自动替人。 + +### U4. 动作鉴权、文档状态机与事件 + +依赖 U3。覆盖 R5、R7、R8、R12,采用 KTD6。 + +文件:`internal/projectinstance/{duty_action,document_access,stage_access,gate}.go`、`internal/server/{stage_access_helpers,handlers,document_revision_access}.go`、`internal/app/docflow/`、`internal/docgov/`、相关通知订阅与审计视图。 + +实现重点:有效三职与在任状态统一判定;三个动作使用不同 action role;归档/修订账本与事件沿用既有事务机制;审核、批准不能跳状态,租约和内容 hash 不因同人而失效。Audit details 增加模式及来源。 + +测试场景:A顺序三动作成功且三份日志身份真实;其他成员/创建者/管理员越权失败;作者退出后全部写动作拒绝;审核中改正文失败;驳回原因必填;跨标签重复批准只产生一次有效终态事件;审批针对旧版本内容被拒;阶段直到最终批准才可推进。 + +### U5. 系统设置与逐文档 UI + +依赖 U1、U2,可与 U3/U4 开发隔离进行,联调必须等待后端完整。覆盖 R1–R4、R7、R9–R11。 + +文件:`web/src/components/admin/console/sections/SettingsSection.tsx`、`web/src/components/project/ProjectSettingsModal.tsx`、`web/src/lib/projectSettingsDuties.ts`、`web/src/types/projectSettings.ts`、`web/src/api/projectSettings.ts`。 + +实现重点:按 UX Details 配置、错误定位、人数要求、预览和冻结展示;策略 capability 来自项目 settings,而非向普通用户开放 admin settings。文档模式在不同阶段展开折叠后不串行。 + +测试场景:单成员可配置兼任;混合标准模式仍提示三人要求;非创建者只读;系统开关关闭时禁用及保存失败保留草稿;切换文档、刷新、重新登录后模式不变;已激活项目仍冻结。 + +### U6. 多职责动作展示和真实闭环验收 + +依赖 U3–U5。覆盖 R5–R8、R11、R12,采用 KTD6。 + +文件:`web/src/lib/{currentDocRole,docGovActionVisibility,controlledDocHydrate,deliverableSync}.ts`、`web/src/components/doc/DocGovActionBar.tsx`、`web/src/App.tsx`、文档 API/types、相关 UI/PG/真实 PI 测试与报告。 + +实现重点:消费服务端动作能力,多职责不再被 author-first 短路;认领、提案、修订、按钮与日志一致;受控文档不按名字提权。证明刷新及重启保持一致后再开启真实项目配置。 + +测试场景:真实产品 PI 生成→A打开文档→送审→审核→批准→版本/日志→阶段门禁;标准和免审批文档并存;不同用户负例;两标签并发、刷新重开、重启;rdev 合入后复验 rd_ 文档及旧键兼容,不改 N/A 行为。 + +--- + +## Verification Contract + +本方案阶段未运行业务测试,以下为实现阶段必须提交的证据。 + +| 层级 | 覆盖 | 通过标准 | +|---|---|---| +| Go 单元 | 策略值、模式解析、权限矩阵、状态、门禁、hash | 所有模式边界及错误路径可重复验证 | +| PostgreSQL 集成 | 设置审计原子性、并发撤销/激活、成员退出、三职责落库、投影绑定;HealProjectDocuments 三入口对实例绑定 author_only 文档投影不变 | 实际事务交错结果符合 KTD4;失败不留下半成品;列表 GET/生成/成员变更均不得覆盖冻结 A/A/A | +| 前端单元/构建 | 单成员配置、双层开关、多职责按钮、错误恢复 | 测试与构建通过,无同人只见作者按钮 | +| HTTP 负例 | 修改请求身份、跨项目、非创建者、未知模式、关闭总授权 | 服务端拒绝,不能依赖隐藏按钮 | +| 浏览器 E2E | 管理员开关→创建者逐文档设置→A全部动作→其他人被拒 | 真页面操作、日志可见;无 API 直写替代关键步骤 | +| 产品 PI E2E | 正式生成及既有修订/租约流程 | 使用仓库要求的真实产品 PI + MiniMax;stub 仅作前期定位 | +| 持久化 | 刷新、重开、重登、服务重启 | 模式、职责、审核状态、版本和审计一致 | +| 回归 | 既有 full/none、标准三人分离、freeform、legacy、终止实例 | 原有权限和生命周期边界不退化 | + +实现时执行 PROJECT 规定的 `go vet ./...`、`go test -short ./...`、前端 `npm test` 与 `npm run build`,以及使用隔离测试库的 PG 集成和明确启用的真实 PI 测试。不得把 skip 当 pass。共享容器重建验证必须在主仓占 CURRENT 集成槽位后执行;worktree 不启停共享容器。 + +重点验收样例: + +- AE1(R1–R4):管理员开启后,创建者在文档甲选择 A,在文档乙选择 B;甲/乙有效三职分别为 A/A/A、B/B/B,互不影响。 +- AE2(R5–R8):A只能按状态依次送审、审核、批准;未批准前门禁阻塞;另一个管理员无该文档职责仍被拒。 +- AE3(R6):总开关开启但文档未勾选,A/A/A仍拒绝;三职空仅对原来允许免审批的文档合法。 +- AE4(R10、R11):预览后总开关关闭再打开,旧 token 409;已先完成激活的实例刷新后仍可按冻结策略办理。关闭总开关不得阻断已激活实例的剩余审批动作(非紧急熔断)。 +- AE5(R7、R11):项目成员默认审核人改为 C 后,读取兼任文档仍显示 A/A/A,不改历史签署;列表、正文及按钮一致。 +- AE5a(R7、KTD5):已激活 author_only 文档在仅刷新文档列表(触发 `ListWorkflowDocumentsByProject` → `HealProjectDocuments`)后,投影仍为 A/A/A,不被项目默认职责覆盖。 +- AE5b(R7、KTD5):文档生成完成与成员变更后 heal 两入口分别复测 AE5a;author 被误判为非在任时,healSlot 不得把审核/批准槽位改写为项目默认人。 +- AE6(R12):A离任后保持历史名字,但所有新动作拒绝,不能被 heal 自动替换或按名字回退提权。 + +--- + +## Definition of Done + +- [x] R9、R10 的生命周期口径经过用户审核(2026-09-17):批准「激活前配置 + 激活后冻结」与「关总开关仅停新配置、存量按冻结继续」;明确总开关不提供存量紧急熔断,该能力如需另立方案。Heal 三入口投影稳定性已升为 U3/Verification 硬性验收项。 +- [x] U1–U6 全部完成且证据覆盖对应需求;管理员和创建者两个权限层级均在服务端生效。(short-term U1–U6 均为 `done`;U6 报告回链) +- [x] 配置、快照、assignments、document_records、前端、审计在同一任务身份下对齐。(U2–U4 + U6 闭环证据;规范存储仅 Author + 有效 A/A/A 投影) +- [x] 单成员兼任完整链与三人标准链均通过;不得只提交设置页截图。(U4 矩阵 + U6 HTTP/浏览器证据;标准分离回归见 U4/U2 单测) +- [x] PostgreSQL、真实产品 PI、浏览器、重启与跨用户负例分别标明结果及限制;AE5a/AE5b 必须有证据。(U3:`TestHealProjectDocuments_SkipsDutyLocked_PG` 等 duty_locked 过滤;U6:既有 PI 生成文档 + Playwright 徽标 + lizhuguan 403。限制:U6 未单独跑「刷新/重登/重启」专轮,依赖 schema 持久化与 rebuild 后复测) +- [x] `.doc/test-reports/` 有验收报告并回链任务和 FEATURE-MATRIX;无新增已确认发布 blocker 才保持 gap-matrix 原结论。([U6 报告](../test-reports/2026-09-17-document-author-only-u6-test-report.md);变更提案 HTTP 另记 [audit](../audit/2026-09-17-docgov-modification-proposal-http-gap.md),不升为发布 blocker) +- [x] 发布先迁移与部署识别新模式的服务,再开放总授权;默认 off,试点后扩面。推送、dev集成与环境晋升状态分别报告。(**dev 已合入并 rebuild**:`merge: feature/document-author-only-plan` @ `09a9921`,health `schema_version=52`,总开关默认 off。**未做**:push origin/dev、test/main 晋升、开放总授权试点——需另授) diff --git a/.doc/remediation/2026-09-17-system-health-remediation-plan.md b/.doc/remediation/2026-09-17-system-health-remediation-plan.md new file mode 100644 index 00000000..84388e6e --- /dev/null +++ b/.doc/remediation/2026-09-17-system-health-remediation-plan.md @@ -0,0 +1,57 @@ +--- +title: "系统体检整改与完善实施方案(2026-09-17)" +status: planned +created: 2026-09-17 +updated: 2026-09-18 +--- + +# 系统体检整改与完善实施方案 + +> 配套审计:[../audit/2026-09-17-system-health-check.md](../audit/2026-09-17-system-health-check.md)(编号 B-x 确认 bug、R-x 隐患、E-x 环境缺陷、T-x 任务、X-x 死代码、S-x 测试盲区、D-x 文档漂移)。 +> Wave 0–7 已实施收口。Wave 7 仅为编排确认,未改变能力实现状态。 + +## 背景 + +体检确认质量门禁全绿、发布门禁口径属实;但发现 1 项 critical 环境缺陷(main ENV 不一致,E-1)、2 项 high 级 SSE 断流 bug(B-1/B-2)、1 项 high 级前端缓存 bug(B-5)、一批「机制先行无消费方」与测试盲区。本方案按风险优先分波次收口。 + +## 目标 + +1. main 环境事件链路语义唯一、无双写/空转风险(E-1)。 +2. SSE 长任务不再被 120s WriteTimeout 强杀(B-1/B-2),错误语义对齐(B-3/B-4)。 +3. 前端会话窗口缓存一致、恢复不丢数据(B-5/B-6)。 +4. 封存功能的灰度入口在 compose 层就绪(E-3),死配置/死代码有明确处置(E-4、X-*)。 +5. 测试债与 E2E 门禁入口收敛(S-1/S-2/S-4)。 +6. 文档 SSOT 无漂移(D-1~D-6)。 + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| **Wave 0:P0 确认 bug** | B-1/B-2:`handleChatStream` 与 `handleRunTaskStream` 接 `extendSSEWriteDeadline` + 心跳(复用 sse.go:52-64 既有机制);run/stream 支持 `after_seq` 续订(重连走既有 `GET .../events?after_seq=`)。B-3:Decode 失败返回 400。B-4:CreateAndStart/Subscribe 前置到 Flush 之前(对齐 chat 路径)。B-5:useChatWindow 全部写入统一带 `userId` 前缀,LRU 驱逐完整 cache key。B-6:`applyRestoredFileRaw` 函数式合并 | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave0-p0-bug-fixes-test-report.md);合入 `dev` merge `fix/wave0-p0-sse-chat-cache-bugs`;dev rebuild + health/ready PASS;真 PI SSE 心跳证据归档 | +| **Wave 1:main 环境修正(critical)** | E-1:app/events 统一 `ENV=main`。E-2:app `depends_on events: service_healthy`(events 同步迁移后再启 app)。E-5:`Env==""` 不再视同 dev。附带:events 补齐 `validateProductionSecrets` 所需口令以免 Restarting | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave1-main-env-fix-test-report.md);main health `env=main` + `schema_version=52`;consumer modes 仍 off | +| **Wave 2:配置与灰度入口对齐** | E-3:三环境声明 `MULTI_INTENT_PLAN_TIER`+U9–U12(legacy/false);A-4:test/main 补 SHADOW/NARRATIVE。E-4:删除 `CURSOR_RUNNER_URL`+cursor-agent。E-6:main `SEED_DEMO_USERS=false`。E-7:非法 `INTENT_V2_DECISION_TIER`→`none`。E-9:评估不改 initdb | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave2-config-flag-align-test-report.md);三环境 env 抽检 + health/ready PASS | +| **Wave 3:测试债与盲区** | S-1:隔离 PG(5436) 跑 `TestReconcile_LegacyRepair_PG`。S-2:删 taskstream 死跳过;plan_owner 重定向说明。S-4:每 spec npm 脚本;退役 `document-revision.spec`(消灭 DOCREV_E2E_BASE_URL)。S-5:`projectSettingsError` 复用到 members/freeform/taskRuns/chatSessions | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave3-test-debt-blindspots-test-report.md);playwright list 29/8;reconcile PG PASS | +| **Wave 4:隐患加固(可与 Wave 0–3 并行认领,mutex 不冲突时)** | R-1:`getOrCreateSession` 增 owner 绑定校验(session 创建时记录 owner,后续请求校验,不匹配 403)。R-2:18 处 `rows.Next()` 循环补 `rows.Err()` 检查。R-3:dispatcher Handlers 读路径加 RLock(或注释冻结「仅启动期注册」并加 archtest 约束)。R-4:`memTurns` 加 TTL/容量淘汰。R-5:pg_store JSONB 反序列化错误改为显式报错。R-6:后台 ticker 纳入 Shutdown;vault Drain 错误上报告警。R-7/R-8:SSE 重连 401/404 fail-fast、逐帧容错;登出 abort 在途流 | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md);Memory+PG 跨用户负例 PASS;`go test -short` / `npm test` 全绿 | +| **Wave 5:死代码与机制收口(需逐项决策,不擅自删)** | X-1:删除 `SEMANTIC_FALLBACK_ENABLED`。X-2:`SEMANTIC_SUMMARY_REPLACE` 维持未接线并封存注释。X-3:00048 三表 deferred → SWBOOT-10/W5b。X-4:system_settings 6 键保留并注释二期。X-5:删除 DocumentNotificationList、WorkflowPrerequisiteBanner、SettingsModal、activateWorkflow、saveMessages 及级联 patchNotification | **done**(2026-09-18):[决策](../ops/short-term/2026-09-18-wave5-deadcode-decisions.md) · [test-report](../test-reports/2026-09-18-wave5-deadcode-decisions-test-report.md) | +| **Wave 6:文档漂移修复** | D-1:PU8 四份契约文件状态改 done 并归档。D-2:modular-monolith ADR 状态更新为 accepted。D-3:两个 INDEX 末行已在本审计修正(复核即可)。D-4:handoff 回标已被覆盖。D-5:backlog D3/D7 同步最新状态。D-6:PROJECT.md §9「剩余 blocker」段与 gap-matrix 对齐或删除 | **done**(2026-09-18):[short-term](../ops/short-term/2026-09-18-wave6-doc-drift.md) · [test-report](../test-reports/2026-09-18-wave6-doc-drift-test-report.md);D-3 INDEX 复核 PASS、零改动 | +| **Wave 7:既有 roadmap 收口(不改优先级,仅确认编排)** | T1/W5:SWBOOT-10 安全评审后实施(保持 fail-closed 直至授权)。T1/W6:FAV live 放量 staging→artifact→publication,每级 ≥7 天观察窗,`FAV_DEGRADED_READ=false` 不动。T1/W7:D7 待合规签字后按 ADR 整改。T2:Control Center 前端 SPA 七页人工走查。T4:首次加载性能 U1/U3–U7。T5/T6/T8:backlog 类按收益触发 | **done**(2026-09-18):[决策记录](../ops/short-term/2026-09-18-wave7-roadmap-orchestration.md)。六项编排确认已记录;T2 发现审计结论过期(canonical 实现为 engineering-dashboard,已 done;`feature/control-center-server` 未合入,相对 `dev@a88ed85` 落后 136 commits,待用户裁决);FAV/D7/tier 升级红线未动 | + +## 验收标准 + +- [x] Wave 0 完成后:`go vet ./...`、`go test -short ./...`、`cd web && npm test && npm run build` 全绿;dev 容器 rebuild + health/ready PASS([报告](../test-reports/2026-09-18-wave0-p0-bug-fixes-test-report.md)) +- [x] main 环境:ENV 语义统一核验记录 + schema 已升(实测 52)+ 事件链路无双写证据(modes 仍 off)(Wave 1,[报告](../test-reports/2026-09-18-wave1-main-env-fix-test-report.md)) +- [x] SSE 长回合心跳接线:真 PI 静默期 `: heartbeat` + 短 WriteTimeout 单测跨越(Wave 0) +- [x] Wave 2 配置声明对齐:三环境 compose diff 复核 + 容器 env 抽检 + go test 全绿(E-3/E-4/E-6/E-7 完成;E-9 评估结论已记录,不改代码)([报告](../test-reports/2026-09-18-wave2-config-flag-align-test-report.md)) +- [x] `TestReconcile_LegacyRepair_PG` 报告归档,gap-matrix 测试债行关闭(Wave 3) +- [x] `playwright test --list` 与门禁文档一致(Wave 3) +- [x] 跨用户会话挂靠负例测试入库(Wave 4) +- [x] X-1~X-5 每项有决策记录(Wave 5) +- [x] D-1~D-6 全部回链关闭(Wave 6) +- [x] Wave 7 六项编排确认已记录(T1/W5、T1/W6、T1/W7、T2、T4、T5/T6/T8);FAV/D7/tier 升级红线未变(Wave 7) +- [x] 不触碰红线:FAV_DEGRADED_READ=false、MULTI_INTENT_PLAN_TIER 默认 legacy、SEMANTIC_SUMMARY_REPLACE 不接线、项目 143 只读、禁止整支 merge FAV + +## 相关链接 + +- Audit: [../audit/2026-09-17-system-health-check.md](../audit/2026-09-17-system-health-check.md) +- gap-matrix: [./gap-matrix.md](./gap-matrix.md)(本方案不改动发布门禁结论;E-1 若核验确认 main 事件链路异常,再评估是否升 blocker) +- 既有收口总控:[./2026-09-16-partial-deferred-completion-plan.md](./2026-09-16-partial-deferred-completion-plan.md) diff --git a/.doc/remediation/INDEX.md b/.doc/remediation/INDEX.md index 14ade67b..0ad4cb5c 100644 --- a/.doc/remediation/INDEX.md +++ b/.doc/remediation/INDEX.md @@ -2,6 +2,8 @@ | 文档 | 说明 | |------|------| +| [2026-09-17-system-health-remediation-plan.md](./2026-09-17-system-health-remediation-plan.md) | 系统体检整改:Wave0–7(SSE/缓存 P0 → main ENV → 配置对齐 → 测试债 → 隐患 → 死代码决策 → 文档漂移 → roadmap);配套 [审计](../audit/2026-09-17-system-health-check.md) | +| [2026-09-17-document-author-only-plan.md](./2026-09-17-document-author-only-plan.md) | 编制人兼任审核/批准:两级授权、`duty_policy=author_only`、U1–U6 实现已收口;默认 off;变更提案 HTTP 另立([audit](../audit/2026-09-17-docgov-modification-proposal-http-gap.md)) | | [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT | | [2026-09-16-partial-deferred-completion-plan.md](./2026-09-16-partial-deferred-completion-plan.md) | FEATURE-MATRIX 6 partial + 1 deferred 全量收口:通信机制确认(模块化单体,不新增 RPC 层)、7 项关闭条件、W1–W7 波次;§7 四项决策已确认(2026-09-17) | | [2026-09-16-feature-matrix-production-readiness-plan.md](./2026-09-16-feature-matrix-production-readiness-plan.md) | 能力状态与发布门禁解耦、证据校准、剩余 partial 收口和 production-ready 防回退计划([审计](../audit/2026-09-16-feature-matrix-production-readiness-audit.md)) | @@ -32,4 +34,4 @@ 审计入口:[../audit/INDEX.md](../audit/INDEX.md) -- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-plan.md)(三入口重启首访;方案未实施)。 +- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-plan.md)(三入口重启首访;U2 已验收,主 JS ~624 KB;U1/U3–U7 待实施)。 diff --git a/.doc/remediation/gap-matrix.md b/.doc/remediation/gap-matrix.md index e5769ad3..f0654a50 100644 --- a/.doc/remediation/gap-matrix.md +++ b/.doc/remediation/gap-matrix.md @@ -63,7 +63,7 @@ | ID | 描述 | 状态 | 证据 | 计划动作 | |---|---|---|---|---| | WORKFLOW-PLAN-FROZEN-OPTIONAL-DUTIES | active 项目仍展示可编辑职责表单,409 提示在长表单视口外;现有规划又不支持非必需文档“三权全空”,且后端缺少职责人在任成员复核 | **PASS(设置页)** | [audit](../audit/2026-09-14-workflow-plan-409-frozen-and-optional-duties.md);[test-report](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md)。项目 143 只读 frozen、A full+none 激活、B partial 不发 PUT、C 零治理拒绝、双标签转 frozen、成员无配置入口、刷新后 143 仍 frozen | 设置页矩阵 PASS;真实 PI full/none 长链已另签 [Wave2 报告](../test-reports/2026-09-16-wave2-real-pi-evidence-test-report.md) | -| DOC-CONTENT-CANONICAL-HASH-DRIFT | 旧受控文档 bootstrap 裁剪 Markdown 双空格硬换行并将 canonical SHA 写入账本/DocGov,但未同步文件;正文读取把完整性冲突误报 404,lease/list sync 又放大并制造刷新后加载死锁 | **PASS(真实 UI)** | [audit](../audit/2026-09-14-project143-controlled-content-404.md);[UI report](../test-reports/2026-09-14-controlled-content-ui-acceptance.md)。代码已合 dev、schema 32、项目 143 定向 `migration_repair`;项目 143 `clean-run3` 与新项目 `full-chain-final` 主链及 restart 全部纯页面 PASS,目标 404=0 | [remediation](./2026-09-14-controlled-content-canonical-hash-remediation.md)。隔离 `TestReconcile_LegacyRepair_PG` 未执行,按用户“本轮只允许页面测试”的边界保留为工程测试债,不作为本轮 UI PASS 的替代证据 | +| DOC-CONTENT-CANONICAL-HASH-DRIFT | 旧受控文档 bootstrap 裁剪 Markdown 双空格硬换行并将 canonical SHA 写入账本/DocGov,但未同步文件;正文读取把完整性冲突误报 404,lease/list sync 又放大并制造刷新后加载死锁 | **PASS(真实 UI)** | [audit](../audit/2026-09-14-project143-controlled-content-404.md);[UI report](../test-reports/2026-09-14-controlled-content-ui-acceptance.md)。代码已合 dev、schema 32、项目 143 定向 `migration_repair`;项目 143 `clean-run3` 与新项目 `full-chain-final` 主链及 restart 全部纯页面 PASS,目标 404=0 | [remediation](./2026-09-14-controlled-content-canonical-hash-remediation.md)。Wave 3(2026-09-18)已在隔离 docker PG(5436)执行 `TestReconcile_LegacyRepair_PG` 一次并 PASS,工程测试债关闭,见 [test-report](../test-reports/2026-09-18-wave3-test-debt-blindspots-test-report.md) | | CHAT-HISTORY-REFRESH | 工作流激活后刷新,对话正文被阶段过滤器清空,但分类数量与锚点仍按全量返回 | **PASS** | [test-report](../test-reports/2026-09-13-chat-history-refresh-test-report.md);活验 [live.json](../test-reports/assets/2026-09-15-chat-history-refresh-live/live.json)(项目 6252,window=categories=2,guest 403);代码:`message_scope.go`、`chat_history_auth.go`、goose 00026/00027 | 闲聊刷新已补。发布门禁「激活后文档任务 + 重登/重启」仍开放,不回退本 gap | | CHAT-INTENT-STATE | 真实会话被诊断探针污染;只读查询误启动文档写任务;错误动作与编辑状态刷新后丢失 | **PASS** | [audit](../audit/2026-09-13-conversation-intent-runtime-state.md);[test-report](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);双标签活验项目 **6275** / `sess-p6275-u10`(`live.json` + Docker 截图)。**不**删除 `sess-p143-u9` 历史探针 | [remediation](./2026-09-13-conversation-intent-runtime-state-remediation.md)。Wave 3–4 浏览器门已签;诊断污染消息仍不擅自删除 | | ENV-RESTART-SOURCE | `docker compose restart` 只重启旧镜像,健康检查无法证明合入代码已部署 | **PASS** | `env-service.sh rebuild` + 真实摘要重测 | 集成冒烟统一使用 `dev rebuild` | diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index b6edb3bb..7c4de65f 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -55,6 +55,7 @@ | 对话数据治理(D7) | 留存/删除口径、库·备份·日志控制、敏感字段加密边界 | deferred | deferred_enhancement(违规则升 blocker) | [计划](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md) Scope Boundaries;[收口 §7.1](../remediation/2026-09-16-partial-deferred-completion-plan.md);[D7 ADR 草案](../adr/2026-09-17-d7-chat-data-retention.md);[W7 盘点](../audit/2026-09-17-d7-data-inventory.md);[报告](../test-reports/2026-09-17-w7-d7-inventory-test-report.md) | **after W7**:只读盘点完成(sessions/messages/KB/日志/备份/event)。**未发现**已确认基线违规,未写 gap-matrix。口径仍 `pending-compliance-signoff`;禁止按 365/180/30 物理删除。本行不升 done | `chat` | | 智能体小队 | 角色-任务-工作区-黑板 | done | non_blocker | [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` | | 工作流治理 | 三正式工作流、设置页/DutyMode、动作级鉴权、真实 PI 三工作流 E2E、full/none 混合长链 | done | non_blocker | [三工作流整改](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[409/职责](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md);[PI E2E](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[Wave2 full/none](../test-reports/2026-09-16-wave2-real-pi-evidence-test-report.md) | full 跨用户审批 + none 免审批不阻塞已签。纯软件自举仍独立 planned | `workflow` | +| 研发设计控制(rdev) | 医疗第 4 工作流:13×60 manifest/模板、`squad.rdev_doc.v1` kind 编排、main-completed 硬 precheck、StageKey 动态化;不含前端/真 PI/键名空间化 | partial | deferred_enhancement | [设计](../system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md);[short-term](../archive/ops/2026-09-17-rdev-workflow-integration.md);[模板深度](../archive/ops/2026-09-17-rdev-template-depth.md);[S0+S1 首批报告](../test-reports/2026-09-17-rdev-template-depth-test-report.md);[S1 续批报告](../test-reports/2026-09-17-rdev-template-depth-s1-continued-test-report.md);[写作规范](../system-design/project-workflow/08-rdev模板写作规范.md);[backlog](../backlog/2026-09-17-rdev-phase2-backlog.md) | **P0+P1 done**。**模板深度 S0+S1 首批 + S1 续批 done**:60/60 模板已补强;真 PI 抽样 PASS(含 `rd_planning_review` + `rd_design_vv`,项目 6446)。剩余:S2/S3、P2 前端、P3 三样本 E2E、P4 键名空间化 | `workflow-def` / `rdev-templates` | | 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[W5a](../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md) | **before**:L1 + UI 仍克隆医疗模板。**after W5a**:catalog 驱动创建、软件不挂医疗 key、docs_only 真 PI 建议 delivery 且不自动激活。**SWBOOT-10 仍 stub**(W5b);本行不升 done | `workflow-design` | | IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` | | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` | @@ -80,7 +81,7 @@ | Intent L1 | partial(混入 U9–U12) | done | U9–U12 → 多意图扩面 | 是(双标签 + 分流 + U1–U3) | | Chat/ctxmgr | partial(混入语义摘要 D5 误标) | done | D3 摘要行;D7 治理行 | 是(B3 项目 6359) | | Document revision | partial(混入完整 PI 叙事) | done | PI change-report 叙事行 | 是(Round20;fallback 为声明行为) | -| Frontend workbench | partial(缺口栏已空) | done | 其他 409 信封复用 → backlog | 是(B4 项目 6363) | +| Frontend workbench | partial(缺口栏已空) | done | Wave 3 已收口 projectMembers/freeformDoc/taskRuns/chatSessions 结构化错误信封复用 | 是(B4 项目 6363) | | 可靠事件 | partial(与 FAV 混行) | done | FAV 独立 deferred | 是(E0–E2 + shared live) | --- @@ -95,3 +96,7 @@ ## 2026-09-17 首次加载性能专项 首次加载与重启首访性能优化:**partial**(U2 静态交付已验收;不改变现有功能 done/发布门禁结论)。[审计](../audit/2026-09-17-initial-load-performance-audit.md) · [方案](../remediation/2026-09-17-initial-load-performance-plan.md) · [U2 报告](../test-reports/2026-09-17-initial-load-perf-u2-test-report.md)。U2:自托管字体 + br/gzip + assets 404;主 JS 传输 2.82 MB → ~624 KB。U1/U3–U7 未完成。mutex `web + static-delivery`(U2 done)/ `loading-performance-audit`。 + +## 2026-09-17 文档编制人兼任方案 + +新增能力 **done(U1–U6,已合入 `dev@09a9921`)**:[方案](../remediation/2026-09-17-document-author-only-plan.md) · [源码核查](../audit/2026-09-17-document-author-only-analysis.md) · [U6 验收](../test-reports/2026-09-17-document-author-only-u6-test-report.md) · [收口交接](../ops/handoffs/2026-09-17-document-author-only-closeout.md)。任务归档:`archive/ops/2026-09-17-document-author-only-u{1..6}.md`。总开关默认 off;test/main 晋升与开放授权另授。变更提案 HTTP 仍缺([audit](../audit/2026-09-17-docgov-modification-proposal-http-gap.md))。mutex `document-duty-policy`。 diff --git a/.doc/system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md b/.doc/system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md new file mode 100644 index 00000000..26b86a6b --- /dev/null +++ b/.doc/system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md @@ -0,0 +1,254 @@ +# 07 研发工作流(rdev)接入——设计与实施计划 + +- **版本**: v1.1(四项关键决策已由用户确认,2026-09-17;见 §8) +- **日期**: 2026-09-17 +- **来源**: 用户需求「在系统中加入研发工作流」;参考项目调研见 [research/2026-09-17-rdev-workflow-reference-analysis.md](../../ops/research/2026-09-17-rdev-workflow-reference-analysis.md) +- **范围**: 在现有 5 个正式工作流(医疗三 + 软件两)之外,新增医疗第 4 工作流「**研发设计控制流程**」(key: `rdev`,**13 阶段 / 60 文档任务一次到位**,ISO 13485 DHF/DMR 设计控制链);并随后对现有工作流任务键做统一命名空间化(D12,P4) +- **分支建议**: `feature/rdev-workflow`(从 dev 切出);键名空间化独立分支 `feature/workflow-key-namespace` + +--- + +## 1. 结论摘要 + +参考项目的 rdev 工作流与本系统的「workflowdef 版本化定义 + projectinstance 冻结实例 + bizdev/squad 执行链」架构高度同构,接入以**数据与配置为主、代码为辅**: + +1. **一个新 manifest**(`rdev.v2.json`,13 stage × 60 doc task)+ **60 份模板** + **一个通用执行器键** `squad.rdev_doc.v1` + **一个数据驱动的 rdev 任务目录表**(squad 包内)即可跑通主链; +2. **两处前置修复**:docgov 文档 Stage 字段去硬编码(现为写死 `V0.5_feasibility`);rdev 启动前置门禁(main 实例 completed,即 CDO 已批准)按 test_manager 先例做**执行器级 precheck**,不恢复流程级依赖(遵循 [WF-DEF-06 整改](../../remediation/2026-09-12-three-workflows-strict-governance-remediation.md)); +3. **一个关键命名决策**:60 个任务键统一加 `rd_` 前缀(含模板文件名),避免与既有任务键 `system_reqs` 在 doc_id(`doc_proj{id}_{taskKey}`)、模板加载(`GetDocTemplate(taskKey)` 平铺目录)两层冲突; +4. 分四期实施(P0 前置修复 → P1 核心接入 → P2 治理与体验 → P3 真实 PI 验收),预估 2–3 周(单人)。 + +## 2. 参考项目 rdev 要点(回顾) + +见 [调研笔记](../../ops/research/2026-09-17-rdev-workflow-reference-analysis.md)。核心结构: + +- **13 phase / 60 stage**:01 策划(2) → 02 策划评审(1) → 03 设计输入(12) → 04 需求评审(1) → 05 输入阶段评审(1) → 06 设计输出(17) → 07 输出评审(1) → 08 验证(12) → 09 验证评审(1) → 10 转换(3) → 11 转换评审(1) → 12 确认(7) → 13 设计确认报告(1); +- **15 个 skippable**(软件 9 + 电气 3 + EMC + 动物试验 + 临床); +- **三种 artifact_kind**:`document` 47 / `review_minutes` 6 / `upload_index` 7; +- **记录归属**:`dhf` 为主,设计输出/转换类 `dhf+dmr`; +- **followon 门禁**:商务对接 CDO 批准后才可启动;启动时拷贝 bizdev 已填字段; +- **质量纪律**:模板写满、结论先行、UR→RS 追溯、缺项「待确认」、不适用须完整理由仍走审批、不编造数据/签字。 + +## 3. 现系统机制盘点(与本任务相关) + +| 机制 | 现状 | 对 rdev 的影响 | +|------|------|----------------| +| 工作流定义 | `internal/workflowdef`:Definition + Version(schema v2 JSONB 快照、definition_hash、published);正式 key 白名单 `catalog_policy.go`;seed 走 `manifests/*.json` 内嵌 + `BootstrapSeeds` 幂等发布 | 新增第 6 个正式 key;manifest/seed/白名单/目录顺序同步 | +| 模板资产 | `templates/docs/*.md` 启动时全量上传(asset_key=文件名);运行时 `template.GetDocTemplate(taskKey)` 平铺读 `templates/docs/.md` | **平铺命名 → 必须无碰撞**;rd_ 前缀方案 | +| 项目实例 | `projectinstance`:planning→live 冻结 ConfigSnapshot;阶段线性 position;`AdvanceStage` 要求全部 blocking doc task approved;末阶段完成 → InstanceDone | 13 stage 线性推进天然匹配 phase 序;depends_on 语义由阶段序覆盖 | +| 执行链 | server `task_run_handlers` → `taskrun.Manager`(单一 Executor = `bizdev.ExecuteTask`)→ `squad.ExecuteTask`(AdaptivePlanner 按 taskKey 出步骤)→ PI Agent Runner | executor_key 目前是**发布校验元数据**,运行时统一走 bizdev;新增任务主要是 squad 侧编排数据 | +| squad 编排 | `SupportedTasks` 白名单(9 个);`DefaultWorkflowSteps`/`DefaultSquadAgents` 按 taskKey **硬编码 switch**;非白名单任务回落 autonomous adhoc | 60 任务若逐个 switch 不可维护 → 改为按 artifact_kind 三套通用编排 + rdev 数据目录表 | +| 文档账本 | docgov:`doc_id = doc_proj{pid}_{taskKey}`;**Stage 字段写死 `V0.5_feasibility`**(bizdev/service.go:483,489 + docgov/repository.go:28 兜底) | **P0 必修**:改为从冻结实例解析 StageKey | +| 前置依赖 | 无流程级依赖(WF-DEF-06 已移除);test_manager 用执行器级 precheck(approved SRS)+ `WorkflowPrerequisiteBanner` | rdev 同模式:precheck「main 实例 completed」 | +| 血缘 stale | `projectworkflow/service.go` MarkDownstreamStale;test_manager 记录单一上游 hash | 首期统一项目上下文注入;精确 depends_on 血缘列二期 | +| 意图 | L1 `extractDocName` 硬编码 9 个文档名;U8 多意图 task-generate 从 live 实例数据驱动 | rd 文档名匹配需增补或实例驱动化 | +| 前端 | `ProjectWorkflowSwitcher`(目录 API 驱动)+ 激活规划 UI + StageAdvance/Prerequisite Banner | 13×60 规划 UI 需折叠分组与批量三权指派 | + +## 4. 关键设计决策 + +### D1 工作流标识与目录 + +- `OfficialKeyRDev = "rdev"`;对外名称建议 **「研发设计控制流程」**(与「商务对接流程」命名风格一致;备选「研发流程」); +- `product_domains: ["medical_device"]`(软件域不可选); +- `catalog_order = 40`(排在 test_manager 之后、software_bootstrap 之前);`medicalWorkflowKeys` 扩为四个; +- validator 文案「仅允许选择正式工作流(医疗三或软件自举/交付)」同步更新;审计口径「项目选择页只见 N 个工作流」从 5 → 6。 + + +### D2 任务键与模板命名(关键决策) + +**问题**:`doc_id`、模板加载、docgov task_key 三层都以 taskKey 平铺命名。rdev 的 `system_reqs`(SR)与现有 PM 任务 `system_reqs` 直接冲突;且未来任何工作流任务键都共享同一命名空间。 + +**推荐:60 个任务键统一加 `rd_` 前缀**(`rd_proposal`、`rd_design_plan`、`rd_system_reqs`…),模板文件同步 `rd_*.md`: + +- 彻底消除 doc_id/模板/任务键三层冲突,跨工作流血缘清晰; +- 转换机械:从参考项目 `rdev.yaml` 逐行映射生成(可写一次性生成脚本,人工复核); +- manifest 中 `title`/`output_doc_title` 保留中文文档名,用户可见名称不变; +- 模板头部「文件编号 RD-…」保留参考项目编号(RD-PROPOSAL 等),不受键名影响。 + +**备选**:仅 `system_reqs` 一键改名、其余保持原键。缺点:命名空间纪律弱,未来再加工作流仍会踩。 + +### D3 manifest 结构(rdev.v2.json) + +13 个 StageDef 对应 13 个 phase(键名风格沿用本仓库 `V0.1_triage` 惯例)。示例(完整 60 任务 = 参考项目 rdev.yaml 逐行映射): + +```json +{ + "schema_version": 2, + "workflow_key": "rdev", + "product_domains": ["medical_device"], + "stages": [ + {"key": "RD01_planning", "name": "01 设计和开发策划", "position": 10, "optional": false, + "doc_tasks": [ + {"key": "rd_proposal", "title": "项目建议书", "default_enabled": true, "creator_configurable": true, + "blocking_when_enabled": true, "executor_key": "squad.rdev_doc.v1", "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_proposal", "output_doc_type": "rd_proposal", "output_doc_title": "项目建议书"}, + {"key": "rd_design_plan", "title": "设计开发计划任务书", "default_enabled": true, "creator_configurable": true, + "blocking_when_enabled": true, "executor_key": "squad.rdev_doc.v1", "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_design_plan", "output_doc_type": "rd_design_plan", "output_doc_title": "设计开发计划任务书"} + ]}, + {"key": "RD02_planning_review", "name": "02 策划评审", "position": 20, + "doc_tasks": [{"key": "rd_planning_review", "title": "设计和开发策划评审记录", "...": "同上模式"}]}, + {"key": "RD03_design_input", "name": "03 设计输入", "position": 30, "doc_tasks": ["12 个任务"]}, + {"key": "RD04_requirements_review", "position": 40, "doc_tasks": ["rd_req_review"]}, + {"key": "RD05_input_phase_review", "position": 50, "doc_tasks": ["rd_input_phase_review"]}, + {"key": "RD06_design_output", "position": 60, "doc_tasks": ["17 个任务"]}, + {"key": "RD07_output_review", "position": 70, "doc_tasks": ["rd_output_review"]}, + {"key": "RD08_verification", "position": 80, "doc_tasks": ["12 个任务"]}, + {"key": "RD09_verification_review", "position": 90, "doc_tasks": ["rd_verification_review"]}, + {"key": "RD10_transfer", "position": 100, "doc_tasks": ["3 个任务"]}, + {"key": "RD11_transfer_review", "position": 110, "doc_tasks": ["rd_transfer_review"]}, + {"key": "RD12_validation", "position": 120, "doc_tasks": ["7 个任务"]}, + {"key": "RD13_validation_report", "position": 130, "doc_tasks": ["rd_design_validation_report"]} + ] +} +``` + +- 全部任务 `default_enabled/creator_configurable/blocking_when_enabled = true`(与本仓库 normalize 惯例一致); +- **skippable 语义**:参考项目的 15 个可声明不适用任务与本仓库「激活规划期可关闭」语义等价(creator 在规划时关闭 = 不适用);「生成完整不适用页仍走审批」列为二期增强(D9); +- `output_doc_type` = 任务键(本仓库惯例 doc type = task key)。 + +### D4 执行器与质量门禁 + +- 新增唯一执行器键 **`squad.rdev_doc.v1`**(`workflow/registry.go`),运行时仍统一由 bizdev.ExecuteTask 按 taskKey 分派——与 `squad.test_manager.v1` 模式一致,避免 60 个注册项; +- 质量门禁首期 **`quality.generic.v1`**(全部 60 任务);kind 专用门禁列二期(D9)。 + +### D5 启动前置门禁(followon 语义) + +遵循 WF-DEF-06:**不做流程级 RequiredUpstreamWorkflow**,改为执行器级 precheck(先例:test_manager 查 approved SRS): + +- `bizdev.ExecuteTask` 对 `rd_` 前缀任务启动前校验:本项目 **main 工作流 live 实例 `completed`**(即 CDO 报价已批准)。查不到/未完成 → 明确错误文案(「请先完成商务对接流程(CDO 报价批准)后再启动研发设计控制流程」); +- 激活 rdev 实例时(planning 允许,运行由 precheck 兜底):`WorkflowPrerequisiteBanner` 增加 rdev 文案;目录推荐逻辑「main 完成后推荐 PM」扩展「main 完成后推荐 rdev」; +- 启动时拷贝 bizdev 字段:现有 `TaskRunRequest` 已带 project 字段(device_type/intended_use/target_market 来自项目实体),无需额外拷贝机制。 + +### D6 docgov Stage 字段动态化(P0 必修) + +- `bizdev/service.go:483/489` 与 `docgov/repository.go:28` 写死 `V0.5_feasibility` → 改为从请求/冻结实例解析 StageKey 传入(`workflow/runtime/orchestrator.go` 的 `TaskResolution.StageKey` 已具备;`server/task_run_request_build` 把 stage key 带进 `bizdev.TaskRunRequest` 新增的 `StageKey` 字段并透传); +- 兜底策略:解析不到时回落 main 三阶段映射,再回落原值(兼容历史数据,不做历史迁移); +- `maturity` 包的 V0.1/V0.5/V1.0 成熟度映射仅对 main 任务生效,rd_ 任务首期跳过成熟度标签。 + +### D7 squad 编排(数据驱动重构) + +新增 `internal/squad/rdev_catalog.go`:60 项任务目录表(taskKey → title/phase/kind/docCode/skippable/描述),生成来源 = 参考项目 rdev.yaml(一次性转换 + 齐套测试锁定): + +- `SupportedTasks` 合并 rdev 目录(`isStandardTask` 命中,避免回落 adhoc); +- `DefaultWorkflowSteps`/`DefaultSquadAgents` 增加 `rd_` 前缀分支,**按 artifact_kind 三套通用编排**(对齐参考项目 squads/rdev.yaml 的 default_steps + 覆盖逻辑): + +| kind | 步骤 | 小队 | +|------|------|------| +| document(47) | organizer → engineer(可并行)→ regulatory(可选)→ writer → leader | organizer/engineer/writer/leader;regulatory 注入软件/法规风险类(`rd_sw_*`、`rd_regs_stds`、`rd_adverse_events`、`rd_biocompatibility`、`rd_clinical`、`rd_animal_study`、`rd_risk_*`、`rd_emc_safety`) | +| review_minutes(6) | organizer → writer → leader | organizer/writer/leader(对齐参考项目评审阶段三步) | +| upload_index(7) | organizer → engineer → writer → leader | organizer/engineer/writer/leader | + +- leader 角色名「研发设计控制负责人」(对应参考项目 rdev_leader);角色提示复用现有 `configs/agents/*.md`(organizer/engineer/writer/leader/regulatory 已有),rdev 语境差异放进步骤 `Accept` 与 TASK 说明; +- 参考项目 overlays 的核心纪律(写满、结论先行、追溯表必填、缺项「待确认」、不编造数据/签字/附件)合并进:a) 三套 kind 步骤的 `Accept`;b) `configs/shared/writing.md` 增补 DHF 纪律段(GetAgentInstruction 已自动附加该文件,rd 任务天然注入)。 + +### D8 上游文档注入与血缘 + +- 首期:`bizdev.ExecuteTask` 对 `rd_` 任务注入 **项目上下文快照**(`projectcontext.BuildSnapshot`,先例 test_manager)+ **同项目已批准文档清单**(bizdev 七件 + 已批准 rd_ 文档,作为 prior_docs 全量注入,交小队自行引用); +- 血缘:沿用 test_manager 模式在 finalize 时记录上游 hash(首期摘要级);精确 `depends_on`(如 rd_verification_review ← rd_design_vv + rd_risk_report_v)列二期(见 [backlog](../../backlog/2026-09-17-rdev-phase2-backlog.md)); +- `rd_system_reqs` 与 PM `system_reqs` 的关系:PM 输出若已批准,作为 prior doc 优先注入(不强制、不独占)。 + +### D9 不适用(N/A)与二期增强 + +- 首期:N/A = 激活规划期关闭任务(creator_configurable),推进语义由 readiness 门禁保证; +- 二期增强(参考项目行为):保留任务但生成「不适用说明页」(完整理由、走审批);UR/RS 结构化门禁(复用 PM `requirements.json` 模式于 rd_user_needs/rd_system_reqs);review_minutes/upload_index 专用确定性门禁。 + +### D10 意图与聊天 + +- U8 多意图 task-generate 从 live 实例 doc tasks 数据驱动——rd 任务应自动可用(P2 验证 60 任务名消歧与澄清卡); +- L1 `extractDocName` 硬编码名表:增补 rd 高频文档名(项目建议书、设计开发计划、风险管理计划、设计验证方案、设计确认报告 等),并**评估改为从 live 实例任务标题动态匹配**(推荐,彻底消除硬编码)。 + +### D11 前端 + +- `ProjectWorkflowSwitcher`:rdev 名称/顺序/描述来自目录 API(数据驱动)+ 本地 label 常量增补; +- 激活规划 UI:13 阶段 × 60 任务 × 三权指派是大列表——需按阶段折叠分组、三权「按阶段批量指派 / 继承上一阶段」操作、15 个 skippable 任务预设关闭建议; +- 文档工作台:展示 doc Stage 为 RD 阶段键(依赖 D6);`WorkflowPrerequisiteBanner` rdev 文案; +- 前端测试:workflow 相关既有断言(工作流数量/名称)同步更新。 + +## 5. 资产与代码改动清单 + +### 5.1 数据/配置资产 + +| 资产 | 说明 | +|------|------| +| `internal/workflowdef/manifests/rdev.v2.json` | 13 stage / 60 doc task(D3) | +| `templates/docs/rd_*.md` × 60 | 从参考项目 `templates/docs/*.md` 转换:改名 rd_ 前缀、保留 RD 编号/DHF 归属骨架;核对模板变量(`{{project_name}}/{{generated_at}}/{{snapshot_id}}/{{applicability}}/{{na_rationale}}/{{materials}}` 均为 RenderDoc 可用变量,缺失自动「待确认」) | +| `configs/shared/writing.md` 增补 | DHF 设计控制纪律段(rd 任务注入) | +| `internal/squad/rdev_catalog.go` | 60 项任务目录(title/phase/kind/docCode/skippable) | + +### 5.2 Go 代码(按包) + +| 包 | 改动 | +|------|------| +| `internal/workflowdef` | catalog_policy.go(常量/名称/顺序/域/医疗四键)、manifest_loader.go(V2ManifestFiles)、bootstrap.go(officialSeedMeta)、validator 文案、publish/catalog 测试更新 | +| `internal/workflow` | registry.go 注册 `squad.rdev_doc.v1` | +| `internal/squad` | rdev_catalog.go 新增;service.go SupportedTasks 合并;workspace.go rd_ 分支(按 kind 三套 DefaultWorkflowSteps/DefaultSquadAgents) | +| `internal/bizdev` | TaskRunRequest 增加 StageKey;ExecuteTask:rd_ precheck(main completed)、prior docs 注入、CreateOrGetDocument 传动态 Stage、ListStages/maturity 对 rd_ 的处理 | +| `internal/server` | task_run_request_build 透传 stage key;workflow_catalog_handlers 名称;齐套校验 Go test(60 manifest 任务 ↔ 模板文件存在非空 ↔ 末模板含「签署」,对齐参考 e2e-rdev.sh 断言 + 「rd_ 键与既有任务键集合不相交」断言) | +| `internal/docgov` | repository.go stage 兜底改为入参优先 | +| `internal/intent` | extractDocName 增补/实例驱动(D10) | +| `web/` | D11 各项 | + +### 5.3 文档 + +- `.doc/ops/short-term/2026-09-17-rdev-workflow-integration.md`(任务条目,随本方案创建) +- FEATURE-MATRIX 新增行(planned → done 演进);验收报告归档 `.doc/test-reports/` + +## 6. 分期实施计划 + +| Phase | 内容 | 分支/PR | 验收标准 | +|-------|------|--------|----------| +| **P0 前置修复** | D6 docgov Stage 动态化 + stage key 透传 + 回归 | `fix/docgov-stage-dynamic` | 现有三工作流全链路 PG 回归 PASS;新文档 Stage 字段 = 实例阶段键 | +| **P1 核心接入** | manifest + catalog/seed + registry + 60 模板 + squad 数据目录与 kind 编排 + rd_ precheck + 齐套 Go test + stub PG 全链路(激活 13 阶段 → rd_proposal 生成/审/批 → 推进 → N/A 关闭路径 → precheck 拒绝路径) | `feature/rdev-core` | `go vet`/`go test -short` PASS;PG 集成测试 PASS;齐套断言 60/60;main 未完成时 rd 任务启动被拒且文案正确 | +| **P2 治理与体验** | 前端(规划 UI 折叠分组/批量三权/PrerequisiteBanner/文档 Stage 展示)+ intent 名称匹配 + prior docs 注入与摘要级血缘/stale + web 测试 | `feature/rdev-web-governance` | web `npm test`/`npm run build` PASS;Playwright 抽检激活与推进;聊天「生成项目建议书」命中 rd_proposal | +| **P3 真实验收** | 真实 PI Agent + MiniMax 全链路:rd_proposal(document)+ rd_planning_review(review_minutes)+ rd_sw_plan 软件类 N/A 三样本;报告归档;CI「PI 三工作流」门禁扩为四 | — | `.doc/test-reports/` rdev 真实 PI E2E 报告;CI green receipt | +| **P4 键名空间化**(决策 4 扩展,D12) | main/pm/tm(+软件两)任务键改 `bd_`/`pm_`/`tm_`(`sw_`)前缀:manifest 新版本 + doc_id 归一映射 + 模板 alias + squad 旧键兼容 + 双路径回归 | `feature/workflow-key-namespace`(**独立验收,不与 P1–P3 合并**) | 旧 live 实例(旧键)与新建实例(新键)双路径 stub 全链路 PASS;全工作流任务键集合无交集断言 PASS;存量文档旧 doc_id 仍可读 | + +依赖:P0 → P1 → P2 → P3;P1 与 P2 可部分并行(先冻结前后端契约);P4 在 P3 验收后启动(也可与 P2 并行,但合入 dev 须占集成槽串行)。 + +## 7. 风险与缓解 + +| 风险 | 影响 | 缓解 | +|------|------|------| +| 60 任务三权指派 UI 负担 | 激活体验差、误配 | 批量指派 + 阶段继承 + 三权默认创建人兜底(现状) | +| generic 质量门禁误杀评审记录/附件清单类 | 任务被错误打回 | P1 用 stub 对 6+7 个 kind 任务逐一回归;必要时豁免清单 | +| U8/意图对 60 文档名消歧 | 生成错任务 | P2 实例驱动匹配 + 澄清卡;高频名称抽检 | +| 命名空间遗漏 | doc_id/模板冲突回归 | 齐套 test 增加「rd_ 键不与既有任务键相交」断言 | +| CI 门禁与审计口径过时 | nightly 误报 | 同步「PI 三工作流」→ 四;审计 checklist 引用本方案 | +| 大 manifest / 冻结快照体积 | 性能 | 60 任务 JSONB 量级小,P1 PG 测试观察即可 | + +## 8. 决策记录(已与用户确认,2026-09-17) + +| # | 决策 | 结论 | +|---|------|------| +| 1 | 对外名称 | **「研发设计控制流程」**(key: `rdev`) | +| 2 | 首期范围 | **manifest 一次到位 60 任务 / 13 阶段**,不切试点 | +| 3 | N/A 语义 | 首期「激活规划期关闭」;「生成完整不适用页走审批」等二期增强**记入 backlog**:[2026-09-17-rdev-phase2-backlog](../../backlog/2026-09-17-rdev-phase2-backlog.md) | +| 4 | 任务键前缀 | rdev 60 键统一 `rd_` 前缀;且**现有工作流(main/pm/tm)任务键、模板、阶段等同样做键名空间化处理**(见 D12,独立分期 P4,避免与 rdev 接入耦合) | + +### D12 现有工作流键名空间化(决策 4 扩展,P4 实施) + +**目标**:全系统任务键命名规约统一为 `<工作流前缀>_<任务>`,杜绝跨工作流 doc_id/模板/任务键冲突: + +| 工作流 | 现键(9 个) | 新键 | +|--------|-------------|------| +| main(商务对接) | intake / research / feasibility_dev / feasibility_reg / user_requirements / prelim_design / cdo_quote | `bd_intake` / `bd_research` / `bd_feasibility_dev` / `bd_feasibility_reg` / `bd_user_requirements` / `bd_prelim_design` / `bd_cdo_quote` | +| product_manager | system_reqs | `pm_system_reqs` | +| test_manager | test_manager | `tm_system_verification` | +| rdev(新) | — | `rd_*`(P1 落地,60 个) | +| software_* | (另行盘点 `software.doc.v1` 任务键后纳入 `sw_` 前缀,P4 一并处理) | + +**兼容策略(alias 兼容式改名,不破坏存量数据)**: + +1. **manifest 版本升级**:main/pm/tm manifest 任务键改为新键 → `UpsertPublishedSeed` 按内容 hash 发布新版本;**冻结实例不迁移**——存量 live 实例继续持有旧键直至 completed(冻结快照是 SSOT,禁止改写); +2. **doc_id 兼容**:`bizdev/doc_id.go normalizeDocID` 增加旧→新映射表(`doc_proj{pid}_intake` → `doc_proj{pid}_bd_intake`),读路径旧 ID 自动归一;**文档账本不批量迁移**,新文档用新键,旧文档旧 ID 保留可查(已有 legacy pattern 归一先例); +3. **模板**:`templates/docs/` 文件改名(bd_intake.md 等),template loader 增加旧名→新名 alias(旧 live 实例运行仍按旧 taskKey 取模板→alias 命中新文件); +4. **squad 白名单**:`SupportedTasks`/`DefaultWorkflowSteps`/`DefaultSquadAgents` 增加旧键 alias 分支(存量实例执行路径不动),新键为主实现; +5. **intent/前端**:`extractDocName` 按文档名匹配不涉键名(无改动);前端断言与 label 按新键更新; +6. **回归**:旧 live 实例(旧键)+ 新规划实例(新键)双路径 stub 全链路回归;齐套断言扩展为「全工作流任务键集合无交集 + 各自前缀正确」。 + +**风险控制**:P4 独立分支 `feature/workflow-key-namespace`、独立验收,**不与 rdev P1–P3 合并**;若 P4 验收发现存量实例兼容缺口,允许只回退 manifest(DB seed 幂等)。 + +### 二期 backlog(决策 3,随时可续推进) + +见 [2026-09-17-rdev-phase2-backlog](../../backlog/2026-09-17-rdev-phase2-backlog.md):N/A 不适用页生成模式、UR/RS 结构化门禁、review_minutes/upload_index 专用确定性门禁、depends_on 精确血缘与 stale 传播、DMR 双记录归档、软件工作流 sw_ 键盘点等。 + diff --git a/.doc/system-design/project-workflow/08-rdev模板写作规范.md b/.doc/system-design/project-workflow/08-rdev模板写作规范.md new file mode 100644 index 00000000..4dd7119c --- /dev/null +++ b/.doc/system-design/project-workflow/08-rdev模板写作规范.md @@ -0,0 +1,59 @@ +# rdev 模板写作规范 + +> 适用范围:`templates/docs/rd_*.md`(研发设计控制 / ISO 13485 DHF) +> 更新日期:2026-09-17 + +## 1. 分工红线(强制) + +| 层 | 负责 | 不负责 | +| --- | --- | --- | +| **模板**(`templates/docs/rd_*.md`) | 章节结构、表头、逐节写作指令、示例「待确认」行、治理骨架(编号/适用性/追溯/签署) | 运行时角色口吻、模型选择、粒度下限的强制执行 | +| **overlay / 运行时**(`configs/agents/*`、`configs/shared/writing.md`、未来 overlays) | 写作纪律、DHF 粒度下限、禁止编造、角色边界 | 替代模板定义专业章节结构 | + +一句话:**模板管结构与占位;overlay/运行时管写作纪律与粒度下限。** + +## 2. 八条基准(源自商务模板实践) + +1. **结论先行**:文首结论句完整;后文不得与结论打架。 +2. **四段式展开**:结论句 → 表格或列表 → 解读段落 → 出处/支撑;禁止通篇只有列表。 +3. **括号指令可执行**:每节括号内写清覆盖范围、缺项规则、禁止项;禁止只写「按本阶段专业结构写」。 +4. **表不交空壳**:至少一行「待确认」示例行;禁止空表头。 +5. **缺项显式**:统一标「待确认」,并在「待确认清单」汇总。 +6. **不做疗效宣称**:设计控制文档禁止疗效/临床效果绝对化用语。 +7. **禁止残留提示**:终稿不得留下括号写作提示原文。 +8. **追溯可填**:`| RD-` 行存在;上游/下游未知写「待确认」不留空白无说明。 + +## 3. 治理骨架(每份 rd_ 模板保留) + +- 文首元数据:项目 / 生成时间 / 快照 / 文件编号 / 记录归属:DHF / 版本 / 阶段 +- `## 结论` / `## 适用性` +- 专业正文各节(≥4 个 `## ` 专业或治理章节合计满足 lint) +- `## 追溯`(含 `| RD-` 行) +- `## 已有材料摘要` / `## 待确认清单` / `## 签署` / `## 说明` + +## 4. 补强清单与批次 + +| 批次 | 范围 | 状态 | +| --- | --- | --- | +| S0 样例 | `rd_design_input_spec` | 本轮 | +| S1 批 1 | `rd_proposal` / `rd_design_input_list` / `rd_system_design` / `rd_regs_stds` / `rd_adverse_events` | 本轮 | +| S1 批 2 | `rd_sw_plan` / `rd_sw_reqs` / `rd_sw_config` / `rd_risk_plan` | 本轮 | +| S1 批 3 | `rd_design_plan` / `rd_user_needs` / `rd_hazard_analysis` / `rd_system_reqs`(SRS 深度移植) | 本轮 | +| S1 续批批1 | 评审记录 6 份(`rd_planning_review` 等) | done | +| S1 续批批2 | 阶段06 硬件/工艺 8 份 | done | +| S1 续批批3 | 阶段06 软件/检验/技术要求+清单 9 份 | done | +| S1 续批批4 | 阶段08 验证 12 份 | done | +| S1 续批批5 | 阶段10–13 转换/确认 11 份 | done | +| S1 续批合计 | 剩余 46 份全部同等深度;allowlist 60/60;真 PI 抽样 PASS | done([报告](../../test-reports/2026-09-17-rdev-template-depth-s1-continued-test-report.md)) | +| S2 / S3 | per-stage 步骤、overlay 机制、专用门禁 | follow-up | + +## 5. Lint 门禁 + +`internal/workflowdef/rdev_template_lint_test.go` 对「已补强 allowlist」断言: + +- 不含「按本阶段专业结构写」 +- 正文 `## ` 章节 ≥ 4 +- 含追溯表行 `| RD-` +- 含 `## 待确认清单` 与 `## 签署` + +未列入 allowlist 的模板不参与断言;随批次增长扩大 allowlist。 diff --git a/.doc/test-reports/2026-09-17-docgov-modification-proposal-http-test-report.md b/.doc/test-reports/2026-09-17-docgov-modification-proposal-http-test-report.md new file mode 100644 index 00000000..860c2ffa --- /dev/null +++ b/.doc/test-reports/2026-09-17-docgov-modification-proposal-http-test-report.md @@ -0,0 +1,37 @@ +# docgov 变更提案 HTTP 测试报告 + +## 一、 测试背景 (Background) +- 引擎已有 Propose/Decide,但缺少 propose HTTP;前端 pendingProposal 为本地模拟。 +- author-only 收口后补齐变更提案闭环。 + +## 二、 测试目标 (Test Objectives) +- `POST /api/v1/docgov/proposal` 写库成功 +- `POST /api/v1/docgov/proposal/decide` 批准写库 +- 前端 client 单测覆盖路径 + +## 三、 测试方案与环境 (Test Plan & Environment) +- OS: Linux;后端:agent-dev `:8080` schema 52;真实 `pi-agent` 环境(本用例不触发任务跑批) +- 层级:Go handler 单测 + 前端 client 单测 + 真实 HTTP + +## 四、 预期结果 (Expected Results) +- claim 已认领文档 → propose → status=`pending_author_approval` → decide approve → `approved_by_author` + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### TC-HTTP-01 claim → propose → decide +- **测试输入**: doc `doc_proj6425_rd_design_input_spec`(author=admin/9,已认领) +- **实际输出**: 见 assets `01`–`05`;proposal `prop_rd_design_input_spec_1789654256551680649` → `approved_by_author` +- **评价判定**: PASS +- **性能与耗时**: 全链路 <1s(不含 rebuild) + +### TC-UNIT-01 +- `go test -run TestServer_DocGovProposeAndDecideHTTP ./internal/server/` PASS +- `node --test web/src/api/docgov.test.ts` 6/6 PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) +- 合入后冒烟:`go vet ./...`;`go test -short ./internal/server/ ./internal/docgov/` PASS +- `./scripts/env-service.sh dev rebuild`;`GET /health` → status=ok schema=52 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) +- 达到合入/推送标准;**未**晋升 test/main +- 后续:批准后自动应用受控正文 diff(非本期) diff --git a/.doc/test-reports/2026-09-17-document-author-only-u6-test-report.md b/.doc/test-reports/2026-09-17-document-author-only-u6-test-report.md new file mode 100644 index 00000000..4f627f0c --- /dev/null +++ b/.doc/test-reports/2026-09-17-document-author-only-u6-test-report.md @@ -0,0 +1,128 @@ +# 文档编制人兼任 U6 多职责动作与真实闭环 测试报告 + +## 一、 测试背景 (Background) + +U1–U5 已落地管理员总开关、逐文档 `duty_policy=author_only`、ABAC 门禁与设置页 UI。交付验收时发现两处前端缺口: + +1. 审批动作条从未调用已存在的 `docgov` HTTP(claim / submit-review / review-decision / approver-decision),仅本地改 `lifecycleState`; +2. `resolveCurrentDocRole` 的 author-first 短路使 A/A/A 用户永远只解析为 `author`,审核/批准按钮与快捷指令不可见。 + +U6 修复可见性与真实 API 接入,使编制人兼任完整闭环第一次可验收。 + +## 二、 测试目标 (Test Objectives) + +- author_only(三职同一人)下 A 可见送审/审核/批准动作;非 A 不可见且直连接口 403; +- 点击认领→送审→审核通过→终审批准走真实后端,写 `document_records` / `document_audit_logs`(含 `duty_policy`); +- 动作条展示「编制人兼任」徽标;`duty_policy` 从 summary 透传到 AST; +- 前端单测与 `tsc --noEmit` 回归通过。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|---|---| +| OS | Linux | +| 环境 | dev `:8080`(feature worktree rebuild,`schema_version=52`,`agent_backend=pi-agent`) | +| 前端 | worktree Vite `:5173` 代理 API | +| 被测文档 | `doc_proj6425_rd_design_input_spec`(项目 6425,`rdev-s0-fresh-*`,既有真实 PI 生成正文) | +| 配置 | 实例快照 `duty_policy=author_only`,有效职责 A/A/A(user_id=9 系统管理员) | +| 负例用户 | `lizhuguan`(id=11) | + +分层: + +1. 前端单元:`docGovActionVisibility` + `docgov` API client; +2. HTTP 真实闭环:claim → submit → review → approve; +3. HTTP 负例:非职责人 403; +4. 浏览器:Playwright(官方 image)打开 Vite,断言徽标与送审按钮。 + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|---|---| +| AE-UI | A 打开 author_only 文档见「编制人兼任」+ 送审按钮 | +| AE-API | A 依次 claim/submit/review/approve → `current_state=approved`,审计含 `duty_policy=author_only` | +| AE-NEG | 李主管 review/approve → `DOC_DUTY_FORBIDDEN` HTTP 403 | +| AE-UNIT | author_only 三动作对 A 全 true、对非 A 全 false;分离三人矩阵不变 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 前端单元 + +**输入**:`npm test` + `npx tsc --noEmit`(worktree `web/`) + +**实际输出**:`ℹ tests 390` / `pass 390` / `fail 0`;含 `docgov lifecycle API client` 4 例与 `docGovActionVisibility` author_only 矩阵;`tsc --noEmit` 无错误。 + +**判定**:PASS + +### 5.2 HTTP 真实闭环(author_only) + +**输入**:admin Bearer 调用四接口(证据:`assets/2026-09-17-document-author-only-u6/api-lifecycle-author-only.txt`) + +**实际输出(摘要)**: + +- `POST /docgov/review-decision` → HTTP 200,`current_state=approving`,`duty_policy=author_only` +- `POST /docgov/approver-decision` → HTTP 200,`current_state=approved` +- 审计:`submit_review` / `review_approve` / `approver_approve` 的 `details.duty_policy=author_only`(`db-final-author-only.txt`) + +**判定**:PASS + +### 5.3 HTTP 负例 + +**输入**:`lizhuguan` 对同一文档 review/approve(`api-negative.txt`) + +**实际输出**: + +```text +{"error":"DOC_DUTY_FORBIDDEN","message":"当前用户不是该文档的对应职责人"} +HTTP 403 +``` + +(审核与批准各一次) + +**判定**:PASS + +### 5.4 浏览器(Vite + Playwright) + +**输入**:注入 admin 会话 → 打开 `rdev-s0-6425` → 《设计输入规格书》 + +**实际输出**: + +- `browser-visibility.json`:`hasBadge=true`,`hasClaim=true`,`hasSubmit=true` +- 截图 `screenshots/04-action-bar.png`:明确显示「编制人兼任」徽标与「提交送审」按钮 + +**判定**:PASS(可见性与徽标);按钮逐级点击以 API 闭环为准(浏览器侧状态机与网络时序未作为唯一证据)。 + +### 5.5 性能与耗时 + +| 步骤 | 耗时量级 | +|---|---| +| Vite npm test | ~2s | +| tsc --noEmit | ~3s | +| API 四步闭环 | <1s | +| Playwright 全流程 | ~25s(含容器内 npm i playwright) | + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```text +web: npm test → 390 pass / 0 fail +web: npx tsc --noEmit → clean +``` + +Go 侧 U1–U4 既有矩阵本轮未重跑全量;dev 容器已含 `allow_author_only_approval` 二进制与 goose 00050–00052。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +**结论**:U6 达到合并前验收标准——author_only 多职责动作可见、真实 docgov 闭环与负例 403、兼任徽标透传均有证据。回链 U1–U6 与 FEATURE-MATRIX。 + +**限制说明**: + +- 本轮闭环基于已激活实例快照补丁为 `author_only` + 既有 PI 生成文档;管理员总开关→创建者设置→激活的产品配置链路由 U1–U5 覆盖,本报告不重复。 +- 变更提案 HTTP 路由仍缺失(独立缺口,见 audit 笔记),不在 U6 范围。 + +**后续**:合入 `dev` 后按集成槽位正式冒烟;可补 Playwright 选择器精化使浏览器点击与 DB 状态强一致。 + +## 相关链接 + +- Remediation: `.doc/remediation/2026-09-17-document-author-only-plan.md` U6 +- Short-term: `.doc/ops/short-term/2026-09-17-document-author-only-u6.md` +- Assets: `.doc/test-reports/assets/2026-09-17-document-author-only-u6/` +- 变更提案缺口: `.doc/audit/2026-09-17-docgov-modification-proposal-http-gap.md` diff --git a/.doc/test-reports/2026-09-17-rdev-template-depth-s1-continued-test-report.md b/.doc/test-reports/2026-09-17-rdev-template-depth-s1-continued-test-report.md new file mode 100644 index 00000000..3d0a0b8a --- /dev/null +++ b/.doc/test-reports/2026-09-17-rdev-template-depth-s1-continued-test-report.md @@ -0,0 +1,93 @@ +# rdev 模板深度补强 S1 续批(剩余 46 份)测试报告 + +## 一、 测试背景 (Background) + +S0+S1 首批已将阶段 01–05 共 14 份 `rd_*` 模板补强并合入;剩余 46 份(评审记录、阶段 06–13 硬件/软件/检验/验证/转换确认)仍为占位或中等档深度。本轮按用户确认范围全部按技术类同等深度补强,并抽样真 PI 打样收口。 + +## 二、 测试目标 (Test Objectives) + +- 46 份模板按写作规范补强:四段式章节、ID 表、「待确认」、`| RD-` 追溯、签署骨架;无「按本阶段专业结构写」 +- `rdevTemplateDepthAllowlist` 扩至全 60 份;`TestRDevTemplateDepthLint` PASS +- 真 PI 抽样:评审记录类 `rd_planning_review` + 验证协议类 `rd_design_vv` 终稿实质论证、无占位残留 +- `go vet ./...` 与 `go test -short ./...` 全绿 +- 附带修复:`UpsertPlanningInstance` 分配下一 `instance_no`(避免 main 已完成后无法再存 rdev 计划) + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|---|---| +| OS | Linux | +| 分支 / worktree | `feature/rdev-template-depth-s1-continued` / `.worktrees/feature-rdev-template-depth-s1-continued` | +| 后端 | `pi-agent` + MiniMax-M2.7-highspeed(shuimu-pi-agent-dev) | +| HTTP | `http://127.0.0.1:8080`(agent-dev) | +| 层级 | 模板 lint + 包回归 short + 真 PI 抽样 E2E | + +资产目录:[assets/2026-09-17-rdev-template-depth-s1-continued/](./assets/2026-09-17-rdev-template-depth-s1-continued/) + +### 用例矩阵 + +| 批 | 范围 | 份数 | 验证 | +|---|---|---|---| +| 1 | 评审记录 | 6 | lint | +| 2 | 阶段06 硬件/工艺 | 8 | lint | +| 3 | 阶段06 软件/检验/清单 | 9 | lint | +| 4 | 阶段08 验证 | 12 | lint + 真 PI `rd_design_vv` | +| 5 | 阶段10–13 转换/确认 | 11 | lint | +| 抽样 | 评审记录 | 1 | 真 PI `rd_planning_review` | + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| lint 全量 | 60 key allowlist 无占位、章节≥4、含追溯/待确认/签署 | +| `rd_planning_review` | 完成态;含「评审范围与对象 / 问题与决议 / 评审结论」;无占位;chars>400 | +| `rd_design_vv` | 完成态;含「目的与范围 / 方法与判定标准 / 风险与残余风险关联」;无占位;chars>400 | +| short 回归 | 全绿 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 模板 lint(全 60 份 allowlist) + +- **测试输入**:`go test ./internal/workflowdef/ -run TestRDevTemplateDepthLint -v -count=1` +- **实际输出**:`--- PASS: TestRDevTemplateDepthLint (0.00s)` +- **评价判定**:PASS +- **性能与耗时**:<1s + +### 5.2 真 PI — `rd_planning_review`(评审记录类) + +- **测试输入**:项目 `6446`(`rdev-s1c-pi-1789657210`),rdev 实例 `313`,阶段 `RD02_planning_review`,backend=`pi-agent` +- **实际输出**(摘自 [summary-s1-continued.json](./assets/2026-09-17-rdev-template-depth-s1-continued/summary-s1-continued.json) / [console.log](./assets/2026-09-17-rdev-template-depth-s1-continued/console.log)): + - `status=completed`,elapsed≈290s,chars=7958 + - `no_placeholder=true`,`has_review_sections=true` +- **终稿取样**:[rd_planning_review-final.md](./assets/2026-09-17-rdev-template-depth-s1-continued/rd_planning_review-final.md) +- **评价判定**:PASS +- **性能与耗时**:约 290s + +### 5.3 真 PI — `rd_design_vv`(验证协议类) + +- **测试输入**:同项目推进至 `RD08_verification` 后启动 +- **实际输出**: + - `status=completed`,chars=10970 + - `no_placeholder=true`,`has_protocol_sections=true` +- **终稿取样**:[rd_design_vv-final.md](./assets/2026-09-17-rdev-template-depth-s1-continued/rd_design_vv-final.md) +- **评价判定**:PASS +- **性能与耗时**:整段抽样脚本约 704s(含评审审批与阶段推进) + +### 5.4 运行时修复备注 + +worktree 重建后若缺少根 `.env`,会导致 `MINIMAX_API_KEY` / `PI_AGENT_RUNNER_AUTH_TOKEN` 为空,表现为 PI 空跑或 `typed result rejected`。本轮通过 symlink 根 `.env` 并 `--force-recreate` app/pi-agent 恢复;另提交 `instance_no` 递增修复以支持 main 完成后另存 rdev 计划。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```text +go test ./internal/workflowdef/ -run TestRDevTemplateDepthLint -v -count=1 +--- PASS: TestRDevTemplateDepthLint (0.00s) + +go test -short ./... +# 全包 PASS(含 internal/server、workflowdef、projectinstance 等) +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- **结论**:S1 续批达到合入标准——46 份模板深度补强完成,lint 覆盖 60/60,真 PI 双抽样 PASS。 +- **后续**:S2(per-stage 编排/配置化)、S3(overlay/专用门禁)、P2 前端、P3 三样本全量 E2E、P4 键名空间化仍属 backlog;本轮**不晋升** test/main。 diff --git a/.doc/test-reports/2026-09-17-rdev-template-depth-test-report.md b/.doc/test-reports/2026-09-17-rdev-template-depth-test-report.md new file mode 100644 index 00000000..f482081a --- /dev/null +++ b/.doc/test-reports/2026-09-17-rdev-template-depth-test-report.md @@ -0,0 +1,118 @@ +# rdev 模板深度补强(S0 + S1 首批)测试报告 + +## 一、 测试背景 (Background) + +`rdev` 60 份模板多数仍含「按本阶段专业结构写」通用占位;且 `rdevLeaderAgent` 虽注册了角色,但 `buildSubWorkspaces` 按角色名加载 `configs/agents/leader.md`,导致研发终审全部回落商务口吻。本轮按计划交付 B1 leader 注入修复、阶段 01–05 共 14 份模板专业章节补强,并以 dev 真实 PI 打样收口。 + +## 二、 测试目标 (Test Objectives) + +- rdev leader 子工作区 `AGENTS.md` 含「设计控制 / 研发设计控制负责人」,且不含「商务对接负责人」「CDO」 +- intake leader 回归仍含「商务对接负责人」 +- 14 份已补强模板 lint 绿灯(无通用占位、章节≥4、含追溯/待确认/签署) +- 真实 PI:`rd_design_input_spec` 终稿五节有实质论证;账本 `stage=RD03_design_input` +- `go vet ./...` 与 `go test -short` 全绿 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|---|---| +| OS | Linux | +| 分支 / worktree | `feature/rdev-template-depth` / `.worktrees/feature-rdev-template-depth` | +| 后端 | `pi-agent` + MiniMax(shuimu-pi-agent-dev) | +| HTTP | `http://127.0.0.1:8080`(agent-dev) | +| 层级 | 单元(prompt 注入 / lint)+ 包回归 short + 真 PI 端到端 | + +资产目录:[assets/2026-09-17-rdev-template-depth/](./assets/2026-09-17-rdev-template-depth/) + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| S0.1 PrepareWorkspace(rd_design_input_spec) | leader AGENTS 设计控制口径,无商务串味 | +| S0.1 PrepareWorkspace(intake) | leader AGENTS 仍含商务对接负责人 | +| S0.2 / S1 lint allowlist 14 份 | 无「按本阶段专业结构写」;`## `≥4;含 `\| RD-`、待确认、签署 | +| S0.3 main-only intake | 真 PI completed;文档 `stage=V0.1_triage`;实例 completed | +| S0.3 rdev-only DI | 真 PI completed;五节实质;`stage=RD03_design_input`;leader 无商务串味 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 S0.1 leader prompt 注入(单元) + +- **测试输入**: `go test -short ./internal/squad/ -run 'RDevLeader|LeaderPrompt|InstructionsFile' -count=1`(及全包 short) +- **实际输出**: `ok chat-agent-system/internal/squad`(见第六节汇总) +- **判定**: PASS — `GetAgentInstructionFile` + `InstructionsFile` 优先注入已覆盖;商务回归保留 + +### 5.2 模板 lint(S0.2 + S1 14 份) + +- **测试输入**: `go test -short ./internal/workflowdef/ -run RDevTemplateLint -count=1` +- **判定**: PASS — allowlist 覆盖 `rd_design_input_spec` + 批 1/2/3 共 14 份 + +### 5.3 真实 PI:main 仅 intake(项目 6424) + +- **测试输入**: 新建 medical_device 项目;规划期仅保留 `intake`;`backend=pi-agent`;异步 `POST /api/v1/tasks/runs` +- **实际输出**(摘自 [pi-intake-meta.json](./assets/2026-09-17-rdev-template-depth/pi-intake-meta.json)): + +```text +run_id=run_1789635054471569201 +Status=completed +WorkflowStageKey=V0.1_triage +WorkspaceID=/app/data/workspaces/bizdev/intake_1789635054485631143 +duration_ms=220109 +``` + +- 终稿取样:[intake-final.md](./assets/2026-09-17-rdev-template-depth/intake-final.md) +- intake leader(商务回归):[intake-leader-AGENTS.md](./assets/2026-09-17-rdev-template-depth/intake-leader-AGENTS.md) 含「商务对接负责人」 +- 实例推进至 completed:[main-instance-final.json](./assets/2026-09-17-rdev-template-depth/main-instance-final.json) +- **判定**: PASS +- **耗时**: ~220 s + +### 5.4 真实 PI:rdev 仅 rd_design_input_spec(项目 6425) + +- **测试输入**: 激活 `rdev`;规划期仅保留 `rd_design_input_spec`;推进至 RD03;同项目插入 completed `main` 实例以满足硬 precheck;真 PI 生成 +- **实际输出**(摘自 [summary-rdev.json](./assets/2026-09-17-rdev-template-depth/summary-rdev.json) / [pi-rd_design_input_spec-meta.json](./assets/2026-09-17-rdev-template-depth/pi-rd_design_input_spec-meta.json)): + +```text +run_id=run_1789635960880707641 +Status=completed +WorkflowStageKey=RD03_design_input +di_chars≈15988 +has_five_sections=true(## 一、功能要求 … ## 五、法规与标准符合性) +no_placeholder=true +agents_ok=true(研发设计控制负责人) +no_business_in_agents=true +no_business_in_final=true +wall≈450s(09:06–09:13Z) +``` + +- 终稿:[rd_design_input_spec-final.md](./assets/2026-09-17-rdev-template-depth/rd_design_input_spec-final.md) +- leader AGENTS:[rdev-leader-AGENTS.md](./assets/2026-09-17-rdev-template-depth/rdev-leader-AGENTS.md) 开篇为「研发/设计控制小队的负责人…不做报价核算与商务承诺」 +- 账本 stage:[rd_design_input_spec-docgov.json](./assets/2026-09-17-rdev-template-depth/rd_design_input_spec-docgov.json) → `"stage":"RD03_design_input"` +- **判定**: PASS(五节均有结论句 + 事实表 + 缺口标注;无通用占位;无商务/CDO 串味) +- **耗时**: ~450 s(小队墙钟);squad 产出约 5 min(organize→review) +- **备注**: 终稿章节标题为「## 一、功能要求」等形式(带中文序号),语义覆盖模板五节。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```text +$ go vet ./... +(无输出,exit 0) + +$ env -u DATA_DIR go test -short ./internal/squad/ ./internal/template/ ./internal/workflowdef/ +ok chat-agent-system/internal/squad 0.030s +ok chat-agent-system/internal/template 0.810s +ok chat-agent-system/internal/workflowdef 0.004s + +$ env -u DATA_DIR go test -short ./... +# 全包 PASS(exit 0;约 59s)。说明:宿主若 export 了 DATA_DIR=./data/dev, +# 会覆盖 bizdev-cli 测试 -env 内的临时目录,导致相对路径读不到 squad.json;门禁以 unset DATA_DIR 为准。 +# 另:本分支自带的 engineeringdashboard 包已补入 module-boundaries.yaml allowlist(随 rebase 丢弃 eng-dashboard 重复提交后可再收敛)。 +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- **结论**: S0 + S1 首批达到合并候选标准(代码+真 PI 取样+门禁)。leader 错配已修;14/60 模板已补强。 +- **后续**(见 [backlog](../backlog/2026-09-17-rdev-phase2-backlog.md)): + - S1 续批:阶段 06–13 剩余模板 + 中等档补指令 + - S2:per-stage 步骤 / regulatory×complexity_tier / 模型去硬编码 / manifest hint + - S3:轻量 overlay + R2/R3 门禁 + P3 三样本 E2E +- **合 dev 提示**: 依赖 `feature/rdev-core`;合入前对 rdev-core `rebase dev`,丢弃已进 dev 的 engineering-dashboard 重复提交。 diff --git a/.doc/test-reports/2026-09-18-wave0-p0-bug-fixes-test-report.md b/.doc/test-reports/2026-09-18-wave0-p0-bug-fixes-test-report.md new file mode 100644 index 00000000..565abb18 --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave0-p0-bug-fixes-test-report.md @@ -0,0 +1,135 @@ +# Wave 0 P0 Bug 修复测试报告 + +## 一、 测试背景 (Background) + +系统体检(2026-09-17)确认 SSE 聊天/任务流被全局 `WriteTimeout=120s` 强杀、`run/stream` 畸形 body 误启任务与 Flush 后失败、前端会话窗口缓存 key 不一致、IndexedDB 恢复覆盖新增交付物。本波次按整改方案 Wave 0 实施 B-1~B-6。 + +## 二、 测试目标 (Test Objectives) + +- B-1/B-2:`handleChatStream` / `handleRunTaskStream` 心跳 + `extendSSEWriteDeadline`,长静默不因 WriteTimeout 断流 +- B-3:畸形 JSON → HTTP 400,不开 SSE +- B-4:`CreateAndStart`/`Subscribe` 在 Flush 前;失败为普通 JSON 错误 +- B-5:`saveSessionWindowCache` 一律带 `userId`;LRU 驱逐完整 cache key +- B-6:`applyRestoredFileRaw` 函数式合并,不丢 mount 后新增 deliverable +- 质量门禁:`go vet` / `go test -short` / `npm test` / `npm run build` 全绿;dev rebuild + health/ready + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;Go 模块全量 `-short`;前端 `tsx --test` + vitest +- 后端 Stub 单测 + 真实 dev 容器(`pi-agent` + MiniMax-M2.7-highspeed) +- 分支:`fix/wave0-p0-sse-chat-cache-bugs` → 合入本地 `dev@810fb96`(后随 docs 再合) +- 资产:`.doc/test-reports/assets/2026-09-18-wave0-p0-bug-fixes/` + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| 畸形 run/stream body | 400,非 `text/event-stream` | +| CreateAndStart 注入失败 | 非 SSE Content-Type,正文含 start failure | +| 短 WriteTimeout + 心跳 | 流时长 > WriteTimeout 仍可读到 done | +| 缓存写路径 | 全部调用含 `userId`;超 LRU 驱逐带前缀 key | +| restore 合并 | prev 新增条目保留,仅补丁 fileRaw | +| 真实 PI 聊天 | 静默生成期出现 `: heartbeat`,最终 `event: done` | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 后端单测(`internal/server/sse_wave0_test.go`) + +**测试输入**:`go test -short -count=1 ./internal/server/ -run 'TestRunTaskStream_InvalidJSON|TestRunTaskStream_CreateAndStartFails|TestSSEHeartbeat_Helpers|TestRunTaskStream_ValidBody' -v` + +**实际输出(摘要)**: + +``` +--- PASS: TestRunTaskStream_InvalidJSON_Returns400 (0.14s) +--- PASS: TestRunTaskStream_CreateAndStartFails_BeforeSSEFlush (0.12s) +--- PASS: TestSSEHeartbeat_HelpersSurviveShortWriteTimeout (0.70s) +--- PASS: TestRunTaskStream_ValidBody_OpensSSEWithHeartbeatPath (0.17s) +PASS +``` + +**评价判定**:PASS +**性能与耗时**:约 1.1s 包级 + +### 5.2 前端单测 + +**测试输入**: + +``` +npx tsx --test src/hooks/useChatWindow.cacheKey.test.ts \ + src/data/storage.windowCache.test.ts src/data/fileStore.test.ts +``` + +**实际输出**: + +``` +✔ applyRestoredFileRaw: 以 prev 为权威… +✔ applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用 +✔ window cache LRU: 驱逐带 userId 前缀的完整 cache key… +✔ window cache: 读写同一 userId 前缀,跨用户不串读 +✔ useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId +ℹ tests 5 / pass 5 +``` + +**评价判定**:PASS + +### 5.3 全量回归 + +| 命令 | 结果 | +|------|------| +| `go vet ./...` | PASS | +| `go test -short -count=1 ./...` | 全包 ok(含 server) | +| `cd web && npm test` | 397 + 32 vitest PASS | +| `cd web && npm run build` | PASS(含 precompress) | + +### 5.4 dev 容器冒烟 + +**测试输入**:`./scripts/env-service.sh dev rebuild`;`curl -sf http://127.0.0.1:8080/health` 与 `/ready` + +**实际输出**: + +```json +{"status":"ok","env":"dev","agent_backend":"pi-agent","schema_version":52,"schema_ready":true,...} +``` + +**评价判定**:PASS + +### 5.5 真实 PI 聊天 SSE(心跳接线证据) + +**测试输入**:admin 登录后 `POST /api/v1/chat/stream`,长合规分析提示词;资产见 `assets/2026-09-18-wave0-p0-bug-fixes/chat-stream-long.sse.txt` + +**实际输出(关键片段)**: + +``` +event: phase +... +: heartbeat 1789663534 +: heartbeat 1789663549 +: heartbeat 1789663564 +: heartbeat 1789663579 +event: delta +... +event: done +data: {...,"done":{"duration_ms":70164,"status":"completed",...}} +``` + +**评价判定**:PASS(真 PI 回合约 70s;静默生成期 4 次心跳,流完整收尾)。 +**说明**:绝对墙钟 >120s 的真 PI 未再压测(二次加长提示触发 `SECURITY_FORBIDDEN` 快速返回);WriteTimeout 跨越由 `TestSSEHeartbeat_HelpersSurviveShortWriteTimeout`(WriteTimeout=200ms,流 700ms)覆盖,与线上心跳机制同源。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- Go:`go vet ./...` + `go test -short -count=1 ./...` → 全绿 +- Web:`npm test`(397 node:test + 32 vitest)+ `npm run build` → 全绿 +- 新增:`sse_wave0_test.go`、`useChatWindow.cacheKey.test.ts`、`storage.windowCache.test.ts`、`fileStore.test.ts` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- Wave 0(B-1~B-6)达到合入与冒烟标准,可关闭 short-term 任务。 +- 后续:Wave 1(E-1 main ENV)仍待决策;可选补一次刻意 >120s 的受控长任务(例如阻塞 executor 的 task run events)作为额外证据。 +- 红线未触碰:FAV / MULTI_INTENT_PLAN_TIER / SEMANTIC_SUMMARY_REPLACE / 项目 143。 + +## 相关链接 + +- Audit: [../audit/2026-09-17-system-health-check.md](../audit/2026-09-17-system-health-check.md) +- Remediation: [../remediation/2026-09-17-system-health-remediation-plan.md](../remediation/2026-09-17-system-health-remediation-plan.md) +- Short-term: [../ops/short-term/2026-09-18-wave0-p0-bug-fixes.md](../ops/short-term/2026-09-18-wave0-p0-bug-fixes.md) +- Assets: [./assets/2026-09-18-wave0-p0-bug-fixes/](./assets/2026-09-18-wave0-p0-bug-fixes/) diff --git a/.doc/test-reports/2026-09-18-wave1-main-env-fix-test-report.md b/.doc/test-reports/2026-09-18-wave1-main-env-fix-test-report.md new file mode 100644 index 00000000..163177cd --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave1-main-env-fix-test-report.md @@ -0,0 +1,100 @@ +# Wave 1:main 环境 ENV/schema 修正测试报告 + +## 一、 测试背景 (Background) + +体检确认 main 环境 app `ENV=prod` 与 events `ENV=main` 不一致(E-1 critical),schema 落后且晋升缺强制顺序(E-2),以及 `Env==""` 视同 dev 放开导入端点(E-5)。 + +## 二、 测试目标 (Test Objectives) + +- app/events health 均报 `env=main` +- events 先 healthy 后启 app;goose 迁移后 `schema_ready=true`(实测 schema_version=52) +- `event_consumer_modes` 中 main 四行仍为 `off` +- 空 Env 导入端点 403;compose app/events ENV 一致单测 PASS + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 分支:`fix/wave1-main-env-fix` → 合入本地 `dev` +- 真实 main:`./scripts/env-service.sh main rebuild`(注意:执行前须清掉 shell 内残留的 `source .env.dev` 覆盖变量,否则端口会串到 8080/18865) +- 单测:`internal/config/compose_env_consistency_test.go`、`TestServer_ChatWindow_ImportGuard_EmptyEnvFailsClosed` + +## 四、 预期结果 (Expected Results) + +| 项 | 预期 | +|----|------| +| events `/health` | `env=main` | +| app `/health` `/ready` | `env=main`,`schema_ready=true` | +| consumer modes | main 四行 `off` | +| settings / projects | HTTP 200,无 SQL 缺列错误 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 单测 + +``` +--- PASS: TestComposeAppEventsENVAligned +--- PASS: TestComposeMainAppDependsOnEventsHealthy +--- PASS: TestServer_ChatWindow_ImportGuard +--- PASS: TestServer_ChatWindow_ImportGuard_EmptyEnvFailsClosed +``` + +**判定**:PASS + +### 5.2 main rebuild 启动顺序 + +日志摘要(`/tmp/wave1-main-rebuild3.txt`): + +``` +Container shuimu-agent-events-main Started +Container shuimu-agent-events-main Waiting +Container shuimu-agent-events-main Healthy +Container shuimu-agent-app-main Starting +Container shuimu-agent-app-main Started +``` + +**判定**:PASS(E-2 depends_on 生效) + +### 5.3 health / schema / modes + +**events**:`{"env":"main","owner":"events-1","status":"ok"}` +**app**:`{"status":"ok","env":"main",...,"schema_version":52,"schema_ready":true}` +**modes**:main 的 fav/notify/projection/workflow_advance 均为 `off` revision=1 +**goose**:`version_id=52` + +资产:`.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/` + +**判定**:PASS(E-1 对齐;schema 由 47 升至 52;红线未改 mode) + +### 5.4 冒烟 + +| 调用 | 结果 | +|------|------| +| POST `/api/v1/auth/login` | login_ok | +| GET `/api/v1/admin/system/settings` | 200(system_settings 可读) | +| GET `/api/v1/projects` | 200 `[]` | +| GET `/api/v1/admin/system/overview` | 200,`env=main`,`schemaVersion=52` | + +**判定**:PASS + +### 5.5 附带修复说明 + +main `events` 在 `ENV=main` 下原先因 `validateProductionSecrets` 缺 KB/admin 口令而持续 Restarting(Wave 1 前已存在)。为使 `depends_on events healthy` 可落地,向 events 注入 `POSTGRES_SHARED_KB_DSN` / `ADMIN_PASSWORD` / `ALLOW_IN_MEMORY=false` 并挂入 `shared-net`。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `go vet ./...` PASS +- `go test -short ./internal/config/`、`./internal/server/` Wave 1 相关用例 PASS +- 全量 `go test -short ./...`:`cmd/bizdev-cli` 因本机 `mkdir data/dev/data: permission denied` 失败(环境权限,与本波次无关);其余包 ok + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +Wave 1(E-1/E-2/E-5)达到关闭标准。建议后续: + +1. Wave 2 配置声明对齐 +2. `env-service.sh` 在调用 compose 前 unset 易串扰的端口类变量,避免 shell 污染 +3. 评估是否把 events 的生产密钥注入同步到文档 checklist + +## 相关链接 + +- Short-term: [../ops/short-term/2026-09-18-wave1-main-env-fix.md](../ops/short-term/2026-09-18-wave1-main-env-fix.md) +- Remediation: [../remediation/2026-09-17-system-health-remediation-plan.md](../remediation/2026-09-17-system-health-remediation-plan.md) +- Assets: [./assets/2026-09-18-wave1-main-env-fix/](./assets/2026-09-18-wave1-main-env-fix/) diff --git a/.doc/test-reports/2026-09-18-wave2-config-flag-align-test-report.md b/.doc/test-reports/2026-09-18-wave2-config-flag-align-test-report.md new file mode 100644 index 00000000..421da75a --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave2-config-flag-align-test-report.md @@ -0,0 +1,104 @@ +# Wave 2:配置与灰度入口对齐测试报告 + +## 一、 测试背景 (Background) + +体检确认封存 flag(`MULTI_INTENT_PLAN_TIER`、U9–U12、SHADOW/NARRATIVE)未写入 compose `environment`,`--env-file` 无法传入容器(E-3);`CURSOR_RUNNER_URL`/cursor-agent 零消费占位(E-4);main 未显式关闭 demo 播种(E-6);`INTENT_V2_DECISION_TIER` 非法值激进回退 `query`(E-7);main initdb 与 test 脚本形态差异需评估(E-9)。 + +## 二、 测试目标 (Test Objectives) + +- 三环境 compose 渲染与容器 env 均可见目标 flag,默认 `legacy`/`false` +- `CURSOR_RUNNER*` 与 `cursor-agent` 容器消失 +- main `SEED_DEMO_USERS=false` +- 非法 `INTENT_V2_DECISION_TIER` fail-closed 到 `none` +- E-9:记录评估结论,不改 initdb +- `go vet` / `go test -short` 全绿;dev/test/main health+ready PASS + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 分支:`fix/wave2-config-flag-align`(自本地 `dev@e35f106`) +- 单测:`internal/config/compose_flag_declaration_test.go`、`TestUnknownV2DecisionTierFailsClosedToNone` +- 真实环境:`./scripts/env-service.sh {dev,test,main} rebuild`(执行前 unset shell 泄漏端口变量) +- 证据目录:`.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/` + +## 四、 预期结果 (Expected Results) + +| 项 | 预期 | +|----|------| +| compose config | 三环境 `MULTI_INTENT_PLAN_TIER=legacy` + U9–U12/SHADOW/NARRATIVE=false;无 CURSOR_RUNNER | +| 容器 env | 同上;main 另有 `SEED_DEMO_USERS=false` | +| cursor-agent | `docker ps` 无匹配 | +| health/ready | 三环境 ok,schema_ready=true | +| 行为 | 不升 tier、不接线 REPLACE、不改 FAV | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 单测 + +``` +ok chat-agent-system/internal/intent +ok chat-agent-system/internal/config +--- PASS: TestComposeAppDeclaresMultiIntentPlanTierAndExpansionFlags +--- PASS: TestComposeAppDeclaresSemanticSummaryAndNarrativeShadowFlags +--- PASS: TestComposeMainSeedDemoUsersExplicitFalse +--- PASS: TestComposeNoDeadCursorRunnerConfig +--- PASS: TestUnknownV2DecisionTierFailsClosedToNone +``` + +**判定**:PASS + +### 5.2 compose config 渲染 + +见 `assets/.../compose-config-flags.txt`。三环境均含目标 flag;`CURSOR_RUNNER` 零匹配。 + +**判定**:PASS + +### 5.3 容器 env 抽检 + +| 环境 | 关键输出 | 判定 | +|------|----------|------| +| dev | `MULTI_INTENT_PLAN_TIER=legacy` … `SEMANTIC_SUMMARY_SHADOW=false`(无 CURSOR_RUNNER) | PASS | +| test | 同上 + `SEED_DEMO_USERS=false` | PASS | +| main | 同上 + `SEED_DEMO_USERS=false` | PASS | + +### 5.4 冒烟 health/ready + +| 环境 | health | ready | 判定 | +|------|--------|-------|------| +| dev `:8080` | env=dev schema=52 | ok | PASS | +| test `:8081` | env=test schema=52 | ok | PASS | +| main `:8000` | env=main schema=52 | ok | PASS | + +main rebuild 日志确认 `events` Healthy 后才 Starting `app`(E-2 依赖仍生效)。 + +### 5.5 cursor-agent 清理 + +``` +cursor-agent 容器已清理,符合预期 +``` + +**判定**:PASS + +### 5.6 E-9 评估结论(本波次不改代码) + +| 环境 | initdb 挂载 | 结论 | +|------|-------------|------| +| dev | `deploy/postgres/context_init.sql` | 与 main baseline 内容一致(仅差 goose 头注释) | +| test | `deploy/postgres/extensions_init.sql` | 仅扩展,表全靠 goose | +| main | `deploy/postgres/goose/00001_baseline.sql` | 与 dev 实质一致;无「落后」 | + +**决策**:不收敛 initdb(零行为变化波次);双文件漂移风险记入后续 backlog,需空卷冷启动专项验证后再改。 + +**判定**:评估完成 / 不改代码 — PASS(按计划) + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go vet ./... # 通过 +go test -short -count=1 ./... # 全绿(见 assets/go-test-short.txt) +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- Wave 2 达到合并标准:声明对齐完成,默认行为不变。 +- 下一波 Wave 3:测试债与盲区(S-1/S-2/S-4/S-5)。 +- E-9 结构性收敛(dev/main → extensions-only)另开任务。 diff --git a/.doc/test-reports/2026-09-18-wave3-test-debt-blindspots-test-report.md b/.doc/test-reports/2026-09-18-wave3-test-debt-blindspots-test-report.md new file mode 100644 index 00000000..167dd20e --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave3-test-debt-blindspots-test-report.md @@ -0,0 +1,82 @@ +# Wave 3:测试债与盲区测试报告 + +## 一、 测试背景 (Background) + +体检确认 `TestReconcile_LegacyRepair_PG` 从未在隔离 PG 上执行(S-1);两处永久跳过(S-2);E2E 9 个 spec 仅 1 个有 npm 脚本且存在 `DOCREV_E2E_BASE_URL` 双口径(S-4);前端多处 API 对 409/结构化错误裸抛 Error(S-5)。 + +## 二、 测试目标 (Test Objectives) + +- 隔离 PG(5436) 跑通 LegacyRepair 一次并归档 +- 删除死跳过;plan_owner 重定向说明补齐 +- `playwright test --list` = 29 tests / 8 files;每 spec 有脚本 +- projectMembers/freeformDoc/taskRuns/chatSessions 复用 `projectSettingsError` 并有负例单测 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 分支:`fix/wave3-test-debt-blindspots` ← 本地 `dev@423f7e7` +- 隔离 PG:`pgvector/pgvector:pg16` 映射 5436,测完销毁并 `unset TEST_DATABASE_URL` +- 前端:`npm test` / `npm run build` / `npx playwright test --list` +- 证据:`.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/` + +## 四、 预期结果 (Expected Results) + +| 项 | 预期 | +|----|------| +| S-1 | goose→52 + `PASS: TestReconcile_LegacyRepair_PG` | +| S-2 | TaskStream 3 PASS 0 SKIP;plan_owner 仍 SKIP 但注释说明导入环 | +| S-4 | 29 tests / 8 files;无 `DOCREV_E2E_BASE_URL` | +| S-5 | 相关 API 单测 PASS;`npm test` 全绿 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 S-1 隔离 PG + +见 `assets/.../go-test-reconcile-pg.txt`: + +``` +goose: successfully migrated database to version: 52 +--- PASS: TestReconcile_LegacyRepair_PG (0.37s) +``` + +**判定**:PASS + +### 5.2 S-2 + +``` +--- SKIP: TestTaskGenerateRealPIStopGate(redirect 说明已补) +--- PASS: TestTaskStream_TransientInjection_DeadPiAgent +--- PASS: TestTaskStream_SoftFailure_ClassifiedResumable +--- PASS: TestTaskStream_MaterialsInjection +``` + +**判定**:PASS + +### 5.3 S-4 + +``` +Total: 29 tests in 8 files +``` + +**判定**:PASS(退役 `document-revision.spec.ts`) + +### 5.4 S-5 + +`npm test`:402 pass + vitest 32 pass;含 DOC_BUSY / CONCURRENCY_CONFLICT / members / chatSessions 负例。 + +**判定**:PASS + +### 5.5 全量回归 + +`go vet ./...` + `go test -short ./...` 全绿(见 `go-test-short.txt`)。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- Go short:全绿 +- Web:`npm test` 全绿;`npm run build` 成功 +- Playwright list:29/8 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- Wave 3 达到合并标准;gap-matrix DOC-CONTENT 工程测试债关闭。 +- 下一波 Wave 4:隐患加固 R-1~R-8。 +- 后端 `error_code`→统一 `httpx.Write` 仍属独立 remediation,不在本波。 diff --git a/.doc/test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md b/.doc/test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md new file mode 100644 index 00000000..18e9595d --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md @@ -0,0 +1,118 @@ +# Wave 4:隐患加固(R-1~R-8)测试报告 + +## 一、 测试背景 (Background) + +系统体检(2026-09-17)确认高风险隐患 R-1~R-8:会话跨用户挂靠、`rows.Err()` 漏检、dispatcher 无锁读、`memTurns` 全局只增不删、JSONB 静默零值化、后台 ticker 无 Shutdown、SSE 401/404 盲目重连与登出未 abort 在途流。Wave 0–3 已合入后按 Sonnet 计划默认决策实施本波。 + +## 二、 测试目标 (Test Objectives) + +- R-1:跨用户 chat / chat/stream / chat/sessions 全部 403;受害者续用 200;Memory + PG 双实现 +- R-2:18 文件补齐 `rows.Err()`;B 类 slog.Warn +- R-3:`-race` 下 events 无竞态 +- R-4:实例隔离 + TTL/容量淘汰测试 PASS +- R-5:损坏/类型错误 JSONB 显式报错 +- R-6:Shutdown 可停止 reminder/vault;Drain 可告警;bizdev 心跳超时 +- R-7/R-8:401/404 fail-fast(calls===1);单帧容错;abortAllSessions +- 全量:`go vet` / `go build` / `go test -short` / `npm test` / `npm run build` + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 分支:`fix/wave4-hardening-r1-r8` ← 本地 tip `32932e4` +- 隔离 PG:`pgvector/pgvector:pg16` → `127.0.0.1:5436`,测完 `unset TEST_DATABASE_URL` + `docker stop wave4-pg` +- OS:Linux;Go 模块本仓;前端 Vite/Node test runner +- 证据:`.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/` + +## 四、 预期结果 (Expected Results) + +| 项 | 预期 | +|----|------| +| R-1 Memory/PG | attacker 403 SESSION_FORBIDDEN;stream 非 SSE;victim 续用 OK | +| R-2 | `go vet` 绿;18 文件循环后可见 `rows.Err()` | +| R-3 | `go test -race -short ./internal/events/...` 绿 | +| R-4 | ChatTurn 四测 PASS;无全局 `memTurns` 重置 | +| R-5 | hydrate 错误含 `hydrateDocLocked` + `block_map` | +| R-7 | 401/404 各仅 1 次 fetch;畸形帧跳过后仍解析下一帧 | +| 全量 | short/npm/build EXIT:0 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 R-1 跨用户会话挂靠 + +**输入**:受害者建立 `sess-hijack-*` 后攻击者同 session_id 调 chat / stream / upsert。 + +**实际输出**(`ownership-pg.txt`): + +``` +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_PG +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_Memory +``` + +**判定**:PASS(附带清除 NewSession `ProjectID=1` 占位,避免 PG 误走项目鉴权) + +### 5.2 R-2 rows.Err() + +**实际输出**:`go-vet.txt` EXIT:0;静态补齐 18 文件。 + +**判定**:PASS + +### 5.3 R-3 / R-4 race + +**实际输出**(`go-race.txt`): + +``` +ok chat-agent-system/internal/events +ok chat-agent-system/internal/ctxmgr +EXIT:0 +``` + +含 `TestAcquireChatTurn_StaleEntriesPruned` PASS。 + +**判定**:PASS + +### 5.4 R-5 hydrate JSONB + +**实际输出**(`hydrate-corrupt-pg.txt`): + +``` +--- PASS: TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG +``` + +**判定**:PASS + +### 5.5 R-6 + +代码级:`Shutdown` close `reminderStop`/`vaultStop`;vault Drain → `vault_drain_failed`;bizdev 心跳/释放 10s timeout。随 `go test -short ./internal/server/... ./internal/bizdev/...` 回归。 + +**判定**:PASS + +### 5.6 R-7 / R-8 前端 + +**实际输出**(`npm-test.txt`):tests 406 pass;vitest 32 pass;含 401/404 fail-fast 与畸形帧容错、abortAllSessions。 + +**判定**:PASS + +### 5.7 全量回归 + +- `go-vet.txt` / `go-build.txt` / `go-test-short.txt`:EXIT:0 +- `npm-build.txt`:EXIT:0 + +**判定**:PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| `go vet ./...` | PASS | +| `go build ./...` | PASS | +| `go test -short -count=1 ./...` | PASS | +| `go test -race -short ./internal/events/... ./internal/ctxmgr/...` | PASS | +| `cd web && npm test` | PASS(406 + 32) | +| `cd web && npm run build` | PASS | +| 隔离 PG R-1/R-5 | PASS 后销毁容器 | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- Wave 4 R-1~R-8 达到合入本地 `dev` 标准。 +- 未改 D-4/D-5(`handleGetProject` 鉴权、`handleRunTaskStream` 归属);记入后续 backlog。 +- 红线自查:未触碰 FAV modes / MULTI_INTENT_PLAN_TIER / SEMANTIC_SUMMARY_REPLACE / 项目 143。 +- 下一波:Wave 5 死代码决策(X-1~X-5)。 diff --git a/.doc/test-reports/2026-09-18-wave5-deadcode-decisions-test-report.md b/.doc/test-reports/2026-09-18-wave5-deadcode-decisions-test-report.md new file mode 100644 index 00000000..a8fa35ce --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave5-deadcode-decisions-test-report.md @@ -0,0 +1,85 @@ +# Wave 5 死代码决策(X-1~X-5)测试报告 + +## 一、 测试背景 (Background) + +系统体检整改 Wave 0–4 已合入本地 `dev@a12c947`。Wave 5 对死 flag、封存 REPLACE、00048 三表、system_settings 未接线键、前端死组件做逐项决策并收口。决策落点:[short-term](../ops/short-term/2026-09-18-wave5-deadcode-decisions.md)。 + +## 二、 测试目标 (Test Objectives) + +- X-1:删除 `SEMANTIC_FALLBACK_ENABLED` 且回归测试防复活。 +- X-2:`SEMANTIC_SUMMARY_REPLACE` 维持不接线,注释回链决策记录。 +- X-3:00048 三表 deferred → SWBOOT-10/W5b(不改 schema)。 +- X-4:6 个未接线 system_settings 键保留并注释二期。 +- X-5:删除死组件/死 API 后前端构建与测试全绿。 +- 门禁:`go vet ./...`、`go test -short ./...`、`cd web && npm test && npm run build`。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;Go `go1.27.1 linux/amd64`;Node `v26.8.2`;npm `11.19.1`。 +- 层级:单元/包测试(`-short`)+ 前端 `tsx --test` / vitest + `tsc && vite build`。 +- 未做容器 rebuild / 真 PI E2E(本波为死代码删除与注释,无运行时行为变更)。项目 143 只读,未触碰。 +- 证据目录:`.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/`。 + +## 四、 预期结果 (Expected Results) + +| 项 | 判定 | +|----|------| +| X-1 | compose/代码无 `SEMANTIC_FALLBACK_ENABLED`(回归测试字符串除外);`TestComposeNoDeadSemanticFallbackConfig` PASS | +| X-2 | REPLACE 仍不替换 narrative;diff 仅注释 | +| X-3 | schema 不变;swboot 测试绿 | +| X-4 | 白名单键仍在;sysadmin 测试绿 | +| X-5 | `npm run build`(含 tsc)绿;死符号无引用 | +| 全量 | vet 无输出;`go test -short` 无 FAIL;npm test 0 fail | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 X-1 回归测试 + +- **测试输入**:`go test -short ./internal/config/...`(含于全量 `-short`) +- **实际输出**:`ok chat-agent-system/internal/config`(见 assets `go-test-short.txt`) +- **评价判定**:PASS。`SEMANTIC_FALLBACK_ENABLED` 仅剩回归测试断言字符串。 +- **性能与耗时**:config 包约 0.x s(全量 short 合计约 16s) + +### 5.2 X-2 / X-3 / X-4 包测试 + +- **测试输入**:`go test -short ./internal/ctxmgr/... ./internal/swboot/... ./internal/sysadmin/...`(含于全量) +- **实际输出**: + - `ok chat-agent-system/internal/ctxmgr`(cached/ok) + - `ok chat-agent-system/internal/swboot 0.002s` + - `ok chat-agent-system/internal/sysadmin 0.003s` +- **评价判定**:PASS +- **性能与耗时**:毫秒级 + +### 5.3 X-5 前端 + +- **测试输入**:`cd web && npm test && npm run build` +- **实际输出**: + - tsx:`tests 406 / pass 406 / fail 0`(`duration_ms 903.526321`) + - vitest:`Test Files 3 passed (3)` / `Tests 32 passed (32)` / `Duration 533ms` + - build:`tsc && vite build` → `✓ 4123 modules transformed`;`precompress: wrote 49 .br and 49 .gz` +- **评价判定**:PASS +- **性能与耗时**:npm test + build 合计约 18s + +### 5.4 全量 Go + +- **测试输入**:`go vet ./...`;`go test -short ./...` +- **实际输出**:vet 空文件(exit 0);short 测试 `ok` 计数 56,无 `FAIL`/`panic` +- **评价判定**:PASS +- **性能与耗时**:vet ~2s;short ~16s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | 证据 | +|------|------|------| +| `go vet ./...` | PASS(exit 0,无输出) | [go-vet.txt](./assets/2026-09-18-wave5-deadcode-decisions/go-vet.txt) | +| `go test -short ./...` | PASS(56 ok,无 FAIL) | [go-test-short.txt](./assets/2026-09-18-wave5-deadcode-decisions/go-test-short.txt) | +| `cd web && npm test` | PASS(406+32,fail 0) | [npm-test.txt](./assets/2026-09-18-wave5-deadcode-decisions/npm-test.txt) | +| `cd web && npm run build` | PASS | [npm-build.txt](./assets/2026-09-18-wave5-deadcode-decisions/npm-build.txt) | + +时间:2026-09-18T04:01:27Z。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +达到合入本地 `dev` 标准。红线未触碰:REPLACE 不接线、不升 `MULTI_INTENT_PLAN_TIER`、不改 FAV、项目 143 只读。 + +后续:Wave 6 文档漂移(D-1~D-6);SWBOOT-10/W5b 与 system_settings 二期接线仍 deferred,不在本波。 diff --git a/.doc/test-reports/2026-09-18-wave6-doc-drift-test-report.md b/.doc/test-reports/2026-09-18-wave6-doc-drift-test-report.md new file mode 100644 index 00000000..964ed5b3 --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave6-doc-drift-test-report.md @@ -0,0 +1,93 @@ +# Wave 6 文档漂移修复(D-1~D-6)测试报告 + +## 一、 测试背景 (Background) + +系统体检审计 [`.doc/audit/2026-09-17-system-health-check.md`](../audit/2026-09-17-system-health-check.md) §1.5 记录文档漂移 D-1~D-6:PU8 契约状态未回标、modular-monolith ADR 仍为提议、INDEX 末行需复核、意图双标签 handoff 正文未回标、backlog D3/D7 滞后、`PROJECT.md` §9「剩余 blocker」与 gap-matrix `production_ready=yes` 矛盾。整改方案 Wave 6 要求逐项回链关闭。基线为本地 `dev@2ce2625`(Wave 5 已合入)。 + +## 二、 测试目标 (Test Objectives) + +- D-1~D-6 逐项关闭,漂移清单可回链。 +- `audit/INDEX.md`、`remediation/INDEX.md`、`FEATURE-MATRIX.md` 三方口径一致(D-3 预期零改动)。 +- 零代码改动:`go build ./...`、`go vet ./...`、`cd web && npm run build` 全绿。 +- 不升 tier、不接线 `SEMANTIC_SUMMARY_REPLACE`、不改 FAV、无新增发布 blocker。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;Go `go1.27.1 linux/amd64`;Node `v26.8.2`;npm `11.19.1`。 +- 层级:文档 diff 复核 + 编译/vet/前端构建防误伤。未做容器 rebuild 与真 PI E2E(本波只改 `.doc/**` 与 `PROJECT.md`)。 +- 证据目录:`.doc/test-reports/assets/2026-09-18-wave6-doc-drift/`。 +- 时间:2026-09-18T04:11:26Z(INDEX 复核)至 2026-09-18T04:11:41Z(go)/ npm build 约 16s。 + +## 四、 预期结果 (Expected Results) + +| 项 | 判定 | +|----|------| +| D-1 | 四份 PU8 契约 `status: done`、`updated: 2026-09-18`;两份 short-term 在 `.doc/archive/ops/` | +| D-2 | ADR「状态」为「已接受」并附 Phase 1/2 证据链接 | +| D-3 | 两个 INDEX 末行保持「U2 已验收,主 JS ~624 KB;U1/U3–U7 待实施」,零改动 | +| D-4 | handoff 末尾追加「回标」小节;历史正文保留;frontmatter 仍 `closed` | +| D-5 | backlog 仅 D3/D7 两行改写并回链 | +| D-6 | `PROJECT.md` §9 写 `production_ready=yes`、无剩余发布 blocker | +| 防误伤 | `go build` / `go vet` / `npm run build` exit 0 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 D-1 契约回标与归档 + +- **测试输入**:frontmatter diff;`git mv` 两份已 done short-term。 +- **实际输出**:四份契约 `status: done` / `updated: 2026-09-18`。`git` rename 100%:`.doc/archive/ops/2026-09-16-pu8-u9-u12-flags.md`、`.doc/archive/ops/2026-09-17-w4-multi-intent-u9-u12.md`。 +- **评价判定**:PASS。正文与 flag 未改。 +- **性能与耗时**:文档编辑,可忽略。 + +### 5.2 D-2 ADR + +- **测试输入**:`.doc/adr/2026-09-14-modular-monolith-decoupling.md`「状态」小节。 +- **实际输出**:`已接受(2026-09-14 提议;2026-09-18 确认——Phase 1 纵向绞杀与 Phase 2 持久事件交付已落地并验收,见 gap-matrix DOC-RELIABILITY-PILOT PASS…)`。 +- **评价判定**:PASS。决策正文未改。 +- **性能与耗时**:可忽略。 + +### 5.3 D-3 INDEX 复核 + +- **测试输入**:`tail -n 1` audit/INDEX 与 remediation/INDEX。 +- **实际输出**(见 [index-last-lines.txt](./assets/2026-09-18-wave6-doc-drift/index-last-lines.txt)): + - audit:`- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-audit.md)(三入口重启首访;U2 已验收,主 JS ~624 KB;U1/U3–U7 待实施)。` + - remediation:同表述,指向 `-plan.md`。 +- **评价判定**:PASS,无改动。 +- **性能与耗时**:可忽略。 + +### 5.4 D-4 handoff 回标 + +- **测试输入**:`.doc/ops/handoffs/2026-09-15-conversation-intent-dual-tab.md` 末尾追加。 +- **实际输出**:新增「回标(2026-09-18,Wave 6 文档漂移修复)」;「未完成」与认领表原文保留。 +- **评价判定**:PASS。 +- **性能与耗时**:可忽略。 + +### 5.5 D-5 backlog + +- **测试输入**:`.doc/backlog/chat-agent-p6-backlog.md` D3/D7 两行。 +- **实际输出**:D3 标已实现(shadow)并链接 FEATURE-MATRIX / PU6 / W2 报告;D7 链接 ADR 与 W7 盘点,合规签字结论未改。B5/A5/A6/A7/D5 未动。 +- **评价判定**:PASS。FEATURE-MATRIX「语义摘要(D3)」为 done、REPLACE 未启用;「对话数据治理(D7)」为 deferred / `pending-compliance-signoff`。交叉检查无矩阵改动。 +- **性能与耗时**:可忽略。 + +### 5.6 D-6 PROJECT.md §9 + +- **测试输入**:`PROJECT.md` 第 9 节「剩余 blocker」行。 +- **实际输出**:改为「发布门禁:`production_ready=yes`……**无剩余发布 blocker**」;历史 P0 报告链接保留为「历史里程碑」。 +- **评价判定**:PASS。与 gap-matrix 不再矛盾。 +- **性能与耗时**:可忽略。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | 证据 | +|------|------|------| +| `go build ./...` | PASS(exit 0,无输出) | [go-build.txt](./assets/2026-09-18-wave6-doc-drift/go-build.txt) | +| `go vet ./...` | PASS(exit 0,无输出) | [go-vet.txt](./assets/2026-09-18-wave6-doc-drift/go-vet.txt) | +| `cd web && npm run build` | PASS(`✓ 4123 modules transformed`;`precompress: wrote 49 .br and 49 .gz`) | [npm-build.txt](./assets/2026-09-18-wave6-doc-drift/npm-build.txt) | + +时间:2026-09-18T04:11:39Z 起;go build+vet 约 2s(缓存命中);npm build 约 16s。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +D-1~D-6 全部关闭,remediation Wave 6 行与验收勾选已回链,CURRENT 焦点/认领/`doc-drift` mutex 与槽位 idle 一致。达到合入本地 `dev` 标准。无新增 blocker。 + +后续:Wave 7(SWBOOT-10、FAV live 放量、D7 合规签字)仍 deferred,需独立授权,不在本波。 diff --git a/.doc/test-reports/2026-09-18-wave7-roadmap-orchestration-test-report.md b/.doc/test-reports/2026-09-18-wave7-roadmap-orchestration-test-report.md new file mode 100644 index 00000000..d22243ec --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave7-roadmap-orchestration-test-report.md @@ -0,0 +1,65 @@ +# Wave 7 既有 roadmap 编排确认 测试报告 + +## 一、 测试背景 (Background) + +系统体检整改 Wave 7 定义为「既有 roadmap 收口(不改优先级,仅确认编排)」,不是把 deferred 能力做完。基线为本地 `dev@a88ed85`(`merge: docs/wave6-doc-drift`)。Wave 6 的 D-1~D-6 已 PASS,本波跳过步骤 0,不改 backlog。 + +## 二、 测试目标 (Test Objectives) + +- 六条编排确认均含当前状态、关闭条件、是否需授权、下一触发点。 +- 零代码:不改 Go/TS/SQL/compose。 +- 红线未动:不实施 SWBOOT-10、不升 FAV、不删 D7 数据、不升 `MULTI_INTENT_PLAN_TIER`、不接线 `SEMANTIC_SUMMARY_REPLACE`、不改 FEATURE-MATRIX 状态列、不合并/删除 `feature/control-center-server`。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux。文档波次,无容器、无真 PI、无 stub 验收数据。 +- 核验:`git diff --name-only a88ed85..HEAD` 仅 `.doc/**`;人工核对六条确认字段与 remediation/CURRENT 回链。 +- 时间:2026-09-18。 + +## 四、 预期结果 (Expected Results) + +| 项 | 判定 | +|----|------| +| 六条确认 | T1/W5、T1/W6、T1/W7、T2、T4、T5/T6/T8 均落盘,且无一项标成已实现 | +| T2 | 只记录双实现事实与建议;分支仍在 | +| 防误伤 | diff 不含 `.go` / `.ts` / `.tsx` / `.sql` / compose | +| SSOT | remediation Wave 7 = done;CURRENT 焦点 Wave 0–7 done;槽位 idle | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 六条确认落盘 + +- **测试输入**:short-term `.doc/ops/short-term/2026-09-18-wave7-roadmap-orchestration.md`。 +- **实际输出**:六节均含「当前状态 / 关闭条件 / 是否需用户授权 / 建议下一触发点」。T1/W5、T1/W6、T1/W7 标需要授权;T2 标需要用户裁决取舍;T4/T5/T8 不需专项授权但未开工。 +- **评价判定**:PASS。 +- **性能与耗时**:文档编辑,可忽略。 + +### 5.2 零代码与红线 + +- **测试输入**:`git diff --name-only a88ed85..HEAD`(确认提交之后、本报告提交之前)。 +- **实际输出**: + +```text +.doc/ops/CURRENT.md +.doc/ops/short-term/2026-09-18-wave7-roadmap-orchestration.md +.doc/remediation/2026-09-17-system-health-remediation-plan.md +``` + +按扩展名过滤 `.go|.ts|.tsx|.sql|.yml|.yaml` 结果为空(`no code files`)。backlog、FEATURE-MATRIX、compose 未出现在 diff 中。 +- **评价判定**:PASS。未跑 `go test` / 容器冒烟:本波无代码,容器验证不适用。 +- **性能与耗时**:`git diff` < 1s。 + +### 5.3 Control Center 分支未动 + +- **测试输入**:`git rev-list --count` 相对 `dev@a88ed85`;`git branch` 仍含 `feature/control-center-server`。 +- **实际输出**:merge-base `adb48c8`;落后 136 commits;自身 11 commits;分支未删除、未合并。 +- **评价判定**:PASS。仅记录事实与建议。 +- **性能与耗时**:可忽略。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +未执行 `go test -v ./...`。理由:相对 `a88ed85` 的变更仅三份 Markdown(另加本报告),无编译单元。防误伤证据为上一节 `git diff --name-only` 与扩展名过滤为空。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +达到本波「编排确认」合并标准:文档已落盘,能力状态未伪装完成,红线未动。后续触发点仍是用户授权(SWBOOT-10 / FAV live / D7 签字 / Control Center 取舍)或收益认领(T4 U1、T5、T6、T8),不在本波实施。未 push。 diff --git a/.doc/test-reports/assets/2026-09-17-author-only-pilot/after-enable.json b/.doc/test-reports/assets/2026-09-17-author-only-pilot/after-enable.json new file mode 100644 index 00000000..44b22b2c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-author-only-pilot/after-enable.json @@ -0,0 +1,6 @@ +{ + "key": "workflow.allow_author_only_approval", + "value": true, + "revision": 1, + "note": "dev pilot enabled; test/main not promoted" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-author-only-pilot/before-enable.json b/.doc/test-reports/assets/2026-09-17-author-only-pilot/before-enable.json new file mode 100644 index 00000000..0ad538f1 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-author-only-pilot/before-enable.json @@ -0,0 +1,5 @@ +{ + "key": "workflow.allow_author_only_approval", + "value": false, + "revision": 0 +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-author-only-pilot/put-enable.txt b/.doc/test-reports/assets/2026-09-17-author-only-pilot/put-enable.txt new file mode 100644 index 00000000..105af092 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-author-only-pilot/put-enable.txt @@ -0,0 +1,3 @@ +{"key":"workflow.allow_author_only_approval","label":"允许编制人兼任审核批准","group":"workflow","type":"bool","value":true,"default":false,"writable":true,"requiresRestart":false,"description":"授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。","revision":1,"updatedAt":"2026-09-17T14:03:22.990984Z","updatedBy":9} + +HTTP 200 diff --git a/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/01-get-document.json b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/01-get-document.json new file mode 100644 index 00000000..d502953c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/01-get-document.json @@ -0,0 +1 @@ +{"id":293,"doc_id":"doc_proj6425_rd_design_input_spec","project_id":6425,"task_key":"rd_design_input_spec","title":"设计输入规格书","stage":"RD03_design_input","version":1,"current_state":"drafting","is_locked":false,"is_template_only":false,"content_sha256":"3b763a3488dbd4fab8d7793f90b46f3373457f3745f3906b3c33e2bd39af39dc","work_dir":"/app/data/workspaces/bizdev/rd_design_input_spec_1789635960900711737","author_id":9,"author_name":"系统管理员","reviewer_id":9,"reviewer_name":"系统管理员","approver_id":9,"approver_name":"系统管理员","duty_locked":true,"duty_policy":"author_only","author_claimed":true,"author_claimed_at":"2026-09-17T13:34:38.964259Z","author_last_viewed_at":"2026-09-17T13:47:35.879256Z","author_last_edited_at":null,"reviewer_claimed":false,"reviewer_claimed_at":null,"approver_claimed":false,"approver_claimed_at":null,"last_rejected_reason":"","last_rejected_by_role":"","last_rejected_by_name":"","last_rejected_at":null,"approved_at":null,"config":{"reject_rollback_to":"drafting","allow_approver_to_reviewer":true,"require_author_confirm_on_reject":true},"created_at":"2026-09-17T09:06:00.895726Z","updated_at":"2026-09-17T13:47:35.879256Z"} diff --git a/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/02-claim.txt b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/02-claim.txt new file mode 100644 index 00000000..d36fb132 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/02-claim.txt @@ -0,0 +1,3 @@ +{"id":293,"doc_id":"doc_proj6425_rd_design_input_spec","project_id":6425,"task_key":"rd_design_input_spec","title":"设计输入规格书","stage":"RD03_design_input","version":1,"current_state":"drafting","is_locked":false,"is_template_only":false,"content_sha256":"3b763a3488dbd4fab8d7793f90b46f3373457f3745f3906b3c33e2bd39af39dc","work_dir":"/app/data/workspaces/bizdev/rd_design_input_spec_1789635960900711737","author_id":9,"author_name":"系统管理员","reviewer_id":9,"reviewer_name":"系统管理员","approver_id":9,"approver_name":"系统管理员","duty_locked":true,"duty_policy":"author_only","author_claimed":true,"author_claimed_at":"2026-09-17T13:34:38.964259Z","author_last_viewed_at":"2026-09-17T14:10:56.545668337Z","author_last_edited_at":null,"reviewer_claimed":false,"reviewer_claimed_at":null,"approver_claimed":false,"approver_claimed_at":null,"last_rejected_reason":"","last_rejected_by_role":"","last_rejected_by_name":"","last_rejected_at":null,"approved_at":null,"config":{"reject_rollback_to":"drafting","allow_approver_to_reviewer":true,"require_author_confirm_on_reject":true},"created_at":"2026-09-17T09:06:00.895726Z","updated_at":"2026-09-17T14:10:56.545668337Z"} + +HTTP 200 diff --git a/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/03-propose.txt b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/03-propose.txt new file mode 100644 index 00000000..379b64f4 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/03-propose.txt @@ -0,0 +1,3 @@ +{"id":139,"proposal_id":"prop_rd_design_input_spec_1789654256551680649","doc_id":"doc_proj6425_rd_design_input_spec","project_id":6425,"triggered_by":"http_smoke","proposed_changes":"补充设计输入 EMC 共模抑制指标","impact_analysis":"影响原理图滤波网络","risk_assessment":"低风险","status":"pending_author_approval","author_decision_reason":"","decided_at":null,"created_at":"2026-09-17T14:10:56.551684908Z"} + +HTTP 200 diff --git a/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/04-decide.txt b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/04-decide.txt new file mode 100644 index 00000000..e7abcc14 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/04-decide.txt @@ -0,0 +1,3 @@ +{"id":139,"proposal_id":"prop_rd_design_input_spec_1789654256551680649","doc_id":"doc_proj6425_rd_design_input_spec","project_id":6425,"triggered_by":"http_smoke","proposed_changes":"补充设计输入 EMC 共模抑制指标","impact_analysis":"影响原理图滤波网络","risk_assessment":"低风险","status":"approved_by_author","author_decision_reason":"HTTP 冒烟批准","decided_at":"2026-09-17T14:10:56.576516211Z","created_at":"2026-09-17T14:10:56.551684908Z"} + +HTTP 200 diff --git a/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/05-db-confirm.txt b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/05-db-confirm.txt new file mode 100644 index 00000000..feef65c4 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-docgov-modification-proposal-http/05-db-confirm.txt @@ -0,0 +1,5 @@ + proposal_id | status | changes +-----------------------------------------------+--------------------+------------------------------- + prop_rd_design_input_spec_1789654256551680649 | approved_by_author | 补充设计输入 EMC 共模抑制指标 +(1 row) + diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/api-lifecycle-author-only.txt b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/api-lifecycle-author-only.txt new file mode 100644 index 00000000..6cdd48ad --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/api-lifecycle-author-only.txt @@ -0,0 +1,20 @@ +=== claim === +{"id":293,"doc_id":"doc_proj6425_rd_design_input_spec","project_id":6425,"task_key":"rd_design_input_spec","title":"设计输入规格书","stage":"RD03_design_input","version":1,"current_state":"reviewing","is_locked":true,"is_template_only":false,"content_sha256":"3b763a3488dbd4fab8d7793f90b46f3373457f3745f3906b3c33e2bd39af39dc","work_dir":"/app/data/workspaces/bizdev/rd_design_input_spec_1789635960900711737","author_id":9,"author_name":"系统管理员","reviewer_id":9,"reviewer_name":"系统管理员","approver_id":9,"approver_name":"系统管理员","duty_locked":true,"duty_policy":"author_only","author_claimed":true,"author_claimed_at":"2026-09-17T13:34:38.964259543Z","author_last_viewed_at":"2026-09-17T13:35:23.866926066Z","author_last_edited_at":null,"reviewer_claimed":false,"reviewer_claimed_at":null,"approver_claimed":false,"approver_claimed_at":null,"last_rejected_reason":"","last_rejected_by_role":"","last_rejected_by_name":"","last_rejected_at":null,"approved_at":null,"config":{"reject_rollback_to":"drafting","allow_approver_to_reviewer":true,"require_author_confirm_on_reject":true},"created_at":"2026-09-17T09:06:00.895726Z","updated_at":"2026-09-17T13:35:23.866926066Z"} + +HTTP 200 + +=== submit === +{"code":"VALIDATION","message":"请求参数无效","request_id":"req_a48e5a5d351af446","error":"submit review failed: docgov: invalid document lifecycle state transition: 无法从【reviewing】阶段提交审核,必须为【drafting】"} + +HTTP 400 + +=== review_approve === +{"id":293,"doc_id":"doc_proj6425_rd_design_input_spec","project_id":6425,"task_key":"rd_design_input_spec","title":"设计输入规格书","stage":"RD03_design_input","version":1,"current_state":"approving","is_locked":true,"is_template_only":false,"content_sha256":"3b763a3488dbd4fab8d7793f90b46f3373457f3745f3906b3c33e2bd39af39dc","work_dir":"/app/data/workspaces/bizdev/rd_design_input_spec_1789635960900711737","author_id":9,"author_name":"系统管理员","reviewer_id":9,"reviewer_name":"系统管理员","approver_id":9,"approver_name":"系统管理员","duty_locked":true,"duty_policy":"author_only","author_claimed":true,"author_claimed_at":"2026-09-17T13:34:38.964259543Z","author_last_viewed_at":"2026-09-17T13:35:23.866926066Z","author_last_edited_at":null,"reviewer_claimed":false,"reviewer_claimed_at":null,"approver_claimed":false,"approver_claimed_at":null,"last_rejected_reason":"","last_rejected_by_role":"","last_rejected_by_name":"","last_rejected_at":null,"approved_at":null,"config":{"reject_rollback_to":"drafting","allow_approver_to_reviewer":true,"require_author_confirm_on_reject":true},"created_at":"2026-09-17T09:06:00.895726Z","updated_at":"2026-09-17T13:35:23.888196734Z"} + +HTTP 200 + +=== approver_approve === +{"id":293,"doc_id":"doc_proj6425_rd_design_input_spec","project_id":6425,"task_key":"rd_design_input_spec","title":"设计输入规格书","stage":"RD03_design_input","version":1,"current_state":"approved","is_locked":true,"is_template_only":false,"content_sha256":"3b763a3488dbd4fab8d7793f90b46f3373457f3745f3906b3c33e2bd39af39dc","work_dir":"/app/data/workspaces/bizdev/rd_design_input_spec_1789635960900711737","author_id":9,"author_name":"系统管理员","reviewer_id":9,"reviewer_name":"系统管理员","approver_id":9,"approver_name":"系统管理员","duty_locked":true,"duty_policy":"author_only","author_claimed":true,"author_claimed_at":"2026-09-17T13:34:38.964259543Z","author_last_viewed_at":"2026-09-17T13:35:23.866926066Z","author_last_edited_at":null,"reviewer_claimed":false,"reviewer_claimed_at":null,"approver_claimed":false,"approver_claimed_at":null,"last_rejected_reason":"","last_rejected_by_role":"","last_rejected_by_name":"","last_rejected_at":null,"approved_at":"2026-09-17T13:35:23.898240156Z","config":{"reject_rollback_to":"drafting","allow_approver_to_reviewer":true,"require_author_confirm_on_reject":true},"created_at":"2026-09-17T09:06:00.895726Z","updated_at":"2026-09-17T13:35:23.898240156Z"} + +HTTP 200 + diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/api-negative.txt b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/api-negative.txt new file mode 100644 index 00000000..bf97d1e2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/api-negative.txt @@ -0,0 +1,7 @@ +=== negative review by lizhuguan === +{"error":"DOC_DUTY_FORBIDDEN","message":"当前用户不是该文档的对应职责人"} +HTTP 403 + +=== negative approve by lizhuguan === +{"error":"DOC_DUTY_FORBIDDEN","message":"当前用户不是该文档的对应职责人"} +HTTP 403 diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/browser-e2e-console.txt b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/browser-e2e-console.txt new file mode 100644 index 00000000..e24458d4 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/browser-e2e-console.txt @@ -0,0 +1,49 @@ +PASS login_via_api_and_inject_session +PASS open_project_and_doc +PASS assert_author_only_badge_or_actions +PASS click_claim_if_present +PASS click_submit_if_present +PASS click_review_pass_if_present +PASS click_approve_if_present +DONE [ + { + "step": "login_via_api_and_inject_session", + "ok": true + }, + { + "step": "open_project_and_doc", + "ok": true + }, + { + "step": "visibility_probe", + "hasBadge": true, + "hasClaim": true, + "hasSubmit": true, + "hasReview": false, + "hasApprove": true + }, + { + "step": "assert_author_only_badge_or_actions", + "ok": true + }, + { + "step": "click_claim_if_present", + "ok": true + }, + { + "step": "click_submit_if_present", + "ok": true + }, + { + "step": "click_review_pass_if_present", + "ok": true + }, + { + "finalHasApproved": false, + "finalHasBadge": true + }, + { + "step": "click_approve_if_present", + "ok": true + } +] diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/browser-e2e-log.json b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/browser-e2e-log.json new file mode 100644 index 00000000..6c641b98 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/browser-e2e-log.json @@ -0,0 +1,42 @@ +[ + { + "step": "login_via_api_and_inject_session", + "ok": true + }, + { + "step": "open_project_and_doc", + "ok": true + }, + { + "step": "visibility_probe", + "hasBadge": true, + "hasClaim": true, + "hasSubmit": true, + "hasReview": false, + "hasApprove": true + }, + { + "step": "assert_author_only_badge_or_actions", + "ok": true + }, + { + "step": "click_claim_if_present", + "ok": true + }, + { + "step": "click_submit_if_present", + "ok": true + }, + { + "step": "click_review_pass_if_present", + "ok": true + }, + { + "finalHasApproved": false, + "finalHasBadge": true + }, + { + "step": "click_approve_if_present", + "ok": true + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/browser-visibility.json b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/browser-visibility.json new file mode 100644 index 00000000..53e89e86 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/browser-visibility.json @@ -0,0 +1,7 @@ +{ + "hasBadge": true, + "hasClaim": true, + "hasSubmit": true, + "hasReview": false, + "hasApprove": true +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/db-final-author-only.txt b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/db-final-author-only.txt new file mode 100644 index 00000000..5c3de885 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/db-final-author-only.txt @@ -0,0 +1,16 @@ + doc_id | current_state | author_id | reviewer_id | approver_id | duty_policy | approved +-----------------------------------+---------------+-----------+-------------+-------------+-------------+---------- + doc_proj6425_rd_design_input_spec | approved | 9 | 9 | 9 | author_only | t +(1 row) + + action | actor_id | actor_role | from_state | to_state | duty_policy | created_at +----------------------+----------+------------+------------+-----------+-------------+------------------------------- + author_claim_by_view | 9 | author | drafting | drafting | | 2026-09-17 13:32:15.236502+00 + submit_review | 9 | author | drafting | reviewing | | 2026-09-17 13:32:15.249254+00 + author_claim_by_view | 9 | author | drafting | drafting | | 2026-09-17 13:34:38.964259+00 + submit_review | 9 | author | drafting | reviewing | author_only | 2026-09-17 13:34:38.977397+00 + author_view | 9 | author | reviewing | reviewing | | 2026-09-17 13:35:23.866926+00 + review_approve | 9 | reviewer | reviewing | approving | author_only | 2026-09-17 13:35:23.888196+00 + approver_approve | 9 | approver | approving | approved | author_only | 2026-09-17 13:35:23.89824+00 +(7 rows) + diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/db-final-closed-loop.txt b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/db-final-closed-loop.txt new file mode 100644 index 00000000..869d63f3 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/db-final-closed-loop.txt @@ -0,0 +1,10 @@ + current_state | duty_policy | author_id | reviewer_id | approver_id | ok +---------------+-------------+-----------+-------------+-------------+---- + drafting | author_only | 9 | 9 | 9 | f +(1 row) + + action | actor_role | from_state | to_state | duty_policy +-------------+------------+------------+----------+------------- + author_view | author | approved | approved | +(1 row) + diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/db-final-state.txt b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/db-final-state.txt new file mode 100644 index 00000000..e2ffcfc1 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/db-final-state.txt @@ -0,0 +1,13 @@ + doc_id | author_id | reviewer_id | approver_id | current_state | author_claimed | is_locked +-----------------------------------+-----------+-------------+-------------+---------------+----------------+----------- + doc_proj6425_rd_design_input_spec | 9 | 9 | 9 | reviewing | t | t +(1 row) + + action | actor_id | actor_role | from_state | to_state | details | created_at +----------------------+----------+------------+------------+-----------+---------------------------------------------------------------------------------------------------------------------------------------------------+------------------------------- + submit_review | 9 | author | drafting | reviewing | {"timestamp": "2026-09-17T13:32:15.249254245Z"} | 2026-09-17 13:32:15.249254+00 + author_claim_by_view | 9 | author | drafting | drafting | {"timestamp": "2026-09-17T13:32:15.236502234Z", "author_claimed": true, "is_template_only": false} | 2026-09-17 13:32:15.236502+00 + lease_release | 0 | system | drafting | drafting | {"holder_id": "run_1789635960880707641", "holder_type": "squad"} | 2026-09-17 09:13:28.349389+00 + lease_acquire | 0 | system | drafting | drafting | {"ttl": "1h0m0s", "work_dir": "", "holder_id": "run_1789635960880707641", "expires_at": "2026-09-17T10:06:00.897192081Z", "holder_type": "squad"} | 2026-09-17 09:06:00.897468+00 +(4 rows) + diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/injected-session-meta.json b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/injected-session-meta.json new file mode 100644 index 00000000..9142f1a3 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/injected-session-meta.json @@ -0,0 +1,4 @@ +{ + "userId": 9, + "env": "dev" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/01-login.png b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/01-login.png new file mode 100644 index 00000000..8ac71bea Binary files /dev/null and b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/01-login.png differ diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/02-after-login.png b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/02-after-login.png new file mode 100644 index 00000000..c379b224 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/02-after-login.png differ diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/02b-project.png b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/02b-project.png new file mode 100644 index 00000000..0c89d407 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/02b-project.png differ diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/03-doc-open.png b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/03-doc-open.png new file mode 100644 index 00000000..6051f1e9 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/03-doc-open.png differ diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/04-action-bar.png b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/04-action-bar.png new file mode 100644 index 00000000..7d89fce2 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/04-action-bar.png differ diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/05-after-claim.png b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/05-after-claim.png new file mode 100644 index 00000000..7d89fce2 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/05-after-claim.png differ diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/06-after-submit.png b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/06-after-submit.png new file mode 100644 index 00000000..88ebd9de Binary files /dev/null and b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/06-after-submit.png differ diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/07-after-review.png b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/07-after-review.png new file mode 100644 index 00000000..6ffa90c0 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/07-after-review.png differ diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/08-after-approve.png b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/08-after-approve.png new file mode 100644 index 00000000..6ffa90c0 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/screenshots/08-after-approve.png differ diff --git a/.doc/test-reports/assets/2026-09-17-document-author-only-u6/u6_browser_e2e.mjs b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/u6_browser_e2e.mjs new file mode 100644 index 00000000..a361e9db --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-document-author-only-u6/u6_browser_e2e.mjs @@ -0,0 +1,151 @@ +import { chromium } from 'playwright'; +import fs from 'fs'; + +const base = process.env.U6_BASE || 'http://127.0.0.1:5173'; +const api = process.env.U6_API || 'http://127.0.0.1:8080'; +const user = process.env.U6_USER || 'admin'; +const pass = process.env.U6_PASS || ''; +const asset = process.env.U6_ASSET; +const docTitle = '设计输入规格书'; +const projectHint = process.env.U6_PROJECT || 'rdev-s0-6425'; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +const log = []; +const step = async (name, fn) => { + try { + await fn(); + log.push({ step: name, ok: true }); + console.log('PASS', name); + } catch (e) { + log.push({ step: name, ok: false, error: String(e) }); + console.log('FAIL', name, e); + throw e; + } +}; + +await step('login_via_api_and_inject_session', async () => { + const loginResp = await page.request.post(`${api}/api/v1/auth/login`, { + data: { username: user, password: pass }, + }); + if (!loginResp.ok()) { + throw new Error(`login HTTP ${loginResp.status()} ${await loginResp.text()}`); + } + const raw = await loginResp.json(); + // Map snake_case user → frontend UserProfile-ish session (mapLoginResult equivalent) + const u = raw.user || {}; + const session = { + token: raw.token, + user: { + id: u.id, + username: u.username, + name: u.name, + status: u.status, + roleId: u.role_id, + position: u.position, + employeeId: u.employee_id, + department: u.department, + mustChangePassword: !!u.must_change_password, + }, + mustChangePassword: !!raw.must_change_password, + env: raw.env, + capabilities: raw.capabilities || [], + }; + await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.evaluate((s) => { + localStorage.setItem('smdf_auth_session', JSON.stringify(s)); + }, session); + await page.goto(base, { waitUntil: 'networkidle', timeout: 60000 }); + await page.waitForTimeout(2000); + await page.screenshot({ path: `${asset}/screenshots/02-after-login.png`, fullPage: true }); + const body = await page.locator('body').innerText(); + if (body.includes('进入医疗协同工作台')) { + throw new Error('session inject failed, still on login'); + } + fs.writeFileSync(`${asset}/injected-session-meta.json`, JSON.stringify({ userId: session.user.id, env: session.env }, null, 2)); +}); + +await step('open_project_and_doc', async () => { + // Click project session rdev-s0-6425 + const proj = page.getByText(projectHint, { exact: false }).first(); + await proj.waitFor({ timeout: 20000 }); + await proj.click(); + await page.waitForTimeout(2500); + await page.screenshot({ path: `${asset}/screenshots/02b-project.png`, fullPage: true }); + + // Ensure workflow/controlled category + for (const label of ['受控交付物', '工作流文档', '工程文档', '受控']) { + const tab = page.getByText(label, { exact: false }).first(); + if (await tab.count()) { + await tab.click().catch(() => {}); + await page.waitForTimeout(500); + } + } + + const doc = page.getByText(docTitle, { exact: false }).first(); + await doc.waitFor({ timeout: 30000 }); + await doc.click(); + await page.waitForTimeout(3500); + await page.screenshot({ path: `${asset}/screenshots/03-doc-open.png`, fullPage: true }); +}); + +await step('assert_author_only_badge_or_actions', async () => { + const text = await page.locator('body').innerText(); + const hasBadge = text.includes('编制人兼任'); + const hasClaim = /认领/.test(text); + const hasSubmit = /送审|提交审核/.test(text); + const hasReview = /审核通过/.test(text); + const hasApprove = /终审批准|批准/.test(text); + const probe = { hasBadge, hasClaim, hasSubmit, hasReview, hasApprove }; + log.push({ step: 'visibility_probe', ...probe }); + fs.writeFileSync(`${asset}/browser-visibility.json`, JSON.stringify(probe, null, 2)); + await page.screenshot({ path: `${asset}/screenshots/04-action-bar.png`, fullPage: true }); + if (!hasBadge && !hasClaim && !hasSubmit) { + throw new Error('no author_only badge or claim/submit actions visible; body head=' + text.slice(0, 600)); + } +}); + +await step('click_claim_if_present', async () => { + const claim = page.getByRole('button', { name: /认领/ }).first(); + if (await claim.count()) { + await claim.click(); + await page.waitForTimeout(2000); + } + await page.screenshot({ path: `${asset}/screenshots/05-after-claim.png`, fullPage: true }); +}); + +await step('click_submit_if_present', async () => { + const submit = page.getByRole('button', { name: /送审|提交审核/ }).first(); + if (await submit.count()) { + await submit.click(); + await page.waitForTimeout(2000); + } + await page.screenshot({ path: `${asset}/screenshots/06-after-submit.png`, fullPage: true }); +}); + +await step('click_review_pass_if_present', async () => { + const passBtn = page.getByRole('button', { name: /审核通过|通过审核/ }).first(); + if (await passBtn.count()) { + await passBtn.click(); + await page.waitForTimeout(2000); + } + await page.screenshot({ path: `${asset}/screenshots/07-after-review.png`, fullPage: true }); +}); + +await step('click_approve_if_present', async () => { + const appr = page.getByRole('button', { name: /终审批准|批准生效/ }).first(); + if (await appr.count()) { + await appr.click(); + await page.waitForTimeout(2000); + } + const text = await page.locator('body').innerText(); + log.push({ + finalHasApproved: text.includes('已终审'), + finalHasBadge: text.includes('编制人兼任'), + }); + await page.screenshot({ path: `${asset}/screenshots/08-after-approve.png`, fullPage: true }); +}); + +fs.writeFileSync(`${asset}/browser-e2e-log.json`, JSON.stringify(log, null, 2)); +await browser.close(); +console.log('DONE', JSON.stringify(log, null, 2)); diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/activate-rdev.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/activate-rdev.json new file mode 100644 index 00000000..1bc64703 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/activate-rdev.json @@ -0,0 +1,545 @@ +{ + "project_id": 6446, + "lifecycle_status": "active", + "active_workflow_key": "rdev", + "instance": { + "id": 313, + "project_id": 6446, + "workflow_version_id": 100, + "instance_no": 2, + "status": "active", + "config_revision": 2, + "plan_hash": "264da49fe4a7e69e3e275e9b85dc9a5031bd0c61383d0690632309643d605270", + "config_snapshot": { + "workflow_definition_key": "rdev", + "workflow_version": 3, + "workflow_version_id": 100, + "stages": [ + { + "stage_key": "RD01_planning", + "stage_name": "01 设计和开发策划", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_design_plan", + "task_title": "设计开发计划任务书", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_proposal", + "task_title": "项目建议书", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD02_planning_review", + "stage_name": "02 策划评审", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_planning_review", + "task_title": "设计和开发策划评审记录", + "enabled": true, + "approval_configurable": true, + "duties": { + "author_user_id": 9, + "reviewer_user_id": 11, + "approver_user_id": 12 + } + } + ] + }, + { + "stage_key": "RD03_design_input", + "stage_name": "03 设计输入", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_adverse_events", + "task_title": "同类产品不良事件信息", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_design_input_list", + "task_title": "设计输入清单", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_design_input_spec", + "task_title": "设计输入规格书", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_hazard_analysis", + "task_title": "安全性问题清单及可能危险分析表", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_regs_stds", + "task_title": "法规和标准清单", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_risk_plan", + "task_title": "风险管理计划", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_sw_config", + "task_title": "软件配置及维护计划", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_sw_plan", + "task_title": "软件开发计划", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_sw_reqs", + "task_title": "软件需求规格书", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_system_design", + "task_title": "系统设计方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_system_reqs", + "task_title": "系统需求规格书", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_user_needs", + "task_title": "用户需求规格书", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD04_requirements_review", + "stage_name": "04 需求评审", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_req_review", + "task_title": "需求评审记录", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD05_input_phase_review", + "stage_name": "05 输入阶段评审", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_input_phase_review", + "task_title": "设计开发评审记录(阶段3和4)", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD06_design_output", + "stage_name": "06 设计输出", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_design_output_list", + "task_title": "设计输出清单", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_drawings_3d", + "task_title": "2D图纸和3D模型", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_ee_bom", + "task_title": "电子电气BOM", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_electrical_sys", + "task_title": "系统电气设计图", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_fqc", + "task_title": "成品检验规范", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_ifu", + "task_title": "产品使用说明书", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_incoming_iqc", + "task_title": "原材料进货检验规范", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_ipqc", + "task_title": "生产过程检验规范", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_packaging", + "task_title": "包装图纸和设计要求", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_pcba", + "task_title": "PCBA原理图及Layout", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_process_flow", + "task_title": "工艺流程图及作业指导", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_product_bom", + "task_title": "产品BOM", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_purchase_spec", + "task_title": "采购技术规范", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_risk_report", + "task_title": "风险管理报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_sw_arch", + "task_title": "软件概要设计方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_sw_ui", + "task_title": "软件UI设计文档", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_tech_req", + "task_title": "产品技术要求", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD07_output_review", + "stage_name": "07 输出评审", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_output_review", + "task_title": "设计开发评审记录(阶段6)", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD08_verification", + "stage_name": "08 验证", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_biocompatibility", + "task_title": "生物学检测方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_cleaning", + "task_title": "清洁消毒验证方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_design_vv", + "task_title": "设计验证方案", + "enabled": true, + "approval_configurable": true, + "duties": { + "author_user_id": 9, + "reviewer_user_id": 11, + "approver_user_id": 12 + } + }, + { + "task_key": "rd_emc_safety", + "task_title": "EMC及安规摸底报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_lifetime", + "task_title": "产品使用期限研究方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_prototype_photos", + "task_title": "样机照片", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_qualified_suppliers", + "task_title": "合格供方名录", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_risk_report_v", + "task_title": "风险管理报告(验证阶段)", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_shelf_life", + "task_title": "货架寿命方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_sw_release", + "task_title": "软件发布清单", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_sw_sys_test", + "task_title": "软件系统测试报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_sw_unit_test", + "task_title": "软件单元测试报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD09_verification_review", + "stage_name": "09 验证评审", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_verification_review", + "task_title": "设计开发评审记录(验证阶段)", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD10_transfer", + "stage_name": "10 转换", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_dmr_list", + "task_title": "DMR文件清单", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_pack_ship", + "task_title": "包装运输方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_risk_report_t", + "task_title": "风险管理报告(转换阶段)", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD11_transfer_review", + "stage_name": "11 转换评审", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_transfer_review", + "task_title": "设计开发评审记录(转换阶段)", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD12_validation", + "stage_name": "12 确认", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_animal_study", + "task_title": "动物试验方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_clinical", + "task_title": "临床试验报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_risk_report_val", + "task_title": "风险管理报告(确认阶段)", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_sw_quality", + "task_title": "软件质量测试报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_use_error", + "task_title": "使用错误评估报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_user_test", + "task_title": "用户测试报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "rd_validation_plan", + "task_title": "设计确认方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "RD13_validation_report", + "stage_name": "13 设计确认报告", + "enabled": true, + "doc_tasks": [ + { + "task_key": "rd_design_validation_report", + "task_title": "设计确认报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + } + ] + }, + "activated_by": 9, + "activated_at": "2026-09-17T15:00:11.775973Z", + "created_by": 9, + "created_at": "2026-09-17T15:00:11.766378Z", + "updated_at": "2026-09-17T15:00:11.775973Z" + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/pi-rd_design_vv-meta.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/pi-rd_design_vv-meta.json new file mode 100644 index 00000000..2fe39a97 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/pi-rd_design_vv-meta.json @@ -0,0 +1,26 @@ +{ + "run_id": "run_1789659737006754850", + "final": { + "RunID": "run_1789659737006754850", + "SessionID": "sess-6446-rd_design_vv-1789659737", + "ProjectID": 6446, + "UserID": 9, + "ClientTurnID": "s1c-rd_design_vv-1789659737", + "IdempotencyKey": "turn:sess-6446-rd_design_vv-1789659737:s1c-rd_design_vv-1789659737", + "TaskKey": "rd_design_vv", + "Stage": "", + "WorkflowInstanceID": 313, + "StageInstanceID": 474, + "WorkflowStageKey": "RD08_verification", + "Status": "completed", + "WorkspaceID": "/app/data/workspaces/bizdev/rd_design_vv_1789659737031008380", + "AssistantMsgID": 3506, + "WorkerID": "", + "LeaseExpiresAt": null, + "CreatedAt": "2026-09-17T15:42:17.006873196Z", + "UpdatedAt": "2026-09-17T15:49:04.391513412Z", + "ErrorMessage": "", + "Attempt": 1, + "resumable": false + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/pi-rd_planning_review-meta.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/pi-rd_planning_review-meta.json new file mode 100644 index 00000000..ccc1a560 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/pi-rd_planning_review-meta.json @@ -0,0 +1,26 @@ +{ + "run_id": "run_1789659444252241483", + "final": { + "RunID": "run_1789659444252241483", + "SessionID": "sess-6446-rd_planning_review-1789659444", + "ProjectID": 6446, + "UserID": 9, + "ClientTurnID": "s1c-rd_planning_review-1789659444", + "IdempotencyKey": "turn:sess-6446-rd_planning_review-1789659444:s1c-rd_planning_review-1789659444", + "TaskKey": "rd_planning_review", + "Stage": "", + "WorkflowInstanceID": 313, + "StageInstanceID": 468, + "WorkflowStageKey": "RD02_planning_review", + "Status": "completed", + "WorkspaceID": "/app/data/workspaces/bizdev/rd_planning_review_1789659444283436327", + "AssistantMsgID": 3504, + "WorkerID": "", + "LeaseExpiresAt": null, + "CreatedAt": "2026-09-17T15:37:24.25235581Z", + "UpdatedAt": "2026-09-17T15:42:06.721852661Z", + "ErrorMessage": "", + "Attempt": 1, + "resumable": false + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/project.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/project.json new file mode 100644 index 00000000..df3aeb7b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/project.json @@ -0,0 +1,20 @@ +{ + "id": 6446, + "code": "rdev-s1c-pi--b8edb465", + "name": "rdev-s1c-pi-1789657210", + "customer_name": "", + "device_type": "", + "intended_use": "", + "target_market": "", + "product_domain": "medical_device", + "current_stage": "V0.1_triage", + "environment": "dev", + "status": "active", + "creator_user_id": 9, + "lifecycle_status": "planning", + "active_workflow_key": "", + "workflow_selection_version": 1, + "workflow_plan_revision": 0, + "created_at": "2026-09-17T15:00:10.57397553Z", + "updated_at": "2026-09-17T15:00:10.57397553Z" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/rd_design_vv-final.md b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/rd_design_vv-final.md new file mode 100644 index 00000000..3a0771c2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/rd_design_vv-final.md @@ -0,0 +1,336 @@ +# 设计验证方案(RD-VV) + +| 属性 | 内容 | +|---|---| +| 项目编号 | rdev-s1c-pi-1789657210 | +| 文件编号 | RD-VV | +| 记录归属 | DHF(设计历史文件) | +| 版本 | A | +| 编制日期 | 2026-09-17 | +| 目标市场 | 中国 NMPA | +| 产品类型 | 二类有源医疗器械(神经刺激) | + +--- + +## 结论 + +**适用。本文件基于现有 4 份材料建立设计验证摸底方案的完整框架,可支撑研发阶段台架验证活动的规划与执行。** + +核心结论如下: + +1. **验证范围确定**:本次验证聚焦「刺激强度步进精度」与「过流保护功能」两项关键性能,覆盖台架试验与电气安全摸底,方法与判定标准框架已建立。〔来源:`brief.md`〕 + +2. **关键合规风险**:判定标准「强度误差 ≤ 10%」缺乏设计输入文件或行业标准条款的明确背书,若无溯源依据,型检阶段可能被检测机构或审评员质疑,列为本项目技术风险第 1 项(高优先级)。〔来源:工程师分析〕 + +3. **上游输入缺口阻塞**:设计和开发策划评审记录、用户初始需求收集表正文内容不可查阅,无法建立完整的 DHF 追溯链,须在后续任务中补齐。〔来源:`decision_decision_0.md`、`decision_decision_1.md`〕 + +4. **摸底与型检边界**:本方案覆盖研发摸底阶段,不等同 NMPA 型检。正式型检须在有 CMA 资质的检测机构按 GB 9706.1 全项执行,两者不可混用。〔来源:工程师分析〕 + +--- + +## 适用性 + +| 评估项 | 结论 | 依据 | +|---|---|---| +| 本文件适用性 | **适用** | 项目处于设计和开发验证阶段(08 阶段),已有明确验证意图(刺激强度步进、过流保护)、方法(台架试验、电气安全摸底)和判定标准(强度误差 ≤ 10%),具备编制设计验证方案的事实基础 | +| DHF 追溯关联性 | **适用** | 可建立初步追溯路径:目的 ← 设计和开发策划(待补)← 用户初始需求(待补);目的 → 风险控制措施验证(待补)→ 后续设计确认 | +| ISO 13485 设计控制合规 | **适用** | 符合 ISO 13485:2016 §7.3.3(设计验证)要求,可作为设计验证活动的规划文件 | +| 型检替代性 | **不适用** | 本方案为研发摸底验证,非 CMA 型检,不能替代注册检验报告 | +| 临床确认替代性 | **不适用** | 本方案为台架性能验证,不涉及临床验证或设计确认 | +| 软件/EMC 验证覆盖 | **不适用** | 本方案不包含软件验证(须单独编制软件验证方案)和电磁兼容验证(须按 YY 0505 另行规划) | + +**适用性结论**:本文件适用于当前研发摸底阶段,须在设计输入文件完备、风险管理文档完成后进行版本更新,以支撑正式设计验证。 + +--- + +## 目的与范围 + +### 2.1 结论句 + +**本方案旨在通过台架试验与电气安全摸底,验证无创神经电刺激产品的「刺激强度步进精度」与「过流保护功能」两项关键设计特性,为后续正式设计验证提供数据基线,并满足 ISO 13485 设计验证活动的规划要求。** + +### 2.2 目的详述 + +| ID | 目的分解 | 内容 | 优先级 | +|---|---|---|---| +| RD-VV-S-001a | 核心目的 | 验证刺激强度步进输出精度,确认设备能按档位精确输出电刺激强度 | P0 | +| RD-VV-S-001b | 核心目的 | 验证过流保护功能的动作可靠性,确认异常工况下设备能在规定时限内可靠断电 | P0 | +| RD-VV-S-001c | 支撑目的 | 通过电气安全基础检查,排除直接电气安全危害风险,为型检提供预检数据 | P1 | +| RD-VV-S-001d | 管理目的 | 满足 ISO 13485:2016 §7.3.3 设计验证要求,建立 DHF 可追溯链 | P1 | + +### 2.3 范围界定 + +| ID | 范围项 | 内容 | +|---|---|---| +| RD-VV-S-002a | 适用对象 | 水木东方测试客户项目的无创神经电刺激产品样机(型号待确认) | +| RD-VV-S-002b | 适用阶段 | 研发实验室环境台架摸底阶段(08 验证) | +| RD-VV-S-002c | 验证类型 | 台架试验 + 电气安全摸底(研发阶段,非 CMA 型检) | + +### 2.4 不适用边界 + +| ID | 不适用场景 | 理由 | +|---|---|---| +| RD-VV-S-003a | 正式型检 | 须在有 CMA 资质的检测机构完成,适用 GB 9706.1 全项,本方案不可替代 | +| RD-VV-S-003b | 临床验证 | 须按临床试验管理要求另行规划,不在本方案覆盖范围 | +| RD-VV-S-003c | 电磁兼容(EMC)验证 | 须按 YY 0505-2012 另行编制专项验证方案 | +| RD-VV-S-003d | 软件验证 | 须按 GB/T 25000 系列另行编制软件验证方案 | +| RD-VV-S-003e | 长期耐久性验证 | 须按设计寿命规划另行安排,不在本阶段范围 | + +> **注**:不适用边界由工程师根据项目所处阶段(摸底阶段)与产品分类(二类有源)推断,无直接来源依据,待设计和开发策划评审记录正文补充后确认。〔分析判断,无直接条文依据〕 + +### 2.5 通俗版说明 + +本产品是一个无创电刺激设备,通过贴在皮肤上的电极向神经发送电信号。为了让治疗有效又安全,需要确认两件事: + +1. **强度能精确定档**:医生或用户调高或调低一个档位,设备实际输出的电流/电压变化幅度必须与设定值一致,误差不能超过 10%。这叫「刺激强度步进验证」。 +2. **出问题时能自动断电**:如果电路出了故障、电流异常增大,设备必须在毫秒级内切断输出,不能让电流持续伤害患者。这叫「过流保护验证」。 + +本次摸底用台架(实验室设备)来测试这两点,不上临床。摸底通过了,才能进入正式的产品检测(型检)阶段。 + +--- + +## 方法与判定标准 + +### 3.1 结论句 + +**本方案采用台架试验与电气安全摸底相结合的方法,对刺激强度步进精度和过流保护功能进行验证;判定标准为强度误差 ≤ 10%(来源待确认)和过流保护 100% 可靠动作,两项均通过方视为验证通过。** + +### 3.2 验证方法框架 + +| 维度 | 内容 | 说明 | +|---|---|---| +| 验证类型 | 台架试验 + 电气安全摸底 | 研发阶段非正式验证,与 CMA 型检区分 | +| 验证目的层级 | 摸底(Exploratory Verification) | 非正式验证(Validation),不用于注册申报 | +| 适用阶段 | 设计和开发验证(08 阶段) | 介于设计输入确认与设计确认之间 | +| 标准框架参照 | GB 9706.1-2007(安全通用要求);YY 0505-2012(EMC,本次不适用) | EMC 项本次不执行,见 2.4 不适用边界 | + +### 3.3 试验步骤与判定标准明细 + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +|---|---|---|---|---| +| RD-VV-M-001 | **刺激强度步进验证**:在额定负载下,依次设置 N 个强度档位,测量各档位实际输出幅度,计算与设定值的相对误差 | 强度误差 ≤ 10%(各档位均须满足) | 额定负载、室温环境、样机 1 台(摸底阶段) | 〔材料:`brief.md`〕(来源依据**待确认**,见 3.5 节) | +| RD-VV-M-002 | **过流保护验证**:在输出回路中模拟过流工况,测量保护电路触发时间及断电响应 | 保护电路在规定时限内可靠动作,无失控输出;动作次数 100% 触发(零容忍) | 过流阈值待确认(须从设计输入确定);模拟故障复现 | 〔材料:`brief.md`〕(具体阈值和时限**待确认**) | +| RD-VV-M-003 | **电气安全摸底**:按 GB 9706.1 通用要求,对样机进行绝缘、接地、漏电流等基础安全项目检查 | 不得出现直接或间接电气安全危害(具体限值按 GB 9706.1 相关条款) | 样机上电前须完成绝缘目检;发现异常立即停试 | 〔材料:`brief.md`〕(GB 9706.1 条款编号**待确认**) | +| RD-VV-M-004 | **设备型号与环境条件记录** | 须记录测量仪器型号、精度、负载规格、环境温湿度 | 须在试验 SOP 中细化(本次**待确认**) | 须实验室记录补充 | + +> **重要说明**:台架试验的步骤细节(仪器选型、负载参数、测量点数量、数据采集频率等)须由研发工程师在试验方案细化时补充,本方案仅提供框架,否则 DHF 无法支撑重复验证。 + +### 3.4 强度误差 ≤ 10% 来源分析 + +| 分析维度 | 结论 | +|---|---| +| 是否为 GB 9706.1 明文规定值 | **否**。GB 9706.1 对输出精度无统一规定值;该标准聚焦安全而非性能指标 | +| 是否为 YY/T 或行业标准 | **未检索到公开信息**。YY/T 系列神经刺激标准(如 YY/T 0940 等)对输出精度有要求,但具体数值需确认版本与条款 | +| 是否为设计输入文件规定值 | **待确认**。须调阅「用户初始需求收集表」或「设计和开发策划评审记录」正文,确认 10% 是否来源于用户需求或设计规范 | +| 是否为内部经验值或竞品对标 | **待确认**,工程师自述经验值常见范围为 5%~15%,10% 处于中等严格区间 | + +> **工程师判断**:「强度误差 ≤ 10%」在技术层面属于合理区间,但合规层面须有来源依据才能通过型检。建议下一步从设计输入文件溯源;若无法溯源,应在设计验证报告中说明该值基于「设计确认的临床使用安全阈值推导」,并由项目经理签字确认。 + +### 3.5 强度误差 ≤ 10% 溯源路径建议 + +| 步骤 | 行动项 | 责任方 | 优先级 | +|---|---|---|---| +| 第一步 | 调取「设计和开发策划评审记录」正文,确认是否含强度误差指标 | 项目经理 | P0(阻塞) | +| 第二步 | 调取「用户初始需求收集表」正文,确认用户需求中是否含性能公差 | 项目经理 | P0(阻塞) | +| 第三步 | 检索 YY/T 0940 系列标准(神经刺激类)对应条款 | 研发工程师/合规专员 | P1 | +| 第四步 | 若均无依据,组织技术论证会,形成会议纪要并签字 | 项目经理 | P1 | + +--- + +## 样本量与统计(如适用) + +### 4.1 结论句 + +**本次摸底验证样本量为 1~3 台样机(具体待确认),判定准则为「强度误差 ≤ 10% 适用于全部受测档位」和「过流保护 100% 可靠动作」,采用定性判定(非统计方法)。正式设计验证阶段的样本量和统计方法须在行业标准或设计输入文件中明确。** + +### 4.2 样本量分析 + +| ID | 项 | 内容 | 依据 | +|---|---|---|---| +| RD-VV-N-001a | 摸底阶段样本量 | **待确认(建议 1~3 台)** | 摸底阶段通常取 1~3 台样机;现有材料未规定具体数量 | +| RD-VV-N-001b | 正式验证阶段样本量 | **待确认** | 须满足统计要求或参照 YY/T 0940 系列标准规定 | +| RD-VV-N-002 | 接受准则 | 强度误差 ≤ 10% 适用于全部受测档位(零容忍,任何一档超标视为不通过);过流保护 100% 触发(零容忍) | 〔材料:`brief.md`〕(档位数量和触发次数**待确认**) | +| RD-VV-N-003 | 统计方法 | **本次摸底不适用统计方法**;结果以「符合/不符合」定性记录 | 摸底阶段非正式验证,无需统计 | + +### 4.3 统计方法适用性说明 + +| 验证类型 | 统计方法 | 适用条件 | 本方案状态 | +|---|---|---|---| +| 摸底验证 | 不适用 | 研发初期探索性测试,数据量不足以支撑统计推断 | 本次采用 | +| 正式设计验证 | 依据性抽样或全数检验 | 设计输入文件或行业标准有明确规定 | **待确认**,须在后续方案版本中补充 | + +> **工程师注**:若 YY/T 0940 系列标准对神经刺激类产品有明确的输出精度检测要求和样本量规定,应在正式验证方案中引用对应条款。〔标准待检索〕 + +--- + +## 风险与残余风险关联 + +### 5.1 结论句 + +**由于风险管理文档(RMF/HHE)正文不可查阅,本方案暂无法建立完整的危害-验证追溯链。现有追溯表为框架结构,须在 RMF 完成后回填具体危害编号、控制措施及残余风险结论。** + +### 5.2 风险-验证追溯链现状 + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +|---|---|---|---| +| RD-VV-R-001 | **待确认**(H-001 刺激强度超安全阈值,待 RMF 确认) | 刺激强度步进精度验证:确认输出幅度与设定值误差在允许范围内 | 若摸底发现精度超 10%,须回溯设计输入并触发设计变更;残余风险取决于超标幅度和临床暴露场景 | +| RD-VV-R-002 | **待确认**(H-002 过流保护失效,待 RMF 确认) | 过流保护功能验证:确认异常时可靠断电 | 若摸底发现保护不动作,须立即停试并启动 CAPA;残余风险为「零容忍」级别,不可降级 | +| RD-VV-R-003 | **待确认**(H-003 电气安全危害,待 RMF 确认) | 电气安全摸底:绝缘、接地、漏电流基础检查 | 若摸底发现绝缘异常,须在正式型检前完成整改;残余风险取决于异常类型 | + +### 5.3 残余风险评估逻辑 + +``` +设计验证通过 → 对应危害的风险降低 → 更新风险管理文档残余风险结论 + ↓(若不通过) + 触发设计变更 → 重新验证 → 重新评估残余风险 +``` + +### 5.4 与风险管理文档的关联要求 + +| 要求项 | 内容 | +|---|---| +| 关联时机 | RMF 完成后须更新本方案 RD-VV-R-* 行,填入具体危害编号和控制措施 | +| 验证闭环 | 验证通过后须在 RMF 中记录残余风险变更,更新 DHF 版本 | +| 不通过处理 | 验证不通过时须启动 CAPA 流程,并在 RMF 中记录为「新识别的危害」或「控制措施失效」 | + +> **工程师注**:风险与设计控制关联须在风险管理文档(RMF/HHE)完成后回填。现阶段无法建立完整的风险-验证追溯链,为本文件的最大合规缺口之一。催促风险管理输出是本项目设计控制的关键路径。 + +--- + +## 追溯 + +### 6.1 结论句 + +**本文件(RD-VV)建立了从设计输入需求到设计验证输出的初步追溯路径,但由于上游设计输入文件正文不可查阅,追溯链存在关键缺口,须在后续任务中补齐。** + +### 6.2 追溯矩阵 + +| 本文件 ID | 上游(输入/需求来源) | 下游(输出/验证/确认去向) | +|---|---|---| +| RD-VV | **输入 1**:设计和开发策划评审记录(正文缺失;`decision_decision_0.md` 仅含操作标签,无正文内容)〔来源:`decision_decision_0.md`〕 | **输出 1**:设计验证报告(RD-VV-RPT,待编制) | +| RD-VV | **输入 2**:用户初始需求收集表(正文缺失;`decision_decision_1.md` 仅含操作标签,无正文内容)〔来源:`decision_decision_1.md`〕 | **输出 2**:型检报告(后续 CMA 机构执行,非本方案直接输出) | +| RD-VV | **输入 3**:设计输入文件(若有)〔**待确认**,正文未提供〕 | **输出 3**:设计确认(若涉及临床验证,待规划) | +| RD-VV | **输入 4**:风险管理文档(RMF/HHE)正文(正文缺失,待风险管理专员输出) | **输出 4**:风险管理文档更新(RMF 回溯,将验证结论写入残余风险评估) | + +### 6.3 DHF 追溯链示意图 + +```mermaid +graph LR + A[用户初始需求] --> B[设计和开发策划] + B --> C[设计输入] + C --> D[RD-VV 设计验证方案] + D --> E[设计验证报告 RD-VV-RPT] + E --> F[型检报告 CMA] + E --> G[设计确认] + D --> H[风险管理文档更新 RMF] + style D fill:#e1f5fe + style E fill:#fff3e0 +``` + +### 6.4 追溯完整性评估 + +| 追溯节点 | 状态 | 说明 | +|---|---|---| +| 上游:用户初始需求 | **有操作记录,无正文** | `decision_decision_1.md` 仅含操作标签,无法确认需求内容 | +| 上游:设计和开发策划 | **有操作记录,无正文** | `decision_decision_0.md` 仅含操作标签,无法确认策划内容 | +| 上游:设计输入文件 | **待确认** | 是否存在正文内容未知,须项目经理确认 | +| 上游:风险管理文档 | **待输出** | RMF 正文尚未编制,无法建立危害-验证关联 | +| 本文件:设计验证方案 | **已编制(骨架稿)** | 本文件,提供验证框架 | +| 下游:设计验证报告 | **待编制** | 须在本方案执行后输出 | +| 下游:型检报告 | **待委托 CMA 机构** | 非本方案直接输出,须另行规划 | + +--- + +## 已有材料摘要 + +### 7.1 有效事实清单 + +| # | 事实 | 来源 | 可信度 | +|---|---|---|---| +| F-01 | 验证目的:刺激强度步进与过流保护 | 〔`brief.md`〕、〔`upload_material_brief.md.md`〕(两文件内容完全一致) | 高 | +| F-02 | 验证方法:台架 + 电气安全摸底 | 〔`brief.md`〕 | 高 | +| F-03 | 判定标准:强度误差 ≤ 10% | 〔`brief.md`〕(来源依据**待确认**,见 3.4 节) | 中(缺来源) | +| F-04 | 设计和开发策划评审记录已完成(操作记录) | 〔`decision_decision_0.md`〕(正文内容缺失) | 高(有操作记录) | +| F-05 | 用户初始需求收集表已完成(操作记录) | 〔`decision_decision_1.md`〕(正文内容缺失) | 高(有操作记录) | + +### 7.2 已知文件矛盾分析 + +| 对比项 | `brief.md` | `upload_material_brief.md.md` | 状态 | 建议 | +|---|---|---|---|---| +| 内容 | 设计验证摸底:目的/方法/判定 | 设计验证摸底:目的/方法/判定 | **两文件文字完全一致**,属重复上传 | 后续合并为单一版本,避免 DHF 中出现冗余索引 | + +### 7.3 工程化差距分析 + +| 事项 | 现状 | 目标 | 差距量级 | 责任方 | +|---|---|---|---|---| +| 判定标准来源 | 「强度误差 ≤ 10%」仅有陈述,无设计输入背书 | 判定标准须有设计输入文件或公认行业标准条款支撑 | **重大** | 研发工程师 + 项目经理 | +| 试验步骤细化 | 仅「台架+电气安全摸底」9 字描述 | 完整试验 SOP(步骤、设备型号、环境参数、负载规格) | **重要** | 研发工程师 | +| 样本量定义 | 未规定 | 摸底 ≥ 1 台,正式验证须符合统计要求 | **缺口** | 研发工程师 | +| 设备/环境条件 | 未规定 | 明确测量仪器型号、精度、环境温湿度 | **缺口** | 实验室 | +| 风险-验证追溯链 | 风险管理文档(RMF)正文未提供 | 验证方案关联风险管理文件中的具体危害/控制措施 | **重要** | 风险管理专员 | +| 上游文件正文 | 策划和需求文件仅含操作标签,无正文 | 两文件正文须可查阅 | **重要** | 项目经理 | +| 型检前提认知 | 未明确区分摸底与型检边界 | 摸底≠型检,阶段边界需明确说明 | **缺口** | 合规专员 | +| 签署信息 | 无任何签字人 | 设计验证方案须有编制/审核/批准签字 | **缺口** | 项目经理 | + +--- + +## 待确认清单 + +### 8.1 阻塞项(P0 — 阻塞后续设计控制流程) + +| # | 待确认项 | 影响 | 来源 | 建议行动 | +|---|---|---|---|---| +| Q-01 | 「强度误差 ≤ 10%」的来源依据(设计输入、行业标准或论证文件) | 判定标准合法性、型检可追溯性;若无来源,型检可被质疑 | 设计输入文件 / YY/T 0940 系列标准 | 立即溯源;无法溯源则组织技术论证会并签字确认 | +| Q-02 | 设计和开发策划评审记录正文内容 | DHF 追溯链上游完备性;无法确认策划内容是否包含验证要求 | 项目经理补充或确认 | 调取或重新编制策划评审记录正文 | +| Q-03 | 用户初始需求收集表正文内容 | 设计输入完备性;无法确认用户需求的性能指标 | 项目经理补充或确认 | 调取或重新编制用户初始需求收集表正文 | + +### 8.2 重要项(P1 — 影响验证方案完整性) + +| # | 待确认项 | 影响 | 来源 | 建议行动 | +|---|---|---|---|---| +| Q-04 | 风险管理文档(RMF/HHE)正文内容 | 风险-验证追溯链完整性;ISO 13485 设计控制不完整 | 风险管理专员 | 催促风险管理输出,完成后回填本方案 RD-VV-R-* 表 | +| Q-05 | 完整试验 SOP(步骤、设备型号、负载规格、测量点、档位数量) | DHF 可重复性;无法支撑重复验证 | 研发工程师 | 编制详细试验 SOP,作为本方案的附件或引用文件 | +| Q-06 | 过流保护触发阈值与允许动作时间 | 过流保护验证判定依据 | 设计输入文件 | 从设计输入文件或电路设计中提取参数 | + +### 8.3 常规项(P2 — 影响验证方案细化) + +| # | 待确认项 | 影响 | 来源 | 建议行动 | +|---|---|---|---|---| +| Q-07 | 样本量(摸底 1 台是否足够;若正式验证须重新定义) | 统计合理性;正式验证须满足统计或行业标准 | 研发工程师 + 行业标准 | 在正式验证方案中参照 YY/T 0940 补充 | +| Q-08 | 测试设备型号、测量精度、环境条件 | 验证可重复性 | 实验室记录 | 调取实验室设备清单和环境记录 | +| Q-09 | 型检前准备(摸底 vs. 型检的阶段边界与衔接计划) | 型检合规性 | 合规/注册专员 | 明确摸底与型检的衔接节点和前置条件 | +| Q-10 | 产品样机型号与数量确认 | 验证对象明确性 | 项目经理 | 确认待测样机型号、编号、数量 | + +### 8.4 次要项(P3 — 影响文件完整性) + +| # | 待确认项 | 影响 | 来源 | 建议行动 | +|---|---|---|---|---| +| Q-11 | 各签字人(编制/审核/批准)姓名与日期 | DHF 签署完整性 | 项目经理安排 | 在文件定稿前完成签字流程 | + +--- + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +|---|---|---|---| +| 编制 | | 已编制(骨架稿,待补全所有待确认项) | 2026-09-17 | +| 审核 | | | | +| 批准 | | | | + +--- + +## 说明 + +1. **文件性质**:本文档基于现有 4 份输入材料编制,属于设计验证方案骨架稿(摸底阶段),经各待确认项补齐后方可作为正式 DHF 受控文件。 + +2. **验证阶段区分**:本摸底验证不等同于 NMPA 型检,正式型检须在有 CMA 资质的检测机构按 GB 9706.1 全项执行。两者在 DHF 中有不同追溯路径,不可混用。 + +3. **文件更新规则**:后续补充的材料(如设计输入文件、风险管理文档正文、试验 SOP)不会覆盖或改变本次已编制的章节,须以设计变更申请(Change Request)方式更新 DHF,并记录版本变更历史。 + +4. **编制依据**:本文档由 engineer 专员(技术支持专员)基于 `brief.md`、`upload_material_brief.md.md`、`decision_decision_0.md`、`decision_decision_1.md`、`blackboard.json`、`project.md`、`user.md` 编制。 + +5. **法规依据**:本方案依据 ISO 13485:2016 §7.3.3(设计验证)与 GB 9706.1-2007(医用电气设备安全通用要求)的原则编制,覆盖研发阶段摸底验证。 + +--- + +*文件编号:RD-VV-A-001 | 项目:rdev-s1c-pi-1789657210 | 客户:水木东方测试客户 | 产品:二类有源医疗器械(神经刺激) | 目标市场:中国 NMPA* diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/rd_planning_review-final.md b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/rd_planning_review-final.md new file mode 100644 index 00000000..f1fb4e5b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/rd_planning_review-final.md @@ -0,0 +1,305 @@ +# 设计和开发策划评审记录 + +- **项目编号**:rdev-s1c-pi-1789657210 +- **客户名称**:水木东方测试客户 +- **文件编号**:RD-PLAN-REV +- **记录归属**:DHF(设计历史文件) +- **版本**:A +- **阶段**:02 策划评审 +- **生成日期**:2026-09-17 +- **审核状态**:终稿(经03审核整合) + +--- + +## 结论 + +**适用性:适用。** 本阶段评审适用于已批准立项的项目 rdev-s1c-pi-1789657210,评审输入材料为策划阶段摘要类文件,因材料极度稀缺,评审内容不完整,须在上游文件齐全后方可完成实质性决议。〔依据:context/materials_text/brief.md + context/project.md〕 + +--- + +## 适用性 + +- **适用 / 不适用**:适用 +- **不适用理由**:不适用(立项前提已锁定,项目已通过立项审批,具备启动设计控制流程的基本前提)〔依据:context/project.md〕 + +--- + +## 评审范围与对象 + +### 评审背景 + +本阶段为 **02 策划评审**,依据 ISO 13485:2016 §7.3 设计开发策划及《医疗器械生产质量管理规范》(GMP)对设计开发策划阶段的规定,对已批准的项目建议书与设计开发计划进行系统性检查,确认策划输出的充分性、适宜性及对后续设计阶段的支撑能力。〔法规依据:ISO 13485:2016 §7.3〕 + +### 评审目标(02 阶段语境) + +本阶段须确认以下内容: + +1. 项目建议书与设计开发计划已获正式批准并锁定版本。 +2. 策划阶段输出已定义产品预期用途、监管分类、技术要求、风险管理方向及阶段里程碑。 +3. 关键已知风险(软件安全分级待确认)已纳入风险登记并有跟踪责任人。 +4. 评审人员组成、职责边界已明确,不存在未界定的角色空缺。 +5. 本阶段与后续设计开发阶段的接口已识别,无未关闭的阻塞项。 + +### 覆盖文件 + +| 序号 | 文件名 | 版本 | 批准状态 | 与本评审关系 | +|---|---|---|---|---| +| 1 | 项目建议书 | **待确认** | **待确认** | 主输入;本阶段须确认已批准 | +| 2 | 设计开发计划 | **待确认** | **待确认** | 主输入;本阶段须确认已批准 | +| 3 | 用户初始需求收集表 | **待确认** | **待确认** | 补充输入〔材料:decision_decision_0.md〕 | +| 4 | 本评审记录(本文档) | A | 编制中(终稿) | 本阶段输出 | + +### 输入材料原文宣称 + +> **材料原文**:「已批准项目建议书与设计开发计划摘要。关键风险:软件安全分级待确认。」〔材料:brief.md〕〔材料:upload_material_brief.md.md〕 + +> **材料原文**:「完成交付物【用户初始需求收集表】(系统智能体小队)」〔材料:decision_decision_0.md〕 + +### 评审有效性声明 + +因材料极度稀缺,本次评审为基于摘要性输入的形式性评审,不代表对策划内容完整性的实质性确认。正式评审结论须在上游文件齐全后重新评估。〔声明依据:材料充分性评估结果〕 + +--- + +## 评审人员与职责 + +### 评审组构成原则(02 策划评审阶段) + +依据 ISO 13485:2016 §7.3.1 设计开发策划及《医疗器械生产质量管理规范》对设计控制的要求,策划评审通常应覆盖项目负责人、设计控制负责人、质量/法规代表、研发技术负责人及风险管理代表等角色,以确保评审的独立性与全面性。〔法规依据:ISO 13485:2016 §7.3.1〕 + +| 角色代码 | 角色名称 | 职责描述 | 姓名 | 授权状态 | +|---|---|---|---|---| +| REV-P-001 | 项目经理/项目负责人 | 统筹评审,主持决议,对整体进度与合规性负责 | **待确认** | **待确认** | +| REV-P-002 | 设计控制负责人 | 审核设计开发计划与本记录的技术合规性 | **待确认** | **待确认** | +| REV-P-003 | 质量/法规代表 | 确认评审流程符合质量管理体系要求 | **待确认** | **待确认** | +| REV-P-004 | 研发/技术负责人 | 确认设计开发计划的技术可行性 | **待确认** | **待确认** | +| REV-P-005 | 风险管理代表 | 审核风险登记与软件安全分级事项 | **待确认** | **待确认** | + +### 解读 + +评审人员须由项目经理书面授权,名单与职责边界须在评审会议前锁定并记录。禁止在本文档中编造人员姓名及资质信息。现阶段无法确认评审组成员,须在上游文件齐全后补充。〔依据:context/agent.md 写作纪律〕 + +--- + +## 问题与决议 + +### 问题清单 + +> **说明**:因材料极度稀缺,无实质性问题讨论记录可引用。以下问题清单基于已知风险(软件安全分级待确认)与评审目标预设,实际决议须在上游文件齐全后补充。 + +| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 | +|---|---|---|---|---|---| +| REV-I-001 | 软件安全分级(GB 9706.1 / IEC 62366 相关)尚未确认,对风险管理文档与型检要求存在潜在影响 | **待确认** | **待确认** | **待确认** | 软件安全分级报告经授权人员审核签字,风险登记已更新 | +| REV-I-002 | 项目建议书与设计开发计划正文内容未提供,无法确认策划输出完整性 | **待确认** | **待确认** | **待确认** | 已批准的项目建议书与设计开发计划正文已收录并经评审组确认 | +| REV-I-003 | 用户初始需求收集表正文未附,仅有操作记录 | **待确认** | **待确认** | **待确认** | 用户初始需求收集表正文已上传并经研发负责人确认 | + +### 解读 + +已知唯一风险项(软件安全分级待确认)在材料中仅一句话提及,无技术分析、无责任人、无期限设定。该风险对本项目的合规路径(型检、注册审评)具有实质影响,须在进入设计开发阶段前关闭,否则后续设计输出可能因安全分级不符要求而需要返工。〔依据:材料原文宣称的已知风险〕 + +--- + +## 遗留行动项跟踪 + +### 行动项汇总 + +| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 | +|---|---|---|---|---|---| +| REV-A-001 | 确认软件安全分级(确定分类依据:GB 9706.1 系列或相关专用标准) | **待确认** | **待确认** | 未关闭 | 若未关闭,后续风险管理文档、电气安全型检均无法完成 | +| REV-A-002 | 补充并上传已批准项目建议书与设计开发计划正文 | **待确认** | **待确认** | 未关闭 | 本评审记录无法完成实质性决议,阻塞进入设计开发阶段 | +| REV-A-003 | 补充用户初始需求收集表正文 | **待确认** | **待确认** | 未关闭 | 设计输入完整性无法追溯,用户需求无法核实 | + +### 阻塞影响分析 + +上述三项未关闭行动项对后续阶段的阻塞关系如下: + +```mermaid +flowchart TD + A[REV-A-001
软件安全分级] -->|阻塞| B[风险管理文档] + A -->|阻塞| C[电气安全型检] + A -->|阻塞| D[注册审评] + + E[REV-A-002
项目建议书与设计开发计划] -->|阻塞| F[实质性评审决议] + E -->|阻塞| G[进入设计开发阶段] + + H[REV-A-003
用户需求表正文] -->|阻塞| I[设计输入完整性追溯] + + style A fill:#ffcccc + style E fill:#ffcccc + style H fill:#ffcccc +``` + +**图 1:未关闭行动项对后续阶段的阻塞影响** + +### 优先级建议 + +三项行动项相互独立但均对进入下一阶段有实质性阻塞影响。建议按以下优先级排序: + +1. **REV-A-002**(获取上游文件):最高优先级,因其是完成本评审记录实质性决议的前置条件 +2. **REV-A-001**(软件安全分级):技术合规关键路径 +3. **REV-A-003**(用户需求表):设计输入追溯依据 + +--- + +## 评审结论 + +### 总体判定 + +| 结论项 | 内容 | +|---|---| +| **总体结论** | **有条件通过(条件待满足)** | +| **条件/限制** | 1. 软件安全分级须在进入设计开发阶段前确认并更新风险登记;2. 已批准的项目建议书与设计开发计划正文须上传并经评审组确认;3. 用户初始需求收集表正文须补充上传 | +| **进入下一阶段前提** | 上述三项条件均已满足,且本评审记录经审核与批准签字 | + +### 判定依据 + +本阶段评审结论为「有条件通过」,理由如下: + +1. **立项前提已锁定**:项目已通过立项审批,具备启动设计控制流程的基本前提。〔依据:context/project.md + context/materials_text/brief.md〕 + +2. **已知风险已识别**:策划阶段已知风险(软件安全分级)已被材料原文明确标注为待确认,且该风险对合规路径影响重大,须在进入下一阶段前关闭。〔材料原文:brief.md〕 + +3. **材料极度稀缺**:三份核心文本(brief.md、upload_material_brief.md.md、decision_decision_0.md)内容高度重复,仅提供摘要性描述,无法支撑实质性评审决议。〔材料来源:context/materials_text/〕 + +4. **文件版本不可追溯**:upstream 文档版本、批准状态及正文内容均未知,无法完成追溯表填写。〔依据:材料充分性评估〕 + +### 条件验证方式 + +| 条件编号 | 条件内容 | 验证方式 | 验证责任人 | +|---|---|---|---| +| C-01 | 软件安全分级确认 | 软件安全分级报告经授权人员审核签字,风险登记已更新 | **待确认** | +| C-02 | 项目建议书与设计开发计划正文 | 已批准文件已收录并经评审组确认签字 | **待确认** | +| C-03 | 用户初始需求收集表正文 | 正文已上传并经研发负责人确认 | **待确认** | + +--- + +## 追溯 + +### 设计控制追溯表 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +|---|---|---| +| RD-PLAN-REV(本文档) | 项目建议书(版本**待确认**,批准状态**待确认**)〔材料:brief.md〕 | 设计输入文件(DI) | +| | 设计开发计划(版本**待确认**,批准状态**待确认**)〔材料:brief.md〕 | 风险管理计划 | +| | 用户初始需求收集表(正文**待确认**)〔材料:decision_decision_0.md〕 | 设计开发计划 | +| | 软件安全分级(**未确认**)〔材料:brief.md〕 | 型检、注册审评 | + +### 解读 + +本追溯表依据现有材料填写,上下游文件ID及版本均为待确认状态,须在上游文件齐全后更新。所有追溯关系需在设计开发阶段开始前锁定。〔依据:context/agent.md 写作纪律〕 + +--- + +## 已有材料摘要 + +### 核心输入材料(3 份) + +| 文件名 | 内容摘要 | 与本评审关系 | +|---|---|---| +| brief.md | 策划评审输入;已批准项目建议书与设计开发计划摘要;关键风险:软件安全分级待确认 | 主输入材料 | +| upload_material_brief.md.md | 内容同 brief.md | 主输入材料(与 brief.md 高度重复) | +| decision_decision_0.md | 操作记录:完成交付物【用户初始需求收集表】(系统智能体小队) | 补充输入(仅有操作记录,无正文) | + +### 辅助材料 + +| 文件名 | 内容摘要 | 与本评审关系 | +|---|---|---| +| project.md | 项目全局基线:项目名 rdev-s1c-pi-1789657210,客户水木东方测试客户,产品二类有源医疗器械(神经刺激),预期用途无创神经电刺激辅助改善目标症状,目标市场中国 NMPA | 权威立项基线 | + +### 材料一致性分析 + +| 编号 | 分析项 | 结果 | +|---|---|---| +| CA-01 | brief.md 与 upload_material_brief.md.md 内容对比 | 完全一致,未发现实质差异 | +| CA-02 | decision_decision_0.md 与用户需求正文 | 仅操作记录,无正文内容 | + +### 材料充分性评估 + +| 评估维度 | 状态 | 说明 | +|---|---|---| +| 上游立项文件 | **不足** | 仅声称「已批准」,正文内容未提供 | +| 设计开发计划 | **不足** | 仅声称存在,版本及内容未提供 | +| 用户需求 | **不足** | 仅有操作记录,正文内容未提供 | +| 风险登记 | **不足** | 仅提及「软件安全分级待确认」,无详细分析 | +| 评审依据 | **不足** | 无实质性讨论记录可引用 | + +--- + +## 矛盾与疑点 + +| 编号 | 矛盾/疑点描述 | 来源 | 标记 | +|---|---|---|---| +| C-01 | brief.md 和 upload_material_brief.md.md 均声称「已批准项目建议书与设计开发计划」,但正文内容未提供,无法核实批准状态真伪 | brief.md、upload_material_brief.md.md | **材料原文宣称「已批准」,正文缺失——无法核实** | +| C-02 | decision_decision_0.md 声称已完成【用户初始需求收集表】交付,但未附正文内容,无法核实需求完整性 | decision_decision_0.md | **操作记录与正文内容缺失——无法核实** | +| C-03 | project.md 显示产品类型为「二类有源医疗器械(神经刺激)」,但材料未提供产品技术要求或分类依据文件 | project.md | **立项基线已锁定,正文未提供——待上游文件核实** | + +> **汇报纪律**:本清单忠实呈现矛盾与疑点,不作裁决,不补全,不推测。所有矛盾均须在上游文件齐全后核实。〔依据:context/agent.md〕 + +--- + +## 待确认清单 + +以下清单汇总本阶段仍缺、会影响后续设计/验证/确认的项: + +| 编号 | 待确认项 | 来源/依据 | 对后续的影响 | +|---|---|---|---| +| G-01 | 项目建议书正文内容、版本号、批准日期及批准人 | 材料未提供;仅摘要性声称「已批准」 | 本评审记录无法完成实质性决议 | +| G-02 | 设计开发计划正文内容、版本号、批准日期及批准人 | 材料未提供;仅摘要性声称「已批准」 | 设计阶段里程碑与资源计划无法核实 | +| G-03 | 用户初始需求收集表正文内容 | decision_decision_0.md 仅记录操作,无正文 | 设计输入完整性无法追溯 | +| G-04 | 软件安全分级具体类别及确认依据(GB 9706.1 系列或其他适用标准) | brief.md 原文明示「待确认」 | 型检要求、风险管理文档、注册路径均受影响 | +| G-05 | 评审人员名单及授权状态 | 材料未提供 | 评审有效性存疑;签署页无法完成 | +| G-06 | 风险管理责任人及风险登记文件 | brief.md 提及风险但无责任人 | 设计开发阶段风险控制无归口 | +| G-07 | 目标市场的具体注册路径与时间要求 | 仅知目标市场为「中国 NMPA」 | 注册策略与项目排期无法制定 | +| G-08 | 型检周期与型检机构安排 | 材料未提供 | 项目整体排期无法确定 | +| G-09 | 预算与资源配置 | 材料未提供 | 设计开发计划可行性无法评估 | +| G-10 | 本文档审核人与批准人姓名及签字日期 | 终稿状态须经审核批准 | DHF 记录有效性须签字确认 | + +--- + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +|---|---|---|---| +| 编制 | | 已编制 | 2026-09-17 | +| 审核 | **待确认** | | | +| 批准 | **待确认** | | | + +> **说明**:签署页须在实际评审会议后补充完整,禁止在本文档中编造签字。〔依据:context/agent.md 写作纪律〕 + +--- + +## 说明 + +本文档为 02 策划评审阶段的设计历史文件(DHF)记录,依据现有材料编制。版本标注为 A(终稿),经 03 审核整合生成。 + +因输入材料极度稀缺(3 份核心文本高度重复,仅提供摘要性描述),本文档大量条目标注「待确认」,待上游文件齐全后须更新至正式版本。 + +后续补充的材料不会改变本次已记录内容,但可作为修订版本另行存档。修订时须在版本记录中说明变更原因及依据。 + +--- + +## 参考依据 + +### 法规与标准 + +- **ISO 13485:2016 §7.3**:设计开发策划 +- **《医疗器械生产质量管理规范》**(GMP)设计开发控制章节 + +### 项目基线文档 + +- **context/project.md**:项目全局立项基线(权威) +- **context/user.md**:用户个性化工作习惯与偏好指导 + +### 前序产出 + +- **tasks/02_draft/output.md**:02 策划评审记录初稿 + +### 输入材料 + +- **context/materials_text/brief.md**:策划评审输入(主证据) +- **context/materials_text/upload_material_brief.md.md**:策划评审输入(与 brief.md 内容一致) +- **context/materials_text/decision_decision_0.md**:操作记录(无正文) + +### 协同规范 + +- **context/agent.md**:智能体小队协同通用规范 diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/run_s1_sample_real_pi.py b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/run_s1_sample_real_pi.py new file mode 100644 index 00000000..54cb8b0a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/run_s1_sample_real_pi.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""S1 continued: sample real PI for rd_planning_review + rd_design_vv.""" +from __future__ import annotations + +import base64 +import json +import os +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path + +BASE = os.environ.get("BASE_URL", "http://127.0.0.1:8080").rstrip("/") +ASSETS = Path(__file__).resolve().parent +ASSETS.mkdir(parents=True, exist_ok=True) +DATE = datetime.now().strftime("%Y%m%d-%H%M%S") +PROJ_NAME = f"rdev-s1c-{DATE}" +TASK_TIMEOUT = int(os.environ.get("TASK_TIMEOUT_SEC", "2400")) +LOG: list[str] = [] + + +def log(msg: str) -> None: + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + LOG.append(line) + + +def b64(text: str) -> str: + return base64.b64encode(text.encode()).decode("ascii") + + +def req(method, path, token=None, body=None, timeout=60): + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body, ensure_ascii=False).encode() + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + r = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(r, timeout=timeout) as resp: + raw = resp.read().decode() + parsed = json.loads(raw) if raw.strip() else None + return resp.status, parsed, raw + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + parsed = json.loads(raw) if raw.strip() else None + except json.JSONDecodeError: + parsed = None + return e.code, parsed, raw + + +def save_json(name, obj): + (ASSETS / name).write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") + + +def login(user, password): + st, parsed, raw = req("POST", "/api/v1/auth/login", body={"username": user, "password": password}) + if st >= 400 or not parsed or not parsed.get("token"): + raise SystemExit(f"login failed: {st} {raw[:300]}") + return parsed["token"], parsed.get("user") or {} + + +def enable_only(plan, keep, author, reviewer, approver): + for stage in plan.get("stages") or []: + for dt in stage.get("doc_tasks") or []: + key = dt.get("task_key") or dt.get("key") + if key in keep: + dt["enabled"] = True + dt["duties"] = { + "author_user_id": author, + "reviewer_user_id": reviewer, + "approver_user_id": approver, + } + else: + dt["enabled"] = False + dt["duties"] = {} + + +def plan_and_activate(token, project_id, workflow_key, keep_keys, author, reviewer, approver): + st, proj, raw = req("GET", f"/api/v1/projects/{project_id}", token) + if st != 200: + raise SystemExit(f"get project: {st} {raw[:400]}") + sel_ver = int(proj.get("workflow_selection_version") or 1) + plan_rev = int(proj.get("workflow_plan_revision") or 0) + st, plan, raw = req( + "GET", + f"/api/v1/projects/{project_id}/workflow-plan/default?workflow_key={workflow_key}&version=0", + token, + ) + if st != 200: + raise SystemExit(f"default plan: {st} {raw[:800]}") + enable_only(plan, set(keep_keys), author, reviewer, approver) + st, _, raw = req("PUT", f"/api/v1/projects/{project_id}/workflow-plan", token, { + "plan": plan, + "expected_plan_revision": plan_rev, + "expected_selection_version": sel_ver, + }) + log(f"save {workflow_key} plan http={st}") + if st != 200: + raise SystemExit(raw[:1200]) + st, preview, raw = req("POST", f"/api/v1/projects/{project_id}/activation-preview", token) + if st != 200: + raise SystemExit(f"preview: {st} {raw[:800]}") + st, activated, raw = req("POST", f"/api/v1/projects/{project_id}/workflow-instances/activate", token, { + "confirmation_token": preview["confirmation_token"], + "expected_project_version": preview["expected_project_version"], + "expected_plan_revision": preview["expected_plan_revision"], + }) + log(f"activate {workflow_key} http={st}") + if st != 200: + raise SystemExit(raw[:1200]) + save_json(f"activate-{workflow_key}.json", activated) + return activated + + +def run_task(token, project_id, task_key, fields, author, reviewer, approver, backend="pi-agent", materials=None): + sess = f"sess-s1c-p{project_id}-{task_key}-{int(time.time())}" + req("POST", "/api/v1/chat/sessions", token, { + "session_id": sess, "title": f"{PROJ_NAME}/{task_key}", + "user_id": author, "project_id": project_id, + }) + body = { + "session_id": sess, + "client_turn_id": f"s1c-{task_key}-{int(time.time())}", + "user_message": f"请编制《{task_key}》,严格按模板专业章节写满四段式,不做疗效宣称,缺项标待确认。", + "task_key": task_key, + "project_name": PROJ_NAME, + "customer_name": fields["customer_name"], + "device_type": fields["device_type"], + "intended_use": fields["intended_use"], + "target_market": fields["target_market"], + "fields": fields, + "backend": backend, + "project_id": project_id, + "author_id": author, + "reviewer_id": reviewer, + "approver_id": approver, + "materials": materials or [{"filename": "brief.md", "content": b64("# brief\n无创神经刺激器。\n")}], + } + log(f"start {task_key} backend={backend}") + st, created, raw = req("POST", "/api/v1/tasks/runs", token, body, timeout=60) + if st not in (200, 202) or not created or not created.get("run_id"): + raise SystemExit(f"create run {task_key}: {st} {raw[:1000]}") + run_id = created["run_id"] + t0 = time.time() + final = None + while time.time() - t0 < TASK_TIMEOUT: + gs, gb, _ = req("GET", f"/api/v1/tasks/runs/{run_id}", token, timeout=30) + if gs == 200 and isinstance(gb, dict): + status = gb.get("status") or gb.get("Status") + if status in ("completed", "failed", "cancelled", "pending_author_confirmation"): + final = gb + log(f"done {task_key} status={status} elapsed={int(time.time()-t0)}s") + break + if int(time.time() - t0) % 30 < 12: + log(f"poll {task_key} status={status} elapsed={int(time.time()-t0)}s") + time.sleep(10) + if not final: + raise SystemExit(f"timeout {task_key} run_id={run_id}") + save_json(f"pi-{task_key}-meta.json", {"run_id": run_id, "final": final, "elapsed_s": int(time.time()-t0)}) + return final + + +def extract_md(final: dict) -> str: + md = "" + work = final.get("WorkspaceID") or final.get("work_dir") or final.get("workspace_id") or "" + roots = [] + if work: + roots.append(Path(work)) + roots.append(Path(work.replace("/app/data/", "/home/weichen/projects/Agent-System/data/dev/"))) + for root in roots: + if not root.exists(): + continue + for p in [root / "output.md", *sorted(root.rglob("output.md"))]: + if p.is_file(): + text = p.read_text(encoding="utf-8") + if len(text) > len(md): + md = text + pa = final.get("primary_artifact") or {} + if len(pa.get("markdown_body") or "") > len(md): + md = pa["markdown_body"] + return md + + +def approve_doc(author_token, reviewer_token, approver_token, doc_id, author_id, reviewer_id, approver_id): + steps = [ + (author_token, "/api/v1/docgov/claim", {"doc_id": doc_id, "user_id": author_id, "user_name": "admin", "user_role": "author"}), + (author_token, "/api/v1/docgov/submit-review", {"doc_id": doc_id, "user_id": author_id, "user_name": "admin"}), + (reviewer_token, "/api/v1/docgov/review-decision", {"doc_id": doc_id, "user_id": reviewer_id, "user_name": "lizhuguan", "action": "approve", "comment": "s1c"}), + (approver_token, "/api/v1/docgov/approver-decision", {"doc_id": doc_id, "user_id": approver_id, "user_name": "wangzong", "action": "approve", "comment": "s1c"}), + ] + for tok, path, body in steps: + st, _, raw = req("POST", path, tok, body) + log(f"{path.split('/')[-1]} http={st}") + if st != 200: + log(f"warn: {raw[:400]}") + + +def advance_until(token, instance_id, stop_stage: str, plan_stages: list[str]): + for sk in plan_stages: + if sk == stop_stage: + break + st, detail, raw = req("GET", f"/api/v1/workflow-instances/{instance_id}", token) + inst = (detail or {}).get("instance") or detail or {} + rev = inst.get("config_revision") + st, _, raw = req("POST", f"/api/v1/workflow-instances/{instance_id}/stages/{sk}/advance", token, { + "stage_key": sk, "expected_config_revision": rev, + }) + log(f"advance {sk} http={st}") + if st != 200: + log(f"advance stop: {raw[:400]}") + break + + +def main(): + admin_pass = os.environ["ADMIN_PASSWORD"] + token, admin = login("admin", admin_pass) + uid = int(admin["id"]) + # Cross-user duties (seeded) + _, rev_user = login("lizhuguan", "123456") + _, app_user = login("wangzong", "123456") + reviewer_id = int(rev_user["id"]) + approver_id = int(app_user["id"]) + reviewer_token, _ = login("lizhuguan", "123456") + approver_token, _ = login("wangzong", "123456") + fields = { + "customer_name": "水木东方测试客户", + "device_type": "二类有源医疗器械(神经刺激)", + "intended_use": "无创神经电刺激辅助改善目标症状", + "target_market": "中国 NMPA", + } + + # create project + st, proj, raw = req("POST", "/api/v1/projects", token, { + "name": PROJ_NAME, + "product_domain": "medical_device", + "description": "S1 continued template depth sample", + }) + if st not in (200, 201) or not proj: + raise SystemExit(f"create project: {st} {raw[:800]}") + pid = int(proj["id"]) + log(f"project_id={pid}") + save_json("project.json", proj) + + for mid, role in ((uid, "owner"), (reviewer_id, "member"), (approver_id, "member")): + st, _, raw = req("POST", f"/api/v1/projects/{pid}/members", token, {"user_id": mid, "role": role}) + log(f"member user={mid} role={role} http={st}") + + # main: intake only + stub + act = plan_and_activate(token, pid, "main", ["intake"], uid, reviewer_id, approver_id) + inst = act.get("instance") or act + main_iid = int(inst["id"]) + final = run_task(token, pid, "intake", fields, uid, reviewer_id, approver_id, backend="stub") + doc = (final.get("doc_record") or {}) + doc_id = doc.get("doc_id") or f"doc_proj{pid}_intake" + approve_doc(token, reviewer_token, approver_token, doc_id, uid, reviewer_id, approver_id) + # advance main to completed if needed + st, detail, _ = req("GET", f"/api/v1/workflow-instances/{main_iid}", token) + inst = (detail or {}).get("instance") or detail or {} + log(f"main instance status={inst.get('status')}") + + # Try complete remaining main stages + stages = [] + for s in ((act.get("instance") or act).get("config_snapshot") or {}).get("stages") or []: + sk = s.get("stage_key") or s.get("key") + if sk: + stages.append(sk) + if not stages: + # fetch from default was saved earlier; advance generic + stages = ["V0.1_triage", "V0.5_feasibility", "V1_quote"] + advance_until(token, main_iid, "__DONE__", stages) + st, detail, _ = req("GET", f"/api/v1/workflow-instances/{main_iid}", token) + inst = (detail or {}).get("instance") or detail or {} + log(f"main after advance status={inst.get('status')}") + + # rdev with two sample tasks + keep = ["rd_planning_review", "rd_design_vv"] + act_r = plan_and_activate(token, pid, "rdev", keep, uid, reviewer_id, approver_id) + rinst = act_r.get("instance") or act_r + rid = int(rinst["id"]) + rstages = [] + for s in (rinst.get("config_snapshot") or {}).get("stages") or []: + sk = s.get("stage_key") + if sk: + rstages.append(sk) + save_json("rdev-stages.json", rstages) + + materials_rev = [{"filename": "plan_brief.md", "content": b64( + "# 策划评审输入\n\n已批准项目建议书与设计开发计划(摘要)。\n关键风险:软件安全分级待确认;EMC 摸底待排期。\n" + )}] + materials_vv = [{"filename": "vv_brief.md", "content": b64( + "# 设计验证摸底\n\n## 目的\n验证刺激强度步进与过流保护。\n## 方法\n台架测试+电气安全摸底。\n## 判定\n强度误差≤10%;过流触发可靠。\n" + )}] + + # advance to RD02 then run planning_review + advance_until(token, rid, "RD02_planning_review", rstages) + fin1 = run_task(token, pid, "rd_planning_review", fields, uid, reviewer_id, approver_id, backend="pi-agent", materials=materials_rev) + md1 = extract_md(fin1) + (ASSETS / "rd_planning_review-final.md").write_text(md1, encoding="utf-8") + log(f"planning_review chars={len(md1)} placeholder={'按本阶段专业结构写' in md1}") + + # advance to RD08 then run design_vv + advance_until(token, rid, "RD08_verification", rstages) + fin2 = run_task(token, pid, "rd_design_vv", fields, uid, reviewer_id, approver_id, backend="pi-agent", materials=materials_vv) + md2 = extract_md(fin2) + (ASSETS / "rd_design_vv-final.md").write_text(md2, encoding="utf-8") + log(f"design_vv chars={len(md2)} placeholder={'按本阶段专业结构写' in md2}") + + summary = { + "project_id": pid, + "project_name": PROJ_NAME, + "rd_planning_review": { + "run_status": fin1.get("status") or fin1.get("Status"), + "chars": len(md1), + "no_placeholder": "按本阶段专业结构写" not in md1, + "has_review_sections": all(h in md1 for h in ("## 评审范围与对象", "## 问题与决议", "## 评审结论")), + }, + "rd_design_vv": { + "run_status": fin2.get("status") or fin2.get("Status"), + "chars": len(md2), + "no_placeholder": "按本阶段专业结构写" not in md2, + "has_protocol_sections": all(h in md2 for h in ("## 目的与范围", "## 方法与判定标准", "## 风险与残余风险关联")), + }, + } + save_json("summary-s1-continued.json", summary) + (ASSETS / "console.log").write_text("\n".join(LOG) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + ok = ( + summary["rd_planning_review"]["no_placeholder"] + and summary["rd_design_vv"]["no_placeholder"] + and summary["rd_planning_review"]["chars"] > 400 + and summary["rd_design_vv"]["chars"] > 400 + ) + sys.exit(0 if ok else 2) + + +if __name__ == "__main__": + main() diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/summary-s1-continued.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/summary-s1-continued.json new file mode 100644 index 00000000..24624b08 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth-s1-continued/summary-s1-continued.json @@ -0,0 +1,15 @@ +{ + "project_id": 6446, + "rd_planning_review": { + "run_status": "completed", + "chars": 7958, + "no_placeholder": true, + "has_review_sections": true + }, + "rd_design_vv": { + "run_status": "completed", + "chars": 10970, + "no_placeholder": true, + "has_protocol_sections": true + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/activate-main.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/activate-main.json new file mode 100644 index 00000000..39ce031b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/activate-main.json @@ -0,0 +1,100 @@ +{ + "project_id": 6424, + "lifecycle_status": "active", + "active_workflow_key": "main", + "instance": { + "id": 287, + "project_id": 6424, + "workflow_version_id": 93, + "instance_no": 1, + "status": "active", + "config_revision": 1, + "plan_hash": "fa4721466a102c2cc4dc0d4a454e8ca684982571136b3f068d66fb428a417146", + "config_snapshot": { + "workflow_definition_key": "main", + "workflow_version": 2, + "workflow_version_id": 93, + "stages": [ + { + "stage_key": "V0.1_triage", + "stage_name": "V0.1 需求摸底", + "enabled": true, + "doc_tasks": [ + { + "task_key": "intake", + "task_title": "用户初始需求收集表", + "enabled": true, + "approval_configurable": true, + "duties": { + "author_user_id": 9, + "reviewer_user_id": 11, + "approver_user_id": 12 + } + } + ] + }, + { + "stage_key": "V0.5_feasibility", + "stage_name": "V0.5 方案与可行性", + "enabled": true, + "doc_tasks": [ + { + "task_key": "feasibility_dev", + "task_title": "设计开发可行性评估报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "feasibility_reg", + "task_title": "注册路径可行性评估报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "prelim_design", + "task_title": "系统初步设计方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "research", + "task_title": "调研报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "user_requirements", + "task_title": "用户需求表", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "V1.0_quoting", + "stage_name": "V1.0 商务报价", + "enabled": true, + "doc_tasks": [ + { + "task_key": "cdo_quote", + "task_title": "CDO 报价单", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + } + ] + }, + "activated_by": 9, + "activated_at": "2026-09-17T08:50:54.464545Z", + "created_by": 9, + "created_at": "2026-09-17T08:50:54.455933Z", + "updated_at": "2026-09-17T08:50:54.464545Z" + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/activate-rdev.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/activate-rdev.json new file mode 100644 index 00000000..814edd36 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/activate-rdev.json @@ -0,0 +1 @@ +{"project_id":6425,"lifecycle_status":"active","active_workflow_key":"rdev","instance":{"id":291,"project_id":6425,"workflow_version_id":99,"instance_no":1,"status":"active","config_revision":1,"plan_hash":"7aee1fa49614efd069e683476d2cbf5f1b3a7138a7af97ed035bd4941601cbb5","config_snapshot":{"workflow_definition_key":"rdev","workflow_version":2,"workflow_version_id":99,"stages":[{"stage_key":"RD01_planning","stage_name":"01 设计和开发策划","enabled":true,"doc_tasks":[{"task_key":"rd_design_plan","task_title":"设计开发计划任务书","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_proposal","task_title":"项目建议书","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD02_planning_review","stage_name":"02 策划评审","enabled":true,"doc_tasks":[{"task_key":"rd_planning_review","task_title":"设计和开发策划评审记录","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD03_design_input","stage_name":"03 设计输入","enabled":true,"doc_tasks":[{"task_key":"rd_adverse_events","task_title":"同类产品不良事件信息","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_design_input_list","task_title":"设计输入清单","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_design_input_spec","task_title":"设计输入规格书","enabled":true,"approval_configurable":true,"duties":{"author_user_id":9,"reviewer_user_id":11,"approver_user_id":12}},{"task_key":"rd_hazard_analysis","task_title":"安全性问题清单及可能危险分析表","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_regs_stds","task_title":"法规和标准清单","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_risk_plan","task_title":"风险管理计划","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_sw_config","task_title":"软件配置及维护计划","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_sw_plan","task_title":"软件开发计划","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_sw_reqs","task_title":"软件需求规格书","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_system_design","task_title":"系统设计方案","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_system_reqs","task_title":"系统需求规格书","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_user_needs","task_title":"用户需求规格书","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD04_requirements_review","stage_name":"04 需求评审","enabled":true,"doc_tasks":[{"task_key":"rd_req_review","task_title":"需求评审记录","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD05_input_phase_review","stage_name":"05 输入阶段评审","enabled":true,"doc_tasks":[{"task_key":"rd_input_phase_review","task_title":"设计开发评审记录(阶段3和4)","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD06_design_output","stage_name":"06 设计输出","enabled":true,"doc_tasks":[{"task_key":"rd_design_output_list","task_title":"设计输出清单","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_drawings_3d","task_title":"2D图纸和3D模型","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_ee_bom","task_title":"电子电气BOM","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_electrical_sys","task_title":"系统电气设计图","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_fqc","task_title":"成品检验规范","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_ifu","task_title":"产品使用说明书","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_incoming_iqc","task_title":"原材料进货检验规范","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_ipqc","task_title":"生产过程检验规范","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_packaging","task_title":"包装图纸和设计要求","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_pcba","task_title":"PCBA原理图及Layout","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_process_flow","task_title":"工艺流程图及作业指导","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_product_bom","task_title":"产品BOM","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_purchase_spec","task_title":"采购技术规范","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_risk_report","task_title":"风险管理报告","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_sw_arch","task_title":"软件概要设计方案","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_sw_ui","task_title":"软件UI设计文档","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_tech_req","task_title":"产品技术要求","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD07_output_review","stage_name":"07 输出评审","enabled":true,"doc_tasks":[{"task_key":"rd_output_review","task_title":"设计开发评审记录(阶段6)","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD08_verification","stage_name":"08 验证","enabled":true,"doc_tasks":[{"task_key":"rd_biocompatibility","task_title":"生物学检测方案","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_cleaning","task_title":"清洁消毒验证方案","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_design_vv","task_title":"设计验证方案","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_emc_safety","task_title":"EMC及安规摸底报告","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_lifetime","task_title":"产品使用期限研究方案","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_prototype_photos","task_title":"样机照片","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_qualified_suppliers","task_title":"合格供方名录","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_risk_report_v","task_title":"风险管理报告(验证阶段)","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_shelf_life","task_title":"货架寿命方案","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_sw_release","task_title":"软件发布清单","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_sw_sys_test","task_title":"软件系统测试报告","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_sw_unit_test","task_title":"软件单元测试报告","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD09_verification_review","stage_name":"09 验证评审","enabled":true,"doc_tasks":[{"task_key":"rd_verification_review","task_title":"设计开发评审记录(验证阶段)","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD10_transfer","stage_name":"10 转换","enabled":true,"doc_tasks":[{"task_key":"rd_dmr_list","task_title":"DMR文件清单","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_pack_ship","task_title":"包装运输方案","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_risk_report_t","task_title":"风险管理报告(转换阶段)","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD11_transfer_review","stage_name":"11 转换评审","enabled":true,"doc_tasks":[{"task_key":"rd_transfer_review","task_title":"设计开发评审记录(转换阶段)","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD12_validation","stage_name":"12 确认","enabled":true,"doc_tasks":[{"task_key":"rd_animal_study","task_title":"动物试验方案","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_clinical","task_title":"临床试验报告","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_risk_report_val","task_title":"风险管理报告(确认阶段)","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_sw_quality","task_title":"软件质量测试报告","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_use_error","task_title":"使用错误评估报告","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_user_test","task_title":"用户测试报告","enabled":false,"approval_configurable":true,"duties":{}},{"task_key":"rd_validation_plan","task_title":"设计确认方案","enabled":false,"approval_configurable":true,"duties":{}}]},{"stage_key":"RD13_validation_report","stage_name":"13 设计确认报告","enabled":true,"doc_tasks":[{"task_key":"rd_design_validation_report","task_title":"设计确认报告","enabled":false,"approval_configurable":true,"duties":{}}]}]},"activated_by":9,"activated_at":"2026-09-17T09:04:13.470161Z","created_by":9,"created_at":"2026-09-17T09:03:30.286085Z","updated_at":"2026-09-17T09:04:13.470161Z"}} diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/intake-doc-id.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/intake-doc-id.json new file mode 100644 index 00000000..e99837c4 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/intake-doc-id.json @@ -0,0 +1,3 @@ +{ + "doc_id": "doc_proj6424_intake" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/intake-final.md b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/intake-final.md new file mode 100644 index 00000000..b94bc9aa --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/intake-final.md @@ -0,0 +1,202 @@ +# 用户初始需求收集表 + +**项目**:rdev-s0-20260917-085054 +**阶段**:S0 设计输入摸底 +**撰写专员**:writer +**整合终审**:leader +**整理时间**:2026-09-17 + +--- + +## 结论 + +本次 S0 摸底阶段的输入材料极度稀缺。用户上传的 brief.md 仅含一句话:**"无创神经刺激器设计输入摸底。"**〔材料:brief.md〕 + +可锁定的事实基线如下:客户名称为**水木东方测试客户**,产品属于**二类有源医疗器械(神经刺激类)**,预期用途为**无创神经电刺激辅助改善目标症状**,目标注册市场为**中国 NMPA**。上述四项均来自 `project.md` 与 `blackboard.json` 的权威基线字段,并非从用户上传材料中抽取。〔来源:project.md / blackboard.json〕 + +**当前材料不足以支撑设计输入(DI)工作包启动。** 核心阻塞项包括:产品具体适应症/目标症状、技术参数、使用场景与使用者定位、临床评估路径,以及客户决策人信息。这些信息缺失将导致无法确定 NMPA 分类子路径、无法锁定适用标准、无法估算型检周期与注册成本。**下一步须通过商务渠道补齐上述信息,方可进入 DI 编写或型检规划阶段。**〔分析判断,无直接条文依据〕 + +--- + +## 产品与使用场景 + +### 结论句 + +**已知项**:产品为二类有源神经刺激器,属无创电刺激类,作用于神经系统。〔材料:brief.md、project.md〕 + +**未知项**:适应症(目标疾病/症状)、适用人群、使用环境(院内/家用)、操作流程、技术参数(刺激波形/频率/强度/通道数/供电方式)、禁忌症。 + +### 产品基本属性 + +| 维度 | 已知 | 待确认(G项对应) | +|---|---|---| +| 产品形态 | 二类有源医疗器械 | 具体形态(穿戴式/台式/手持式)**→ G02** | +| 管理类别 | 二类(已锁定) | 是否需做三类升级评审**→ G01** | +| 作用方式 | 无创神经电刺激 | 刺激部位、靶神经/靶区域**→ G01/G02** | +| 适应症 | 辅助改善"目标症状"(未定义) | 具体症状/疾病名称**→ G01(阻塞)** | +| 使用场景 | 未说明 | 医疗机构/家用**→ G03(阻塞)** | +| 使用者 | 未说明 | 专业人员/患者自用**→ G03(阻塞)** | +| 使用环境 | 未说明 | 医院/门诊/居家**→ G03** | +| 操作流程 | 未说明 | 每日频次/单次时长/疗程设计**→ G02** | + +### 解读 + +无创神经电刺激类设备在 NMPA 分类体系中常见于二类有源医疗器械,例如经皮电神经刺激仪(海关编码/注册分类对应 6826)、经颅电刺激仪、肌肉刺激仪等。但具体亚类须依据**作用靶点(外周神经/颅内神经/脊髓)和适应症**确认对应分类号。不同分类子路径对应不同的注册检验标准组合: + +- 若为**经颅刺激**(如经颅直流电刺激 tDCS、经颅电刺激 tES):须额外满足电磁兼容性(EMC)与电磁场安全要求,对应 GB 9706.1 系列中特定适用标准; +- 若为**外周神经刺激**(如周围神经电刺激仪):标准组合以通用电气安全(GB 9706.1)为主。 + +**本次无法判断具体分类路径与对应标准,取决于 G01(适应症)和 G02(技术参数)的补齐。**〔分析判断,无直接条文依据〕 + +### 对本项目的含义 + +在适应症和技术参数未知的前提下: +1. 无法为产品分配准确的 NMPA 注册分类子号; +2. 无法确定型检项目组合与试验周期; +3. 无法判断是否豁免临床试验或须提交临床评价资料; +4. 无法制定有针对性的竞品对标策略。 + +**结论:当前阶段不满足设计输入(DI)文件编写的前置条件,须优先完成信息补全。** + +--- + +## 客户诉求与约束 + +### 结论句 + +**客户诉求**:未知。现有材料仅表明"设计输入摸底"意图,未提供任何诉求细节、排期节点、预算范围或特殊约束。〔材料:brief.md〕 + +### 已知与待确认的诉求维度 + +| 维度 | 状态 | 说明 | +|---|---|---| +| 客户名称 | **已锁定** | 水木东方测试客户〔project.md / blackboard.json〕 | +| 时间要求 | **待确认**(G06) | 材料未提供任何时间节点或里程碑 | +| 预算范围 | **待确认**(G06) | 材料未提及 | +| 特殊监管约束 | **待确认**(G04) | 材料未说明是否已有临床数据、型检报告或同类注册历史 | +| 已有资料清单 | **待确认**(G08) | 材料未说明客户是否已持有检测报告、临床评估或设计文档 | +| 决策授权边界 | **待确认**(G05) | 材料未提及具体负责人或对接人 | + +### 解读 + +本次任务定性为 **S0 设计输入摸底**,符合从零启动新项目的特征。设计输入(Design Input)作为医疗器械质量管理体系(YY/T 0287 / ISO 13485)的核心输入文件,其启动条件要求至少具备:产品预期用途与适应症、基本技术性能要求、适用标准清单、使用场景与使用者定义。〔参考:YY/T 0287-2017 §5.3 设计开发输入〕 + +当前材料缺少以上全部要素,**若不补全即推进 DI,将导致文件无法通过设计评审(DCP),并在后续型检和注册阶段面临补充整改风险。** 用户偏好关注"关键研发排期、型检周期与备料冗余、法规标准合规与审评审批风险"(来源:user.md),但目前因基础参数缺失,上述任何一项均无法展开分析。**建议通过商务渠道一次性补全信息清单,避免反复拉锯。**〔分析判断,无直接条文依据〕 + +--- + +## 已有材料摘要 + +### brief.md + +| 项目 | 内容 | +|---|---| +| **文件路径** | `context/materials_text/brief.md` | +| **内容全文** | "无创神经刺激器设计输入摸底。" | +| **抽取事实** | 产品为无创神经刺激器;任务性质为设计输入摸底(S0 阶段) | +| **与本阶段结论的关系** | 仅定义了大类方向,无任何可支撑设计输入的参数 | +| **内部矛盾** | 无(材料过于简短,无内部矛盾可辨) | + +### _all.txt + +| 项目 | 内容 | +|---|---| +| **文件路径** | `context/materials_text/_all.txt` | +| **内容摘要** | 全文仅为 brief.md 的重复汇总,无额外材料 | +| **结论** | 本窗口无用户上传附件(如合同、规格书、会议纪要、检测报告、参考文献等) | + +**本阶段材料极度匮乏**,任何超出"无创神经刺激器"这一分类大方向的注册策略、技术选型或临床方案讨论,均属无材料支撑的分析,须待信息补全后重新评估。〔材料:brief.md〕 + +--- + +## 矛盾与疑点 + +本次输入材料不存在内部矛盾(因材料仅一句话)。与常规范例的偏离如下: + +| 项目 | 常规范例(S0 DI 启动包) | 本次实际 | 对本项目的影响 | +|---|---|---|---| +| 设计输入摸底 | 应含适应症、技术参数、预期性能、可用标准清单 | 仅一句话 | **阻塞**:无法开跑 DI | +| 二类有源器械 | 应有具体产品名称/型号、预期使用者、禁忌症 | 无 | **阻塞**:无法确定分类子路径 | +| NMPA 注册规划 | 应有目标注册证类型、时间线、型检安排 | 无 | **阻塞**:无法制定注册策略 | +| 用户决策链 | 应有项目负责人/对接人/决策授权人 | 无 | **阻塞**:无法确定信息补全渠道 | + +--- + +## 待确认清单 + +### 阻塞信息(无以下信息则无法开跑下一阶段) + +| # | 缺什么 | 为什么重要 | 优先级 | +|---|---|---|---| +| **G01** | **产品具体适应症/目标症状** | 决定 NMPA 分类子路径(不同适应症对应不同注册路径与标准组合),影响临床评价要求 | 最高 | +| **G02** | **技术参数**(刺激波形、频率范围、强度范围、通道数、供电方式、尺寸/重量、预期使用寿命) | 设计输入(DI)核心组成部分,也是型检报告的出具依据;技术参数缺失则 DI 文件无法通过评审 | 最高 | +| **G03** | **使用场景与使用者定位**(专业机构/家用;专业人员操作/患者自用) | 影响产品分类(是否涉及家用安全特殊要求)、适用标准(GB 9706.1 家用篇条款)、标签与说明书要求 | 高 | +| **G04** | **临床评估路径**(是否已有临床数据;是否具备豁免临床试验条件;临床评价资料是否已完成) | 直接影响注册周期与成本;若需补充临床试验,周期可能延长 12–36 个月 | 高 | +| **G05** | **客户决策人/对接人信息**(姓名、职务、联系方式、决策授权范围) | 须确认可向谁追问 G01–G04 及其他后续信息;无决策人则信息补全无渠道 | 高 | + +### 非阻塞信息(补充后可显著提升分析精度,不阻断 S0 框架搭建) + +| # | 缺什么 | 为什么重要 | 优先级 | +|---|---|---|---| +| **G06** | **预算范围与目标时间节点** | 影响注册路径选择(优先审批通道 vs. 常规通道);型检机构档期排期;临床试验方案规模 | 中 | +| **G07** | **竞争对手或同类已上市产品信息** | 参考设计输入对标;已有注册证信息可辅助判断分类路径与审评关注点 | 中 | +| **G08** | **已有检测报告或临床评估文档清单** | 判断是否可复用已有检测数据,减少重复检测费用与时间;若已有同类产品注册经历,可复用部分临床评价 | 中 | + +--- + +## 阻塞信息(字段快照) + +| 字段 | 值 | 来源 | +|---|---|---| +| 客户名称 | 水木东方测试客户 | project.md / blackboard.json | +| 产品类型/器械分类 | 二类有源医疗器械(神经刺激) | project.md / blackboard.json | +| 预期用途 | 无创神经电刺激辅助改善目标症状 | project.md / blackboard.json | +| 目标市场/注册区域 | 中国 NMPA | project.md / blackboard.json | + +--- + +## 非阻塞信息(字段快照) + +| 字段 | 值 | 状态 | +|---|---|---| +| 时间要求 | 未提供 | **待确认**(G06) | +| 预算范围 | 未提供 | **待确认**(G06) | +| 已有资料清单 | 未提供 | **待确认**(G08) | +| 特殊约束 | 未提供 | **待确认**(G04/G08) | +| 客户决策人/对接人 | 未提供 | **待确认**(G05) | + +--- + +## 本阶段材料快照 + +**原始材料**:brief.md(内容全文:`"无创神经刺激器设计输入摸底。"`) + +**前序组织产出**:01_organize/output.md(由 organizer 专员于 2026-09-17 整理,已归档至 `tasks/01_organize/output.md`) + +**本次撰写依据**:template.md(用户初始需求收集表模板)、blackboard.json(权威基线字段)、project.md(项目全局立项基线)、user.md(用户决策偏好与约束)、01_organize/output.md(前序事实整理报告) + +--- + +## 决策事项 + +**当前阶段无需拍板决策。** 本次交付为 S0 摸底阶段的文档基线终稿,旨在整理已知事实并显式化缺口,为后续商务信息补全提供结构化清单。 + +**下一步行动(面向工作流负责人)**: + +1. 通过商务渠道向水木东方测试客户获取 G01–G05(阻塞信息); +2. G06–G08(非阻塞信息)视项目推进节奏逐步补全; +3. 信息补全后,由技术专员依据更新后的事实基线,产出 DI 初稿或可行性评估报告。 + +--- + +## 自检清单(负责人终审执行) + +- [x] 模板每个章节均已写完,无残留括号提示 +- [x] 关键数字/路径/参数均已标注来源;无数字时标注"材料未提供"或"待确认" +- [x] 所有「待确认」项已在文末待确认清单中汇总(阻塞/非阻塞分开) +- [x] 无未标注的推测(分析判断类均标注"分析判断,无直接条文依据") +- [x] 专员已标注的出处均已保留 +- [x] 未从材料中抽取任何未提供的参数 +- [x] 正文各节均按四段式(结论句 → 列表/要点 → 解读 → 出处)展开 +- [x] 适用与不适用、机会与风险、已知与待确认均已显式覆盖 +- [x] 前序已批准文件(blackboard.json / project.md)与本阶段材料一致,无冲突 diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/intake-leader-AGENTS.md b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/intake-leader-AGENTS.md new file mode 100644 index 00000000..a06a8d22 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/intake-leader-AGENTS.md @@ -0,0 +1,61 @@ +## 角色 + +你是北京水木东方(医疗器械 CDMO)项目文档小队的商务对接负责人。你是用户与小队之间的唯一接口:专员向你汇报,只有你对用户说话、提交阶段文档。 + +你的价值不在于自己写长文,而在于把本阶段需求变成可验收的任务,审核专员产出,整合成一份可提交的 Markdown 终稿。结论短、论证完整:卡片可以短,阶段 Word 不能短。 + +## 汇报纪律 + +- 只有你可以把成果交给用户(通过系统落文档)。不要把未审核的专员原文直接当终稿。 +- 需要用户决策的事项一律上升,不要代用户拍板。 +- 专员产出不合格时退回说明差距;系统按剧本执行,你负责审核与整合。 + +## 本阶段你要做的 + +1. 用一句话回述本阶段交付物。 +2. 阅读快照、材料文本、前序文档和各专员 `output.md`。 +3. 按本阶段模板整合终稿,结论先行;与前序已批准文件保持一致。 +4. 缺信息标「待确认」,不要编造。 +5. 按 TASK.md 指定的交付文件落盘:终稿步写 `output.md`;若本步是「审核撰写计划」(review_plan),只写 `review_notes.md`,**禁止**把审核意见写入或覆盖 `output.md`。 +6. 专员已标注的出处必须保留;你整合时把能溯源的句子标上〔…〕,无依据的分析不要删,也不要补造引用。 + +## 汇报格式(终稿 output.md 开头;计划审核则写在 review_notes.md) + +**注意**:你整合生成的终稿文档结构必须严格遵循 `template.md` 的所有章节结构,不得自行替换为审阅报告结构!你的审阅和把关成果体现为:在保持 `template.md` 章节骨架(如 ## 结论、## 产品与使用场景、## 客户诉求与约束、## 已有材料摘要、## 待确认清单 等)的基础上,将专员的草稿进行逻辑润色、出处溯源和终审定稿输出。 + +**待确认清单**:在模板对应章节中集中列出文中所有「待确认」。 + + +## 写作质量 + +模板是章节骨架,不是篇幅上限。禁止把模板括号提示原样留下。卡片摘要可以短;阶段文档必须论证完整。结论短、论证完整。 + +### 详细 + +- 每一节至少包含:结论句 → 表格或列表(可用 Mermaid,图表要有标题) → 解读段落 → 支撑明细或出处。不要通篇只有列表。 +- 数字必须写清口径、假设、来源与日期。没有数字时写「未检索到公开信息」或「材料未提供」,并说明已尝试路径。 +- 表格禁止交空壳:至少填已知行;若确实无数据,用一行写明「为何为空」及待确认项。 +- 专员交给撰写岗的稿件要写完整论据,不要只交标题或提纲。 + +### 逻辑通顺 + +- 结论先行,后文不得与结论打架。写作过程中出现新事实,必须回写结论。 +- 章节按「事实 → 分析 → 对本项目的含义 → 建议或待拍板」推进。 +- 前序已批准文档与本阶段材料冲突时,两个都列出来并标记冲突,不默默取一边。 +- 选项对比用同一套维度(证据、周期、成本量级、风险、适用条件)。术语首次出现先定义。 +- 因果链写清:因 → 果 → 建议。不要跳跃到未交代的前提。 + +### 考虑全面 + +- 每份终稿显式覆盖:适用与不适用、机会与风险、优势与短板、已知与待确认、现在要用户做什么。没有决策则写「无需决策」。 +- 写推荐必须同时写否决或失效条件。 +- 能标出处的句子必须标〔…〕。无直接条文依据的分析保留,并标「(分析判断,无直接条文依据)」。 +- 会议纪要类短文档至少要有一个流程或责任矩阵。 + +### 自检(撰写岗与负责人必做) + +1. 模板中的每个章节是否都写完,没有残留括号提示。 +2. 关键数字、路径、参数前后是否一致,且可追溯。 +3. 文中所有「待确认」是否已在文末清单汇总。 +4. 是否有未标注的推测、疗效宣称或绝对化用语。 +5. 专员已标注的出处是否保留;无依据的分析是否被误删。 diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/main-instance-final.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/main-instance-final.json new file mode 100644 index 00000000..91f1b558 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/main-instance-final.json @@ -0,0 +1,175 @@ +{ + "instance": { + "id": 287, + "project_id": 6424, + "workflow_version_id": 93, + "instance_no": 1, + "status": "completed", + "config_revision": 4, + "plan_hash": "fa4721466a102c2cc4dc0d4a454e8ca684982571136b3f068d66fb428a417146", + "config_snapshot": { + "workflow_definition_key": "main", + "workflow_version": 2, + "workflow_version_id": 93, + "stages": [ + { + "stage_key": "V0.1_triage", + "stage_name": "V0.1 需求摸底", + "enabled": true, + "doc_tasks": [ + { + "task_key": "intake", + "task_title": "用户初始需求收集表", + "enabled": true, + "approval_configurable": true, + "duties": { + "author_user_id": 9, + "reviewer_user_id": 11, + "approver_user_id": 12 + } + } + ] + }, + { + "stage_key": "V0.5_feasibility", + "stage_name": "V0.5 方案与可行性", + "enabled": true, + "doc_tasks": [ + { + "task_key": "feasibility_dev", + "task_title": "设计开发可行性评估报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "feasibility_reg", + "task_title": "注册路径可行性评估报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "prelim_design", + "task_title": "系统初步设计方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "research", + "task_title": "调研报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "user_requirements", + "task_title": "用户需求表", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + }, + { + "stage_key": "V1.0_quoting", + "stage_name": "V1.0 商务报价", + "enabled": true, + "doc_tasks": [ + { + "task_key": "cdo_quote", + "task_title": "CDO 报价单", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + } + ] + }, + "activated_by": 9, + "activated_at": "2026-09-17T08:50:54.464545Z", + "created_by": 9, + "created_at": "2026-09-17T08:50:54.455933Z", + "updated_at": "2026-09-17T08:54:38.265556Z" + }, + "stages": [ + { + "id": 391, + "stage_key": "V0.1_triage", + "position": 10, + "enabled": true, + "status": "completed", + "doc_tasks": [ + { + "id": 323, + "task_key": "intake", + "enabled": true, + "status": "pending", + "approved": false + } + ] + }, + { + "id": 392, + "stage_key": "V0.5_feasibility", + "position": 20, + "enabled": true, + "status": "completed", + "doc_tasks": [ + { + "id": 324, + "task_key": "feasibility_dev", + "enabled": false, + "status": "skipped_disabled", + "approved": false + }, + { + "id": 325, + "task_key": "feasibility_reg", + "enabled": false, + "status": "skipped_disabled", + "approved": false + }, + { + "id": 326, + "task_key": "prelim_design", + "enabled": false, + "status": "skipped_disabled", + "approved": false + }, + { + "id": 327, + "task_key": "research", + "enabled": false, + "status": "skipped_disabled", + "approved": false + }, + { + "id": 328, + "task_key": "user_requirements", + "enabled": false, + "status": "skipped_disabled", + "approved": false + } + ] + }, + { + "id": 393, + "stage_key": "V1.0_quoting", + "position": 30, + "enabled": true, + "status": "completed", + "doc_tasks": [ + { + "id": 329, + "task_key": "cdo_quote", + "enabled": false, + "status": "skipped_disabled", + "approved": false + } + ] + } + ] +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/pi-intake-meta.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/pi-intake-meta.json new file mode 100644 index 00000000..dd2d81f2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/pi-intake-meta.json @@ -0,0 +1,28 @@ +{ + "create_http": 202, + "run_id": "run_1789635054471569201", + "final": { + "RunID": "run_1789635054471569201", + "SessionID": "sess-rdev-s0-p6424-intake", + "ProjectID": 6424, + "UserID": 9, + "ClientTurnID": "rdev-s0-intake-1789635054", + "IdempotencyKey": "turn:sess-rdev-s0-p6424-intake:rdev-s0-intake-1789635054", + "TaskKey": "intake", + "Stage": "", + "WorkflowInstanceID": 287, + "StageInstanceID": 391, + "WorkflowStageKey": "V0.1_triage", + "Status": "completed", + "WorkspaceID": "/app/data/workspaces/bizdev/intake_1789635054485631143", + "AssistantMsgID": 3329, + "WorkerID": "", + "LeaseExpiresAt": null, + "CreatedAt": "2026-09-17T08:50:54.471673298Z", + "UpdatedAt": "2026-09-17T08:54:25.885010407Z", + "ErrorMessage": "", + "Attempt": 1, + "resumable": false + }, + "duration_ms": 220109 +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/pi-rd_design_input_spec-meta.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/pi-rd_design_input_spec-meta.json new file mode 100644 index 00000000..998412d0 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/pi-rd_design_input_spec-meta.json @@ -0,0 +1,23 @@ +{ + "RunID": "run_1789635960880707641", + "SessionID": "sess-rdev-s0-p6425-di3", + "ProjectID": 6425, + "UserID": 9, + "ClientTurnID": "rdev-di3-1789635960", + "IdempotencyKey": "turn:sess-rdev-s0-p6425-di3:rdev-di3-1789635960", + "TaskKey": "rd_design_input_spec", + "Stage": "", + "WorkflowInstanceID": 291, + "StageInstanceID": 396, + "WorkflowStageKey": "RD03_design_input", + "Status": "completed", + "WorkspaceID": "/app/data/workspaces/bizdev/rd_design_input_spec_1789635960900711737", + "AssistantMsgID": 3359, + "WorkerID": "", + "LeaseExpiresAt": null, + "CreatedAt": "2026-09-17T09:06:00.880788098Z", + "UpdatedAt": "2026-09-17T09:13:28.353775223Z", + "ErrorMessage": "", + "Attempt": 1, + "resumable": false +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/project.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/project.json new file mode 100644 index 00000000..5e586eb9 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/project.json @@ -0,0 +1,20 @@ +{ + "id": 6424, + "code": "rdev-s0-2026-76bc6575", + "name": "rdev-s0-20260917-085054", + "customer_name": "水木东方测试客户", + "device_type": "二类有源医疗器械(神经刺激)", + "intended_use": "无创神经电刺激辅助改善目标症状", + "target_market": "中国 NMPA", + "product_domain": "medical_device", + "current_stage": "V0.1_triage", + "environment": "dev", + "status": "active", + "creator_user_id": 9, + "lifecycle_status": "planning", + "active_workflow_key": "", + "workflow_selection_version": 1, + "workflow_plan_revision": 0, + "created_at": "2026-09-17T08:50:54.444179634Z", + "updated_at": "2026-09-17T08:50:54.444179634Z" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/rd_design_input_spec-docgov.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/rd_design_input_spec-docgov.json new file mode 100644 index 00000000..3e533c00 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/rd_design_input_spec-docgov.json @@ -0,0 +1,40 @@ +{ + "id": 293, + "doc_id": "doc_proj6425_rd_design_input_spec", + "project_id": 6425, + "task_key": "rd_design_input_spec", + "title": "设计输入规格书", + "stage": "RD03_design_input", + "version": 1, + "current_state": "drafting", + "is_locked": false, + "is_template_only": false, + "content_sha256": "3b763a3488dbd4fab8d7793f90b46f3373457f3745f3906b3c33e2bd39af39dc", + "work_dir": "/app/data/workspaces/bizdev/rd_design_input_spec_1789635960900711737", + "author_id": 9, + "author_name": "系统管理员", + "reviewer_id": 11, + "reviewer_name": "李主管", + "approver_id": 12, + "approver_name": "王总监", + "author_claimed": false, + "author_claimed_at": null, + "author_last_viewed_at": null, + "author_last_edited_at": null, + "reviewer_claimed": false, + "reviewer_claimed_at": null, + "approver_claimed": false, + "approver_claimed_at": null, + "last_rejected_reason": "", + "last_rejected_by_role": "", + "last_rejected_by_name": "", + "last_rejected_at": null, + "approved_at": null, + "config": { + "reject_rollback_to": "drafting", + "allow_approver_to_reviewer": true, + "require_author_confirm_on_reject": true + }, + "created_at": "2026-09-17T09:06:00.895726405Z", + "updated_at": "2026-09-17T09:13:28.349118981Z" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/rd_design_input_spec-final.md b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/rd_design_input_spec-final.md new file mode 100644 index 00000000..0db7fc11 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/rd_design_input_spec-final.md @@ -0,0 +1,391 @@ +# 设计输入规格书 + +| 字段 | 值 | +|---|---| +| 项目 | rdev-s0-6425 | +| 文件编号 | RD-DI-SPEC | +| 记录归属 | DHF(设计历史文件) | +| 版本 | A | +| 阶段 | 03 设计输入 | +| 编制依据快照 | ws_rd_design_input_spec_1789635960900794034 | +| 编制日期 | 2026-09-17 | + +--- + +## 结论 + +**本规格书适用性:条件成立,存在高优先级缺口待确认。** + +依据现有用户材料(design_input_brief.md + upload_material_design_inputrief.md.md,内容完全一致)已可建立设计输入规格书的四个核心章节骨架。然而,五项高优先级缺口(S-01~S-05)尚未闭环,直接影响功能边界确定、安全参数设计、型检送检方案与临床评价范围界定。**在以下缺口获得用户正式确认前,不得进入详细设计阶段。** + +关键风险锚点: +- 刺激强度上限未定义,将导致过流保护整定值(DI-S-001)无法参数化,型检送检受阻; +- 预期用途未由用户在材料中正式签署确认,存在设计开发范围蔓延风险; +- YY 9706.210 专用安全标准适用性未确认,输出限制设计存在合规隐患。 + +--- + +## 适用性 + +| 项目 | 结论 | 理由 | +|---|---|---| +| 本文件适用性 | **适用** | 项目已锁定为「二类有源医疗器械(神经刺激)」,目标市场为 NMPA;用户材料提供了功能/性能/安全/法规四个维度的摸底陈述,满足设计输入编制的起点条件 | +| DHF 适用性 | **适用** | 依据 ISO 13485 第 7.1 条及 GB 9706.1-2022 第 4 条,设计开发阶段须建立并保持设计历史文件(DHF),本文件为其组成部分 | +| ISO 14971 风险管理要求 | **适用** | 材料明确要求「ISO 14971」风险管理框架,本文件须纳入风险管理文档追溯链 | +| 是否因缺项声明不适用 | **否** | 虽存在大量「待确认」项,但用户材料已提供部分功能/性能/法规事实基底,可编制骨架;缺口不影响本阶段文档本身的必要性 | + +--- + +## 一、功能要求 + +### 结论 + +用户材料已明确四项核心功能(脉冲刺激、强度可调、会话计时、紧急停止)〔材料:design_input_brief.md〕,构成功能基线。**强度上限、输出波形(单相/双相、恒流/恒压)、通道数为高优先级缺口,须确认为 DI-F-002/003/004 后方可冻结功能规格。** + +### 事实表 + +| ID | 功能陈述 | 优先级 | 来源 | 验证方式 | +|---|---|---|---|---| +| DI-F-001 | **脉冲刺激输出**:通过皮肤表面电极施加电脉冲,触发目标神经/肌肉兴奋 | 🔴 高 | 材料原文〔材料:design_input_brief.md 功能章〕 | 型检(输出波形、频率、脉宽);临床性能测试 | +| DI-F-002 | **强度可调**:输出幅度(电流或电压)在用户指定范围内连续可调 | 🔴 高 | 材料原文〔材料:design_input_brief.md 功能章〕 | 型检(量程、精度);可用性测试(操作验证) | +| DI-F-003 | **会话计时**:记录并显示单次治疗时长 | 🟡 中 | 材料原文〔材料:design_input_brief.md 功能章〕 | 功能测试 | +| DI-F-004 | **紧急停止**:用户可一键切断所有输出,停止刺激 | 🔴 高 | 材料原文〔材料:design_input_brief.md 功能章〕 | 型检(安全功能测试);可用性测试 | +| DI-F-005 | **刺激频率可调**:范围 1–100 Hz | 🟡 中 | 材料原文〔材料:design_input_brief.md 性能章〕 | 型检 | +| DI-F-006 | **脉宽可调**:范围 50–500 µs | 🟡 中 | 材料原文〔材料:design_input_brief.md 性能章〕 | 型检 | +| DI-F-007 | **过流保护**:输出电流超过预设阈值时自动关断 | 🔴 高 | 材料原文〔材料:design_input_brief.md 安全章〕 | 型检(GB 9706.1-2022 §8.7);过流保护功能测试 | +| DI-F-008 | **电极脱落检测**:检测到电极与皮肤接触不良时告警或自动切断 | 🔴 高 | 材料原文〔材料:design_input_brief.md 安全章〕 | 型检;危害分析验证 | +| DI-F-009 | **输出波形类型**(单相/双相、恒流/恒压) | **待确认** | 上游缺口 S-02 | — | +| DI-F-010 | **独立刺激通道数**(单通道/双通道) | **待确认** | 上游缺口 | — | +| DI-F-011 | **用户界面类型**(物理按键/触摸屏/APP) | **待确认** | 上游缺口 | — | + +### 解读 + +**功能覆盖分析**:材料所述功能覆盖了经皮神经电刺激(TENS)类设备的基本操作流程。用户(操作者)角色在材料中未定义,推定以非专业医护人员(或在受控环境/家用场景使用)为假设。 + +**操作流程推断**:基于四项核心功能,操作流程至少包含:开机 → 参数设置(频率/脉宽/强度)→ 开始治疗 → 计时运行 → 紧急停止 → 结束复位。该流程须在可用性工程文档中细化。 + +**缺口传递**:DI-F-002 的「强度范围」是安全上限的核心锚点——若 100 Hz 与 500 µs 上限为设备物理边界而非临床安全边界,则强度上限须另行确定,并与过流保护整定值联动设计。DI-F-009(输出波形类型)影响电极设计、EMC 测试布置与危害分析,须在详细设计前确认。 + +**已知缺口**: + +| 缺口 ID | 缺口描述 | 影响 | +|---|---|---| +| S-02 | 刺激强度上限未定义 | 与 DI-F-002 联动,无法完成强度量程设计 | +| S-04 | 目标人群与禁忌症未定义 | 影响功能适用范围声明 | + +--- + +## 二、性能要求 + +### 结论 + +用户材料已给出频率与脉宽的数值范围〔材料:design_input_brief.md〕,可填入 DI-P-001/002。**刺激强度上限(电流峰值或电压峰值)完全缺失,属最高优先级缺口;精度指标、量程边界、测量条件均未定义,须在 DI-P-003/004 中补充。** + +### 事实表 + +| ID | 性能指标 | 目标值/范围 | 测量条件 | 来源 | +|---|---|---|---|---| +| DI-P-001 | 刺激频率 | 1–100 Hz | 额定负载、室温 23°C ±2°C(推定) | 材料原文〔材料:design_input_brief.md 性能章〕 | +| DI-P-002 | 刺激脉宽 | 50–500 µs | 额定负载、室温(推定) | 材料原文〔材料:design_input_brief.md 性能章〕 | +| DI-P-003 | **输出强度上限**(电流峰值 mA 或电压峰值 V) | **待确认** | — | 上游缺口 S-02 | +| DI-P-004 | **输出波形**(单相/双相、恒流/恒压) | **待确认** | — | 上游缺口 S-02 | +| DI-P-005 | **通道数**(独立刺激通道数量) | **待确认** | — | 上游缺口 | +| DI-P-006 | **频率设置精度** | **待确认** | — | — | +| DI-P-007 | **脉宽设置精度** | **待确认** | — | — | +| DI-P-008 | **强度设置精度** | **待确认** | — | — | +| DI-P-009 | **输出启动上升时间** | **待确认** | — | — | +| DI-P-010 | **预热时间/启动稳定时间** | **待确认** | — | — | +| DI-P-011 | **输出噪声/纹波** | **待确认** | — | — | +| DI-P-012 | **预期使用寿命** | **待确认** | — | 上游缺口 S-10 | + +### 解读 + +**参数范围分析**:频率 1–100 Hz、脉宽 50–500 µs 的范围与主流经皮神经电刺激(TENS)设备文献一致(分析判断,无直接条文依据),但材料未提供该上限的来源说明。 + +**上限性质判定**:100 Hz 与 500 µs 可能代表: +1. 临床有效剂量上限(安全约束); +2. 设备硬件输出能力上限(工程约束); +3. 两者兼有。 + +若为硬件能力边界,则强度上限须另行确定,不受频率/脉宽上限约束;若为临床安全边界,则强度上限须与该边界联动确定。需在 S-02/S-03 中澄清。 + +**与标准的潜在约束关系**:依据工程师阶段分析,YY 9706.210-2021(对应 IEC 60601-2-10:2016)第 6.8 条对神经刺激器输出有限制要求。材料所述 1–100 Hz、50–500 µs 与该标准限制范围大致吻合,但需待 YY 9706.210 适用性确认后(DI-R-004)进行正式比对。 + +**已知缺口**: + +| 缺口 ID | 缺口描述 | 影响 | +|---|---|---| +| S-02 | 刺激强度上限未定义 | 阻断 DI-P-003 参数化,无法确定过流保护阈值 | +| S-03 | 频率/脉宽上限来源未确认 | 影响是否与 YY 9706.210 §6.8 比对 | +| S-10 | 预期使用寿命未定义 | 影响 DI-P-012 可靠性指标 | + +--- + +## 三、接口与环境要求 + +### 结论 + +用户材料仅提供 USB 充电与工作温度 5–40°C 两项,存在大量缺项。**使用环境(医疗机构/家用)、IP 防护等级、湿度范围、EMC 版次、供电电压范围均未定义,其中 YY 9706.102 版次(第 3 版 vs 第 4 版)对 EMC 测试方案有显著影响,须优先确认(S-06)。** + +### 事实表 + +| ID | 接口/环境项 | 要求陈述 | 适用边界 | 来源 | +|---|---|---|---|---| +| **电气接口** ||||| +| DI-I-001 | 充电接口 | USB 充电 | — | 材料原文〔材料:design_input_brief.md 接口章〕 | +| DI-I-002 | 供电方式 | 锂电池供电(推定,需确认) | — | 工程推断,无直接材料依据 | +| DI-I-003 | 额定输入电压范围 | **待确认** | — | — | +| DI-I-004 | 电极连接器规格 | **待确认** | — | — | +| **用户界面** ||||| +| DI-I-005 | 用户界面类型 | **待确认** | — | 上游缺口 | +| DI-I-006 | 显示屏规格 | **待确认** | — | — | +| **机械接口** ||||| +| DI-I-007 | 设备尺寸约束 | **待确认** | — | — | +| DI-I-008 | 设备重量约束 | **待确认** | — | — | +| **使用环境** ||||| +| DI-I-009 | 工作温度 | 5–40°C | 室内常规环境 | 材料原文〔材料:design_input_brief.md 接口章〕 | +| DI-I-010 | 相对湿度 | **待确认** | — | 上游缺口 G-05 | +| DI-I-011 | 大气压力(海拔) | **待确认** | — | 上游缺口 G-05 | +| DI-I-012 | 防护等级(IP 等级) | **待确认** | — | 上游缺口 G-05 | +| DI-I-013 | 使用环境(医疗机构/家用/公共场所) | **待确认** | — | 上游缺口 S-05 | + +### 解读 + +**使用环境判定的影响**:若设备预期用于家用,则须额外满足 GB 9706.1-2022 第 16 条(家用医用电气设备补充要求)及 YY 9706.111-2021(如适用);若预期用于医疗机构,则须满足 GB 9706.1-2022 第 16 条(医用电气设备通用要求)基本安全要求。两种场景对 IP 防护等级要求与 EMC 测试组别要求不同。 + +**USB 充电的隐含要求**:USB 充电接口须满足 GB 4943.1(信息技术设备安全)或 YY 9706.1-2022 第 16 条对电源适配器的相关要求,且须考虑充电接口的电气安全设计(漏电流、绝缘)。 + +**已知缺口**: + +| 缺口 ID | 缺口描述 | 影响 | +|---|---|---| +| S-05 | 使用环境(家用/医疗)未确认 | 影响 YY 9706.111 适用性、EMC 测试组别 | +| S-06 | YY 9706.102 版次未确认 | 影响 EMC 测试方案,返工风险 | +| G-05 | 环境参数(湿度/海拔/IP等级)未细化 | 影响型检环境测试项目 | + +--- + +## 四、安全与风险输入 + +### 结论 + +用户材料明确要求过流保护与电极脱落检测,并指定 ISO 14971 风险管理框架〔材料:design_input_brief.md 安全章〕,构成 DI-S-001/002/003 的基础。但危害分析尚未开展,剩余风险接受准则、危害情境清单、已知禁忌均未提供。**在危害分析完成前,安全与风险输入章节不得关闭。** + +### 事实表 + +| ID | 安全/风险输入 | 关联危害/控制措施 | 验证/确认线索 | 来源 | +|---|---|---|---|---| +| DI-S-001 | **过流保护**:输出电流超过预设阈值时自动关断 | 过高电流导致组织热损伤/神经损伤 | 型检 GB 9706.1-2022 §8.7;过流保护功能测试;阈值整定值验证 | 材料原文〔材料:design_input_brief.md 安全章〕 | +| DI-S-002 | **电极脱落检测**:电极接触中断时告警或自动关断 | 电极脱落导致开路高电压/皮肤刺痛 | 型检;危害分析验证 | 材料原文〔材料:design_input_brief.md 安全章〕 | +| DI-S-003 | **紧急停止功能**:用户可一键切断所有输出 | 操作失误时快速终止刺激 | 型检安全功能测试;可用性测试 | 材料原文〔材料:design_input_brief.md 安全章〕 | +| DI-S-004 | **风险管理框架**:满足 ISO 14971:2019 | 须建立风险管理文档,纳入 DHF | 风险管理文档 | 材料原文〔材料:design_input_brief.md 安全章〕 | +| DI-S-005 | **刺激强度上限**(关联 DI-P-003) | 最高优先级:与过流保护整定值直接联动;未定义则过流保护阈值无法确定 | S-02 待确认 | 上游缺口 S-02 | +| DI-S-006 | **禁忌症清单** | 心脏起搏器植入者、癫痫患者等高风险人群——材料未提供 | 临床评价/文献调研 | 上游缺口 S-04 | +| DI-S-007 | **剩余风险接受准则** | ISO 14971:2019 §5.4 要求明确;材料未提供 | 风险管理计划 | 上游缺口 G-03 | +| DI-S-008 | **生物相容性要求**(BGP 电极与皮肤接触) | 与人体接触部件须进行生物相容性评价 | S-08 待确认 | 上游缺口 S-08 | +| DI-S-009 | **输出电压上限**(恒压模式适用) | 开路条件下电压不得超过安全限值 | 与电极脱落检测联动设计 | 上游缺口 S-02 | + +### 解读 + +**过流保护与强度上限的耦合关系**:过流保护(DI-S-001)的有效性与刺激强度上限(DI-P-003)强耦合。若设备标称最大输出强度为 I_max,则过流保护阈值须设定在 I_max 附近且留有适当余量。用户材料未给出强度上限,导致 DI-S-001 无法完成参数化设计。 + +**电极脱落检测的电压安全**:在恒压输出模式下,若电极脱落,负载突然增大可能导致电压骤升,危及患者。须在危害分析中明确控制策略(电压钳位、限流、告警等),并在 DI-S-009 中记录电压上限要求。 + +**YY 9706.210 的叠加约束**:若 DI-R-004(YY 9706.210)适用性确认,则第 6.8 条「输出限制」将对输出参数形成额外约束。须在危害分析中比对标准条款与用户材料参数的差异。 + +**已知缺口**: + +| 缺口 ID | 缺口描述 | 影响 | +|---|---|---| +| S-02 | 刺激强度上限未定义 | 阻断 DI-S-001 参数化 | +| S-04 | 禁忌症未定义 | 影响标签/说明书,审评发补风险 | +| S-08 | 生物相容性要求未定义 | 影响与人体接触部件评价 | +| G-03 | 剩余风险接受准则未定义 | ISO 14971 符合性缺失 | + +--- + +## 五、法规与标准符合性 + +### 结论 + +用户材料列举了 GB 9706.1、YY 9706.102 与 ISO 14971 三项标准依据〔材料:design_input_brief.md 法规章〕,可建立初步符合性框架。但存在两项关键缺项:**YY 9706.210(神经刺激专用安全标准)适用性未确认;YY 9706.102 的版次(2021 版对应第 3 版;新版第 4 版征求意见中)未确认,直接影响 EMC 检测方案。** + +### 法规/标准符合性表 + +| ID | 法规/标准 | 适用条款/要点 | 对本设计输入的含义 | 符合性证据计划 | +|---|---|---|---|---| +| DI-R-001 | **GB 9706.1-2022**(医用电气设备 通用安全要求,等同采用 IEC 60601-1:2005+AMD1:2012+AMD2:2020) | 通用安全要求;第 8.7 条(保护导体/过流保护);第 16 条(医用电气设备补充要求) | 所有安全设计须满足;过流保护属于第 8.7 条强制要求 | 型检报告;设计验证报告 | +| DI-R-002 | **YY 9706.102-2021**(医用电气设备 第 1-2 部分:通用安全要求 并列标准 电磁兼容 要求及试验,对应 IEC 60601-1-2:2014 第 3 版) | 全项目 EMC 测试;发射、抗扰度 | 须完成注册检测;**版次(S-06)待确认**:若新版 YY 9706.102(对应 IEC 60601-1-2:2018 第 4 版)生效,测试项目有调整 | 型检报告 | +| DI-R-003 | **ISO 14971:2019**(医用器件风险管理应用) | 风险管理全过程要求;第 5 章危害识别、第 6 章风险估计、第 7 章风险控制 | 须建立风险管理文档(DHF 组成文件);所有设计输入须有风险考量 | 风险管理文档(RM-DOC) | +| DI-R-004 | **YY 9706.210-2021**(医用电气设备 第 2-10 部分:神经和肌肉刺激器的基本性能与基本安全要求,对应 IEC 60601-2-10:2016) | 神经刺激器专用安全与性能要求;第 6.8 条输出限制、说明书要求 | **须确认本产品是否适用**(若预期用途为神经刺激,大概率适用);若适用则额外增加约束 | 型检报告;适用性分析报告 | +| DI-R-005 | **GB/T 25000.51-2016**(系统与软件工程 系统与软件质量要求与评价(SQuaRE) 第 51 部分) | 软件质量要求;就绪可用产品 | 若含嵌入式软件,须满足此标准 | 软件测试报告 | +| DI-R-006 | **YY 9706.111-2021**(医用电气设备 第 1-11 部分:通用安全和基本性能通用要求 并列标准:家庭护理环境使用的医用电气设备的要求) | 家用环境补充安全要求 | **若设备预期用于家用(S-05),须满足**;会影响 EMC 测试组别与温湿度测试条件 | 型检报告 | +| DI-R-007 | **医疗器械网络安全注册技术审查指导原则(2022年版)** | 网络安全基本要求、数据接口安全 | 若含网络/蓝牙/Wi-Fi 功能(S-07),须满足 | 网络安全评价报告(如适用) | + +### 解读 + +**YY 9706.210 的核心地位**:DI-R-004(YY 9706.210)对神经刺激设备是核心标准,与通用安全标准形成叠加约束。例如,YY 9706.210 第 6.8 条对输出能量有明确限制,若本产品频率上限 100 Hz、脉宽上限 500 µs 与 YY 9706.210 的限制不一致,则须在设计中遵守更严格的标准。若 DI-R-004 确认适用,则 DI-P-001/002 的上限值可能受到该标准的约束(而非仅由材料给出),需在 S-03 中重新对齐。 + +**EMC 版次的窗口期风险**:GB 9706.1-2022 已发布实施,IEC 60601-1-2:2018(第 4 版)对应的 YY 9706.102 修订版可能在近期生效。若 NMPA 在型检时已要求新版,则测试项目将有调整(新增无线共存测试等)。S-06 须在型检送检前确认。 + +**已知缺口**: + +| 缺口 ID | 缺口描述 | 影响 | +|---|---|---| +| S-03 | YY 9706.210 适用性未确认 | 专用安全要求可能遗漏 | +| S-05 | 使用环境未确认 | YY 9706.111 适用性不明 | +| S-06 | YY 9706.102 版次未确认 | EMC 测试方案存在返工风险 | +| S-07 | 软件需求未确认 | GB/T 25000.51 符合性无法保证 | + +--- + +## 六、追溯矩阵 + +| 本文件 ID | 上游(输入/需求来源) | 下游(输出/验证/确认) | +|---|---|---| +| **RD-DI-SPEC**(本文件) | 01_organize/output.md(事实清单);用户材料(design_input_brief.md) | 设计开发计划(DD-DP);危害分析报告(RM-HAZ);设计验证报告(DD-VR);型检送检清单;风险管理文档(RM-DOC) | +| DI-F-001(脉冲刺激输出) | 材料:design_input_brief.md 功能章 | DD-VR 功能测试;型检功能测试 | +| DI-F-002(强度可调) | 材料:design_input_brief.md 功能章 | DD-VR 强度量程测试;型检 | +| DI-F-003(会话计时) | 材料:design_input_brief.md 功能章 | DD-VR 功能测试 | +| DI-F-004(紧急停止) | 材料:design_input_brief.md 功能章 | DD-VR 安全功能测试;型检;可用性测试 | +| DI-F-005/006(频率/脉宽可调) | 材料:design_input_brief.md 性能章 | 型检性能测试 | +| DI-F-007(过流保护) | 材料:design_input_brief.md 安全章 | 型检 GB 9706.1-2022 §8.7 | +| DI-F-008(电极脱落检测) | 材料:design_input_brief.md 安全章 | 型检;危害分析验证 | +| DI-P-001/002(频率/脉宽范围) | 材料:design_input_brief.md 性能章 | 型检性能测试 | +| DI-P-003/004(强度/波形) | **待确认 S-02** | — | +| DI-I-001(USB充电) | 材料:design_input_brief.md 接口章 | 型检安全测试 | +| DI-I-009(工作温度) | 材料:design_input_brief.md 接口章 | 型检环境测试 | +| DI-S-001/002/003(安全功能) | 材料:design_input_brief.md 安全章 + ISO 14971 | RM-HAZ;型检安全测试 | +| DI-R-001(GB 9706.1-2022) | 材料:design_input_brief.md 法规章 | 型检报告 | +| DI-R-002(YY 9706.102) | 材料:design_input_brief.md 法规章 | 型检报告 | +| DI-R-003(ISO 14971) | 材料:design_input_brief.md 法规章 | RM-DOC | +| DI-R-004(YY 9706.210) | 工程推断(无直接材料依据) | 型检报告;适用性分析报告 | + +--- + +## 七、已有材料摘要 + +### 7.1 核心原始材料 + +| 来源 | 可用内容 | 局限性 | +|---|---|---| +| design_input_brief.md(主材料) | 功能4项、性能2项、接口2项、安全3项、法规3项 | 无预期用途正式陈述、无强度上限、无可靠性指标、无用户/环境详细描述 | +| upload_material_design_input_brief.md.md | 内容与主材料完全相同 | 同上 | +| 02_engineer/output.md | 已提取事实清单、11项缺口、5项矛盾、技术路线分析 | 缺口均为「待确认」,未获用户正式确认 | + +### 7.2 项目级基线 + +| 来源 | 锁定内容 | +|---|---| +| project.md | 产品类型(二类有源神经刺激)、预期用途立项定义「无创神经电刺激辅助改善目标症状」、目标市场(NMPA) | +| blackboard.json | 客户名称(水木东方测试客户)、设备类型、目标市场 | + +### 7.3 矛盾与冲突记录 + +| 编号 | 矛盾描述 | 材料A | 材料B/对照 | 处理方式 | +|---|---|---|---|---| +| C-01 | 预期用途来源 | 材料本身无 intended_use 陈述 | project.md 定义为「无创神经电刺激辅助改善目标症状」 | **标记为 S-01,用户确认前不得作为正式设计输入** | +| C-02 | 频率上限来源不清 | 材料写 1–100 Hz | 立项定义为二类有源神经刺激,临床常用频率可达 200 Hz | **标记为 S-03,确认为临床安全上限还是硬件能力上限** | +| C-03 | 脉宽上限来源不清 | 材料写 50–500 µs | 神经刺激文献常用上限可达 1000 µs | **标记为 S-03,同上** | +| C-04 | EMC 标准版次未确认 | 材料写 YY 9706.102 | GB 9706.1-2022 已发布,YY 9706.102 对应 IEC 60601-1-2:2014 第 3 版;**第 4 版** IEC 60601-1-2:2018 已发布(YY 9706.102 修订中) | **标记为 S-06,确认适用版次** | +| C-05 | YY 9706.210 适用性未确认 | 材料未提及此标准 | 神经刺激设备大概率适用 YY 9706.210-2021 | **标记为待确认,DI-R-004 中记录** | + +--- + +## 八、风险清单 + +| 风险 ID | 风险描述 | 影响 | 概率 | 优先级 | 缓控措施 | +|---|---|---|---|---|---| +| R-01 | **刺激强度上限未定义 → 过流保护整定值无法确定 → 型检无法完成送检** | 设计阶段冻结受阻 | 高 | 🔴 极高 | 优先安排 S-02 确认 | +| R-02 | **预期用途未由用户正式确认 → 临床评价范围无法确定 → 注册发补风险** | 注册路径受阻 | 高 | 🔴 极高 | 安排 S-01 确认,并在设计历史记录中注明来源 | +| R-03 | **YY 9706.210 适用性未确认 → 专用安全要求遗漏 → 型检报告缺陷** | 型检不通过 | 中 | 🔴 高 | 安排 S-03/S-06 同步确认 | +| R-04 | **使用环境(家用/医疗)未确认 → YY 9706.111 适用性不明 → EMC 测试组别错误 → 型检返工** | 型检周期延长 3–6 个月 | 中 | 🟡 中 | 安排 S-05 确认 | +| R-05 | **禁忌症未定义 → 标签/说明书缺失 → 审评发补** | 注册发补 | 中 | 🟡 中 | 安排 S-04 确认 | +| R-06 | **嵌入式软件无需求 → GB/T 25000.51 符合性缺失 → 审评发补** | 注册发补 | 中 | 🟡 中 | 安排 S-07 确认 | +| R-07 | **无可靠性指标 → 使用寿命声明无依据 → 审评要求补充** | 注册发补/设计返工 | 低 | 🟡 中 | 安排 S-10 确认 | +| R-08 | **生物相容性要求缺失 → 与人体接触部件评价无法闭环** | 生物相容性测试缺失 | 低 | 🟡 中 | 安排 S-08 确认 | + +--- + +## 九、待确认清单 + +> 以下事项须由用户在进入详细设计前正式确认。按优先级排序,建议优先处理高优先级项。 + +| 序号 | 确认项 | 优先级 | 关联章节 | 不确认的后果 | +|---|---|---|---|---| +| **S-01** | **预期用途(Intended Use)是否以「无创神经电刺激辅助改善目标症状」为准?** 须用户正式签署确认。 | 🔴 高 | 功能要求;法规与标准 | 临床评价范围无法确定,注册路径存在不确定性 | +| **S-02** | **刺激强度上限(电流峰值 mA 或电压峰值 V)是多少?** 与过流保护整定值(DI-S-001)直接联动,须同步确定。 | 🔴 高 | 性能要求;安全与风险 | 过流保护参数无法确定,型检无法完成 | +| **S-03** | **频率上限 100 Hz、脉宽上限 500 µs 的来源:** 为临床有效剂量上限(安全约束)还是设备硬件能力边界(工程约束)?若为安全约束,则须与 YY 9706.210-2021 §6.8 比对确认。 | 🔴 高 | 性能要求;法规与标准 | YY 9706.210 适用性无法判断,输出限制设计存在风险 | +| **S-04** | **目标人群(年龄、疾病状态)与禁忌症清单是什么?** 影响临床评价、标签与说明书。 | 🔴 高 | 安全与风险;功能要求 | 禁忌症未定义,审评发补风险 | +| **S-05** | **使用环境是否为家用环境(家庭护理环境)?** 若是,须满足 YY 9706.111-2021;EMC 测试组别亦受影响。 | 🟡 中 | 接口与环境;法规与标准 | YY 9706.111 适用性不明,型检方案存在返工风险 | +| **S-06** | **YY 9706.102(EMC)采用 2021 版(第 3 版,对应 IEC 60601-1-2:2014)还是最新版(第 4 版,IEC 60601-1-2:2018)?** 须确认 NMPA 当前接受的版本。 | 🟡 中 | 接口与环境;法规与标准 | EMC 测试方案无法确定 | +| **S-07** | **是否含嵌入式软件?** 若是,须确定软件安全等级(GB/T 25000.51)以及是否需要网络安全要求。 | 🟡 中 | 功能要求;法规与标准 | 软件需求缺失,GB/T 25000.51 符合性无法保证 | +| **S-08** | **电极接触类型(皮肤/侵入)与接触时间(短暂/长期/永久)是什么?** 影响生物相容性评价范围与测试项目。 | 🟡 中 | 安全与风险 | 生物相容性测试项目无法确定 | +| **S-09** | **设计开发计划节点与型检预计送检时间?** 影响资源配置与里程碑设定。 | 🟡 中 | 追溯表(下游) | 设计开发计划无进度约束输入 | +| **S-10** | **设备预期使用寿命与保修期?** 影响 MTBF/MTTF 假设与标签声明。 | 🟡 中 | 性能要求 | 可靠性指标缺失,使用寿命声明无依据 | + +--- + +## 十、签署 + +| 角色 | 姓名 | 结论 | 日期 | +|---|---|---|---| +| 编制(研发设计控制负责人) | — | 已完成 DHF 终审,整合前序专员产出 | 2026-09-17 | +| 审核 | — | | | +| 批准 | — | | | + +> **注**:依据本规格书「DHF 设计控制纪律」,签字须由具备相应授权的人员在缺口确认完成后补充填写。本版(A版)为初始发行,缺口关闭后应更新版本。 + +--- + +## 附录 A:工程师技术评审纪要(引用自 02_engineer/output.md) + +### A.1 技术路线分析 + +**设备类型判定**:基于材料描述「脉冲刺激、强度可调、会话计时、紧急停止」,结合频率 1–100 Hz、脉宽 50–500 µs 范围,本产品属于经皮神经电刺激(TENS)类设备(符合 YY 9706.210-2021 范围定义)(分析判断,无直接条文依据),最可能的预期用途场景为镇痛或肌肉刺激。 + +**输出模式**:材料未明确恒流/恒压输出模式。恒流模式对组织阻抗变化有自适应性,是更安全的 TENS 设备方案,但成本较高;恒压模式成本低但安全性略弱。若用户无明确要求,推荐优先考虑恒流模式,并满足 YY 9706.210 第 6.8 条的输出限制要求。 + +### A.2 工程化差距摘要 + +| 事项 | 现状 | 目标 | 差距 | 内部/外部能力 | 工作量量级 | +|---|---|---|---|---|---| +| 强度上限确定 | 未提供 | 明确电流/电压上限值 | 需用户确认 + 标准比对 | 用户确认 + 研发内部设计 | 1–2 周 | +| 危害分析开展 | ISO 14971 框架已引用,分析未开展 | 完成危害清单与风险控制措施 | 需专业风险管理工程师 | 内部或外部风险管理咨询 | 4–8 周 | +| 型检方案编制 | 材料列有 GB 9706.1/YY 9706.102 | 完整检测项目清单、样品数量、送检机构 | 需 GB 9706.1-2022 与 YY 9706.210 比对 | 型检机构 + 研发内部 | 2–4 周 | +| 软件需求定义 | 未提及 | 嵌入式软件需求文档、等级确认 | 需确认是否含软件 | 研发内部 | 2–3 周 | +| EMC 版次确认 | YY 9706.102 版次不明 | NMPA 接受的最新有效版本 | 需法规/注册确认 | 法规/注册团队 | 1–2 周 | +| 临床评价范围 | 预期用途待确认 | 临床评价报告(CER) | 需预期用途闭环 + 文献调研 | 临床评价机构 | 8–16 周 | + +### A.3 对前序的验证 + +01_organize 产出的事实清单(F-01~F-14)与缺口清单(G-01~G-11)准确、可追溯,已被本规格书完整引用。本规格书验证了以下上游判断: + +- G-01(预期用途无正式陈述):确认,标记为 S-01。 +- G-02(刺激强度上限缺失):确认,标记为 S-02,且该缺口同时阻断安全章节 DI-S-001 参数化。 +- G-05(使用环境边界细化):确认,标记为 S-05/S-06,EMC 版次额外增加 DI-R-002 风险。 +- G-11(IEC 60601-1-2 版次):扩展为 S-06,且发现 NMPA 可能存在第 3 版与第 4 版的窗口期问题。 + +无前序与本阶段之间的矛盾。已批准文档(project.md)锁定的核心前提未被本阶段推翻。 + +--- + +## 附录 B:引用文件清单 + +| 编号 | 文件名称 | 版本/日期 | 来源 | +|---|---|---|---| +| 1 | design_input_brief.md | — | 用户上传材料 | +| 2 | upload_material_design_input_brief.md.md | — | 用户上传材料 | +| 3 | project.md | — | 项目上下文 | +| 4 | blackboard.json | 2026-09-17T09:07:08Z | 前序专员产出 | +| 5 | 02_engineer/output.md | 2026-09-17 | 前序工程师产出 | +| 6 | GB 9706.1-2022 | 2022 | 国家标准 | +| 7 | YY 9706.102-2021 | 2021 | 行业标准 | +| 8 | YY 9706.210-2021 | 2021 | 行业标准 | +| 9 | ISO 14971:2019 | 2019 | 国际标准 | +| 10 | GB/T 25000.51-2016 | 2016 | 国家标准 | +| 11 | YY 9706.111-2021 | 2021 | 行业标准 | + +--- + +**说明**:本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容,须通过变更控制流程更新。 diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/rdev-leader-AGENTS.md b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/rdev-leader-AGENTS.md new file mode 100644 index 00000000..b042d930 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/rdev-leader-AGENTS.md @@ -0,0 +1,78 @@ +## 角色 + +你是研发/设计控制小队的负责人(花名册名称:研发设计控制负责人)。按 ISO 13485 把本阶段做成可追溯的 DHF 记录。你是用户与小队之间的唯一接口:专员向你汇报,只有你对用户说话、提交阶段文档。 + +你的价值不在于自己写长文,而在于把本阶段设计控制要求变成可验收的任务,审核专员产出,整合成一份可提交的 Markdown 终稿。结论短、论证完整:卡片可以短,DHF 文档不能短。 + +**明确边界**:不做报价核算与商务承诺,不写商务对接口径。 + +## 汇报纪律 + +- 只有你可以把成果交给用户(通过系统落文档)。不要把未审核的专员原文直接当终稿。 +- 需要用户决策的事项一律上升,不要代用户拍板。 +- 专员产出不合格时退回说明差距;系统按剧本执行,你负责审核与整合。 + +## 本阶段你要做的(DHF 终审) + +1. 用一句话回述本阶段交付物及其 DHF 归属。 +2. 阅读快照、材料文本、前序文档和各专员 `output.md`。 +3. 按本阶段 RD 模板整合终稿,结论先行;与前序已批准文件保持一致。 +4. **追溯表必填核查**:本文件 ID、上游输入、下游验证/确认;未知项写入「待确认」而非留空。 +5. **不适用理由完整性**:声明不适用时须写完整理由,禁止空壳「不适用」。 +6. 缺信息标「待确认」,**禁止编造**试验数据、标准号、URL、签字、附件正文。 +7. 按 TASK.md 指定的交付文件落盘:终稿步写 `output.md`;若本步是「审核撰写计划」(review_plan),只写 `review_notes.md`,**禁止**把审核意见写入或覆盖 `output.md`。 +8. 专员已标注的出处必须保留;你整合时把能溯源的句子标上〔…〕,无依据的分析不要删,也不要补造引用。 + +## 汇报格式(终稿 output.md 开头;计划审核则写在 review_notes.md) + +**注意**:你整合生成的终稿文档结构必须严格遵循 `template.md` 的所有章节结构,不得自行替换为审阅报告结构!你的审阅和把关成果体现为:在保持 `template.md` 章节骨架(如 ## 结论、## 适用性、专业正文各节、## 追溯、## 待确认清单、## 签署 等)的基础上,将专员的草稿进行逻辑润色、出处溯源和终审定稿输出。 + +**待确认清单**:在模板对应章节中集中列出文中所有「待确认」。 + + +## 写作质量 + +模板是章节骨架,不是篇幅上限。禁止把模板括号提示原样留下。卡片摘要可以短;阶段文档必须论证完整。结论短、论证完整。 + +### 详细 + +- 每一节至少包含:结论句 → 表格或列表(可用 Mermaid,图表要有标题) → 解读段落 → 支撑明细或出处。不要通篇只有列表。 +- 数字必须写清口径、假设、来源与日期。没有数字时写「未检索到公开信息」或「材料未提供」,并说明已尝试路径。 +- 表格禁止交空壳:至少填已知行;若确实无数据,用一行写明「为何为空」及待确认项。 +- 专员交给撰写岗的稿件要写完整论据,不要只交标题或提纲。 + +### 逻辑通顺 + +- 结论先行,后文不得与结论打架。写作过程中出现新事实,必须回写结论。 +- 章节按「事实 → 分析 → 对本项目的含义 → 建议或待拍板」推进。 +- 前序已批准文档与本阶段材料冲突时,两个都列出来并标记冲突,不默默取一边。 +- 选项对比用同一套维度(证据、周期、成本量级、风险、适用条件)。术语首次出现先定义。 +- 因果链写清:因 → 果 → 建议。不要跳跃到未交代的前提。 + +### 考虑全面 + +- 每份终稿显式覆盖:适用与不适用、机会与风险、优势与短板、已知与待确认、现在要用户做什么。没有决策则写「无需决策」。 +- 写推荐必须同时写否决或失效条件。 +- 能标出处的句子必须标〔…〕。无直接条文依据的分析保留,并标「(分析判断,无直接条文依据)」。 +- 会议纪要类短文档至少要有一个流程或责任矩阵。 + +### 自检(撰写岗与负责人必做) + +1. 模板中的每个章节是否都写完,没有残留括号提示。 +2. 关键数字、路径、参数前后是否一致,且可追溯。 +3. 文中所有「待确认」是否已在文末清单汇总。 +4. 是否有未标注的推测、疗效宣称或绝对化用语。 +5. 专员已标注的出处是否保留;无依据的分析是否被误删。 + +## DHF 设计控制纪律(研发设计控制流程 rd_* 任务) + +> **分工红线**:模板管结构与占位;overlay/运行时管写作纪律与粒度下限。详见 [08-rdev模板写作规范](../../.doc/system-design/project-workflow/08-rdev模板写作规范.md)。 + +适用于 ISO 13485 设计历史文件(DHF)类受控文档: + +- 模板各章节必须写满;禁止残留括号提示或空表壳。 +- 结论先行:文首给出适用性/通过/不适用结论,后文论证不得与结论矛盾。 +- 追溯表必填:本文件 ID、上游输入、下游验证/确认;未知项写入「待确认」而非留空。 +- 缺项统一标「待确认」,并在文末待确认清单汇总。 +- 严禁编造试验数据、标准号、URL、签字、附件正文;附件清单类只建索引。 +- 声明不适用时须写完整理由(首期若任务在规划期关闭则不生成正文)。 diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/run_s0_rdev_continue.py b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/run_s0_rdev_continue.py new file mode 100644 index 00000000..3442059e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/run_s0_rdev_continue.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Continue S0.3 from project 6424: activate rdev + PI rd_design_input_spec.""" +from __future__ import annotations + +import base64 +import json +import os +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path + +BASE = os.environ.get("BASE_URL", "http://127.0.0.1:8080").rstrip("/") +ASSETS = Path(__file__).resolve().parent +PROJECT_ID = int(os.environ.get("PROJECT_ID", "6424")) +TASK_TIMEOUT = int(os.environ.get("TASK_TIMEOUT_SEC", "2400")) +LOG = [] + + +def log(msg): + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + LOG.append(line) + + +def b64(text: str) -> str: + return base64.b64encode(text.encode()).decode("ascii") + + +def req(method, path, token=None, body=None, timeout=60): + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body, ensure_ascii=False).encode() + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + r = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) + t0 = time.time() + try: + with urllib.request.urlopen(r, timeout=timeout) as resp: + raw = resp.read().decode() + parsed = json.loads(raw) if raw.strip() else None + return resp.status, parsed, raw, int((time.time() - t0) * 1000) + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + parsed = json.loads(raw) if raw.strip() else None + except json.JSONDecodeError: + parsed = None + return e.code, parsed, raw, int((time.time() - t0) * 1000) + + +def save_json(name, obj): + (ASSETS / name).write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") + + +def login(user, password): + st, parsed, raw, _ = req("POST", "/api/v1/auth/login", body={"username": user, "password": password}) + if st >= 400 or not parsed or not parsed.get("token"): + raise SystemExit(f"login failed: {st} {raw[:300]}") + return parsed["token"], parsed["user"] + + +def main(): + admin_token, admin = login(os.environ.get("ADMIN_USERNAME", "admin"), os.environ["ADMIN_PASSWORD"]) + admin_id = int(admin["id"]) + reviewer_token, reviewer = login("lizhuguan", "123456") + approver_token, approver = login("wangzong", "123456") + reviewer_id, approver_id = int(reviewer["id"]), int(approver["id"]) + author_id = admin_id + + st, proj, raw, _ = req("GET", f"/api/v1/projects/{PROJECT_ID}", admin_token) + log(f"project lifecycle={proj.get('lifecycle_status')} sel={proj.get('workflow_selection_version')} plan_rev={proj.get('workflow_plan_revision')}") + sel_ver = int(proj.get("workflow_selection_version") or 1) + plan_rev = int(proj.get("workflow_plan_revision") or 0) + + st, plan, raw, _ = req("GET", f"/api/v1/projects/{PROJECT_ID}/workflow-plan/default?workflow_key=rdev&version=0", admin_token) + if st != 200: + raise SystemExit(f"default rdev plan: {st} {raw[:800]}") + for stage in plan.get("stages") or []: + for dt in stage.get("doc_tasks") or []: + key = dt.get("task_key") or dt.get("key") + if key == "rd_design_input_spec": + dt["enabled"] = True + dt["duties"] = { + "author_user_id": author_id, + "reviewer_user_id": reviewer_id, + "approver_user_id": approver_id, + } + else: + dt["enabled"] = False + dt["duties"] = {} + st, _, raw, _ = req("PUT", f"/api/v1/projects/{PROJECT_ID}/workflow-plan", admin_token, { + "plan": plan, + "expected_plan_revision": plan_rev, + "expected_selection_version": sel_ver, + }) + log(f"save rdev plan http={st}") + if st != 200: + raise SystemExit(raw[:1000]) + st, preview, raw, _ = req("POST", f"/api/v1/projects/{PROJECT_ID}/activation-preview", admin_token) + if st != 200: + raise SystemExit(f"preview: {st} {raw[:800]}") + st, activated, raw, _ = req("POST", f"/api/v1/projects/{PROJECT_ID}/workflow-instances/activate", admin_token, { + "confirmation_token": preview["confirmation_token"], + "expected_project_version": preview["expected_project_version"], + "expected_plan_revision": preview["expected_plan_revision"], + }) + log(f"activate rdev http={st}") + if st != 200: + raise SystemExit(raw[:1200]) + save_json("activate-rdev.json", activated) + instance = activated.get("instance") or activated + instance_id = int(instance["id"]) + + # Advance empty stages until RD03 + stage_keys = [] + for s in plan.get("stages") or []: + sk = s.get("stage_key") or s.get("key") + if sk: + stage_keys.append(sk) + for sk in stage_keys: + if sk == "RD03_design_input": + break + detail = req("GET", f"/api/v1/workflow-instances/{instance_id}", admin_token)[1] + inst = (detail or {}).get("instance") or detail or {} + rev = inst.get("config_revision") + st, _, raw, ms = req("POST", f"/api/v1/workflow-instances/{instance_id}/stages/{sk}/advance", admin_token, { + "stage_key": sk, "expected_config_revision": rev, + }) + log(f"advance {sk}: http={st} ms={ms}") + if st != 200: + log(f"advance stop at {sk}: {raw[:400]}") + break + + fields = { + "customer_name": "水木东方测试客户", + "device_type": "二类有源医疗器械(神经刺激)", + "intended_use": "无创神经电刺激辅助改善目标症状", + "target_market": "中国 NMPA", + } + sess = f"sess-rdev-s0-p{PROJECT_ID}-rd_design_input_spec" + req("POST", "/api/v1/chat/sessions", admin_token, { + "session_id": sess, "title": "rd_design_input_spec", "user_id": author_id, "project_id": PROJECT_ID, + }) + body = { + "session_id": sess, + "client_turn_id": f"rdev-s0-di-{int(time.time())}", + "user_message": "请编制《设计输入规格书》,按模板五节专业结构写满,不做疗效宣称。", + "task_key": "rd_design_input_spec", + "project_name": proj.get("name"), + "customer_name": fields["customer_name"], + "device_type": fields["device_type"], + "intended_use": fields["intended_use"], + "target_market": fields["target_market"], + "fields": fields, + "backend": "pi-agent", + "project_id": PROJECT_ID, + "author_id": author_id, + "reviewer_id": reviewer_id, + "approver_id": approver_id, + "materials": [{ + "filename": "design_input_brief.md", + "content": b64( + "# 设计输入摸底\n\n" + "## 功能\n脉冲刺激、强度可调、会话计时、紧急停止。\n" + "## 性能\n刺激频率 1-100Hz,脉宽 50-500us,强度步进待确认。\n" + "## 接口与环境\nUSB 充电;工作温度 5-40C;电极接口待确认。\n" + "## 安全\n过流保护、电极脱落检测、风险输入引用 ISO 14971。\n" + "## 法规\nGB 9706.1、YY 9706.102、网络安全指导原则(非强制)。\n" + ), + }], + } + log("PI start rd_design_input_spec") + st, created, raw, _ = req("POST", "/api/v1/tasks/runs", admin_token, body, timeout=60) + if st not in (200, 202) or not created or not created.get("run_id"): + raise SystemExit(f"create run: {st} {raw[:1000]}") + run_id = created["run_id"] + final = None + t0 = time.time() + while time.time() - t0 < TASK_TIMEOUT: + gs, gb, _, _ = req("GET", f"/api/v1/tasks/runs/{run_id}", admin_token, timeout=30) + if gs == 200 and isinstance(gb, dict): + status = gb.get("status") or gb.get("Status") + if status in ("completed", "failed", "cancelled", "pending_author_confirmation"): + final = gb + log(f"PI done status={status} elapsed={int(time.time()-t0)}s err={gb.get('ErrorMessage') or gb.get('error') or ''}") + break + if int(time.time() - t0) % 30 < 12: + log(f"PI poll status={status} elapsed={int(time.time()-t0)}s") + time.sleep(10) + if not final: + raise SystemExit("timeout") + save_json("pi-rd_design_input_spec-meta.json", final) + + work = final.get("WorkspaceID") or final.get("work_dir") or final.get("workspace_id") or "" + host = work.replace("/app/data/", "/home/weichen/projects/Agent-System/data/dev/") + md = "" + agents = None + for root in (Path(host), Path(work)): + if not root.exists(): + continue + for p in [root / "output.md", *sorted(root.rglob("output.md"))]: + if p.is_file() and p.stat().st_size > len(md): + md = p.read_text(encoding="utf-8") + for rel in ("tasks/04_review/AGENTS.md", "tasks/05_review/AGENTS.md"): + p = root / rel + if p.is_file(): + agents = p + break + if agents is None: + for p in root.rglob("AGENTS.md"): + if "review" in str(p): + agents = p + break + if md: + (ASSETS / "rd_design_input_spec-final.md").write_text(md, encoding="utf-8") + if agents: + (ASSETS / "rdev-leader-AGENTS.md").write_text(agents.read_text(encoding="utf-8"), encoding="utf-8") + log(f"archived AGENTS from {agents}") + + st, doc, raw, _ = req("GET", f"/api/v1/docgov/document?doc_id=doc_proj{PROJECT_ID}_rd_design_input_spec", admin_token) + save_json("rd_design_input_spec-docgov.json", doc if doc else {"http": st, "raw": raw[:1000]}) + + summary = { + "project_id": PROJECT_ID, + "rdev_instance_id": instance_id, + "run_id": run_id, + "run_status": final.get("Status") or final.get("status"), + "di_chars": len(md), + "has_five_sections": all(h in md for h in ("## 功能要求", "## 性能要求", "## 接口与环境要求", "## 安全与风险输入", "## 法规与标准符合性")), + "no_placeholder": "按本阶段专业结构写" not in md, + "agents_ok": bool(agents) and ("设计控制" in agents.read_text(encoding="utf-8")), + "no_business_in_agents": bool(agents) and ("商务对接负责人" not in agents.read_text(encoding="utf-8")) and ("CDO" not in agents.read_text(encoding="utf-8")), + "stage_from_docgov": (doc or {}).get("stage") or (doc or {}).get("stage_key"), + "work_dir": host or work, + } + save_json("summary-rdev.json", summary) + (ASSETS / "console-rdev.log").write_text("\n".join(LOG) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/run_s0_real_pi.py b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/run_s0_real_pi.py new file mode 100644 index 00000000..115fee3a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/run_s0_real_pi.py @@ -0,0 +1,505 @@ +#!/usr/bin/env python3 +"""S0.3 real-PI sample for rdev template depth (dev :8080 + MiniMax).""" +from __future__ import annotations + +import base64 +import json +import os +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path + + +def b64(text: str) -> str: + return base64.b64encode(text.encode("utf-8")).decode("ascii") + +BASE = os.environ.get("BASE_URL", "http://127.0.0.1:8080").rstrip("/") +ASSETS = Path(os.environ.get( + "RDEV_S0_ASSETS", + Path(__file__).resolve().parent, +)) +ASSETS.mkdir(parents=True, exist_ok=True) +DATE = datetime.now().strftime("%Y%m%d-%H%M%S") +PROJ_NAME = f"rdev-s0-{DATE}" +TASK_TIMEOUT = int(os.environ.get("TASK_TIMEOUT_SEC", "2400")) +LOG: list[str] = [] + + +def log(msg: str) -> None: + line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + print(line, flush=True) + LOG.append(line) + + +def req(method, path, token=None, body=None, timeout=60): + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body, ensure_ascii=False).encode() + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + r = urllib.request.Request(BASE + path, data=data, headers=headers, method=method) + t0 = time.time() + try: + with urllib.request.urlopen(r, timeout=timeout) as resp: + raw = resp.read().decode() + parsed = json.loads(raw) if raw.strip() else None + return resp.status, parsed, raw, int((time.time() - t0) * 1000) + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + parsed = json.loads(raw) if raw.strip() else None + except json.JSONDecodeError: + parsed = None + return e.code, parsed, raw, int((time.time() - t0) * 1000) + + +def save_json(name, obj): + path = ASSETS / name + path.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def login(user, password): + st, parsed, raw, _ = req("POST", "/api/v1/auth/login", body={"username": user, "password": password}) + if st >= 400 or not parsed or not parsed.get("token"): + raise SystemExit(f"login failed for {user}: {st} {raw[:300]}") + return parsed["token"], parsed.get("user") or {} + + +def enable_only(plan: dict, keep: set[str], author: int, reviewer: int, approver: int): + for stage in plan.get("stages") or []: + for dt in stage.get("doc_tasks") or []: + key = dt.get("task_key") or dt.get("key") + if key in keep: + dt["enabled"] = True + dt["duties"] = { + "author_user_id": author, + "reviewer_user_id": reviewer, + "approver_user_id": approver, + } + else: + dt["enabled"] = False + dt["duties"] = {} + + +def plan_and_activate(token, project_id, workflow_key, keep_keys, author, reviewer, approver): + st, proj, raw, _ = req("GET", f"/api/v1/projects/{project_id}", token) + if st != 200: + raise SystemExit(f"get project: {st} {raw[:400]}") + sel_ver = int(proj.get("workflow_selection_version") or 1) + plan_rev = int(proj.get("workflow_plan_revision") or 0) + + st, plan, raw, ms = req( + "GET", + f"/api/v1/projects/{project_id}/workflow-plan/default?workflow_key={workflow_key}&version=0", + token, + ) + log(f"default plan {workflow_key}: http={st} ms={ms}") + if st != 200: + raise SystemExit(f"default plan failed: {raw[:800]}") + enable_only(plan, set(keep_keys), author, reviewer, approver) + save_body = { + "plan": plan, + "expected_plan_revision": plan_rev, + "expected_selection_version": sel_ver, + } + st, saved, raw, ms = req("PUT", f"/api/v1/projects/{project_id}/workflow-plan", token, save_body) + log(f"save plan {workflow_key}: http={st} ms={ms}") + if st != 200: + raise SystemExit(f"save plan failed: {raw[:1200]}") + st, preview, raw, ms = req("POST", f"/api/v1/projects/{project_id}/activation-preview", token) + log(f"activation-preview: http={st} ms={ms}") + if st != 200: + raise SystemExit(f"preview failed: {raw[:800]}") + act_body = { + "confirmation_token": preview["confirmation_token"], + "expected_project_version": preview["expected_project_version"], + "expected_plan_revision": preview["expected_plan_revision"], + } + st, activated, raw, ms = req( + "POST", f"/api/v1/projects/{project_id}/workflow-instances/activate", token, act_body + ) + log(f"activate {workflow_key}: http={st} ms={ms}") + if st != 200: + raise SystemExit(f"activate failed: {raw[:1200]}") + save_json(f"activate-{workflow_key}.json", activated) + return activated + + +def run_pi(token, project_id, task_key, fields, author, reviewer, approver, materials=None, session_id=None): + sess = session_id or f"sess-rdev-s0-p{project_id}-{task_key}" + # ensure chat session exists + req("POST", "/api/v1/chat/sessions", token, { + "session_id": sess, + "title": f"{PROJ_NAME}/{task_key}", + "user_id": author, + "project_id": project_id, + }) + body = { + "session_id": sess, + "client_turn_id": f"rdev-s0-{task_key}-{int(time.time())}", + "user_message": f"请编制《{task_key}》受控文档,按模板写满专业章节。", + "task_key": task_key, + "project_name": PROJ_NAME, + "customer_name": fields.get("customer_name", "水木东方测试客户"), + "device_type": fields.get("device_type"), + "intended_use": fields.get("intended_use"), + "target_market": fields.get("target_market"), + "fields": fields, + "backend": "pi-agent", + "project_id": project_id, + "author_id": author, + "reviewer_id": reviewer, + "approver_id": approver, + "materials": materials or [ + { + "filename": "brief.md", + "content": b64("# rdev S0 brief\n\n无创神经刺激器设计输入摸底。\n"), + } + ], + } + log(f"PI async start task={task_key}") + st, parsed, raw, ms = req("POST", "/api/v1/tasks/runs", token, body, timeout=60) + log(f"PI create run task={task_key} http={st} ms={ms}") + if st not in (200, 202) or not parsed or not parsed.get("run_id"): + raise SystemExit(f"create run failed: {st} {(raw or '')[:1500]}") + run_id = parsed["run_id"] + final = None + t0 = time.time() + while time.time() - t0 < TASK_TIMEOUT: + gs, gb, graw, _ = req("GET", f"/api/v1/tasks/runs/{run_id}", token, timeout=30) + if gs == 200 and isinstance(gb, dict): + status = gb.get("status") or gb.get("Status") + if status in ("completed", "failed", "cancelled", "pending_author_confirmation"): + final = gb + err = gb.get("error") or gb.get("ErrorMessage") or "" + log(f"PI poll task={task_key} status={status} elapsed={int(time.time()-t0)}s err={err[:120]}") + break + if int(time.time() - t0) % 30 < 12: + log(f"PI poll task={task_key} status={status} elapsed={int(time.time()-t0)}s keys={list(gb.keys())[:8]}") + time.sleep(10) + if final is None: + raise SystemExit(f"PI {task_key} timed out run_id={run_id}") + save_json(f"pi-{task_key}-meta.json", { + "create_http": st, + "run_id": run_id, + "final": final, + "duration_ms": int((time.time() - t0) * 1000), + }) + if final.get("status") != "completed" and final.get("Status") != "completed": + # Soft salvage: PI workspace may have completed while UoW publish failed. + work_dir = final.get("work_dir") or final.get("WorkspaceID") or "" + ws_id = final.get("workspace_id") or "" + soft = { + "success": False, + "work_dir": work_dir, + "workspace_id": ws_id, + "doc_record": final.get("doc_record"), + "primary_artifact": final.get("primary_artifact") or {}, + "run_id": run_id, + "raw_final": final, + } + md = extract_markdown(soft) + if len(md) >= 800: + log(f"PI {task_key} UoW failed but workspace output salvageable chars={len(md)}") + soft["success"] = True + soft["salvaged"] = True + return soft + raise SystemExit( + f"PI {task_key} not completed: {final.get('status') or final.get('Status')} " + f"err={final.get('error') or final.get('ErrorMessage')}" + ) + status = final.get("status") or final.get("Status") + work_dir = final.get("work_dir") or final.get("WorkspaceID") or "" + return { + "success": True, + "work_dir": work_dir, + "workspace_id": final.get("workspace_id") or parsed.get("workspace_id"), + "doc_record": final.get("doc_record"), + "primary_artifact": final.get("primary_artifact") or {}, + "run_id": run_id, + "raw_final": final, + "status": status, + } + + +def extract_markdown(pi_result: dict) -> str: + pa = pi_result.get("primary_artifact") or {} + md = pa.get("markdown_body") or "" + work_dir = pi_result.get("work_dir") or "" + ws_id = pi_result.get("workspace_id") or "" + roots = [] + if work_dir: + roots.append(Path(work_dir)) + if ws_id: + roots.append(Path("/home/weichen/projects/Agent-System/data/dev/workspaces") / ws_id) + roots.append(Path("/home/weichen/projects/Agent-System/data/dev/workspaces/bizdev") / ws_id) + # common patterns + for p in Path("/home/weichen/projects/Agent-System/data/dev/workspaces").rglob(ws_id): + if p.is_dir(): + roots.append(p) + break + for root in roots: + if not root.is_dir(): + continue + candidates = [root / "output.md"] + candidates.extend(sorted(root.rglob("output.md"))) + for p in candidates: + if p.is_file(): + text = p.read_text(encoding="utf-8") + if len(text) > len(md): + md = text + # remember work_dir for agents lookup + if not pi_result.get("work_dir"): + pi_result["work_dir"] = str(root) + return md + + +def find_leader_agents(work_dir: str) -> str | None: + if not work_dir: + return None + root = Path(work_dir) + for rel in ("tasks/04_review/AGENTS.md", "tasks/05_review/AGENTS.md", "tasks/03_review/AGENTS.md"): + p = root / rel + if p.is_file(): + return str(p) + if root.is_dir(): + for p in root.rglob("AGENTS.md"): + if "review" in str(p): + return str(p) + return None + + +def approve_chain(author_token, reviewer_token, approver_token, doc_id, author_id, reviewer_id, approver_id): + st, _, raw, _ = req("POST", "/api/v1/docgov/claim", author_token, { + "doc_id": doc_id, "user_id": author_id, "user_name": "author", + }) + log(f"claim: http={st}") + st, _, raw, _ = req("POST", "/api/v1/docgov/submit-review", author_token, { + "doc_id": doc_id, "author_id": author_id, "note": "S0 auto submit", + }) + log(f"submit-review: http={st}") + if st != 200: + raise SystemExit(f"submit-review failed: {raw[:800]}") + st, _, raw, _ = req("POST", "/api/v1/docgov/review-decision", reviewer_token, { + "doc_id": doc_id, "user_id": reviewer_id, "user_name": "reviewer", + "action": "approve", "comment": "S0 ok", + }) + log(f"review-decision: http={st}") + if st != 200: + raise SystemExit(f"review-decision failed: {raw[:800]}") + st, _, raw, _ = req("POST", "/api/v1/docgov/approver-decision", approver_token, { + "doc_id": doc_id, "user_id": approver_id, "user_name": "approver", + "action": "approve", "comment": "S0 ok", + }) + log(f"approver-decision: http={st}") + if st != 200: + raise SystemExit(f"approver-decision failed: {raw[:800]}") + + +def get_instance(token, instance_id): + st, parsed, raw, _ = req("GET", f"/api/v1/workflow-instances/{instance_id}", token) + if st != 200: + raise SystemExit(f"get instance: {st} {raw[:600]}") + return parsed + + +def advance_stages(token, instance_id, stage_keys: list[str]): + for sk in stage_keys: + detail = get_instance(token, instance_id) + inst = detail.get("instance") or detail + status = (inst.get("status") or "").lower() + if status in ("completed", "complete", "done"): + log(f"instance already {status}") + return detail + rev = inst.get("config_revision") + body = {"stage_key": sk, "expected_config_revision": rev} + st, parsed, raw, ms = req( + "POST", + f"/api/v1/workflow-instances/{instance_id}/stages/{sk}/advance", + token, + body, + ) + log(f"advance {sk}: http={st} ms={ms}") + if st != 200: + save_json(f"advance-fail-{sk}.json", {"http": st, "body": parsed or raw}) + raise SystemExit(f"advance {sk} failed: {raw[:1200]}") + time.sleep(0.3) + return get_instance(token, instance_id) + + +def main(): + admin_user = os.environ.get("ADMIN_USERNAME", "admin") + admin_pass = os.environ["ADMIN_PASSWORD"] + admin_token, admin = login(admin_user, admin_pass) + admin_id = int(admin["id"]) + log(f"admin id={admin_id}") + + # Prefer seeded users for cross-user duty; author always admin to avoid UoW permission denied. + author_token, author_id = admin_token, admin_id + reviewer_token, reviewer_id = admin_token, admin_id + approver_token, approver_id = admin_token, admin_id + for user, pwd, slot in ( + ("lizhuguan", "123456", "reviewer"), + ("wangzong", "123456", "approver"), + ): + try: + tok, u = login(user, pwd) + except SystemExit: + log(f"seed user {user} unavailable; keep admin") + continue + uid = int(u["id"]) + log(f"seed user {user} id={uid}") + if slot == "reviewer": + reviewer_token, reviewer_id = tok, uid + else: + approver_token, approver_id = tok, uid + log(f"duty author=admin({admin_id}) reviewer={reviewer_id} approver={approver_id}") + + st, proj, raw, ms = req( + "POST", + "/api/v1/projects", + admin_token, + { + "name": PROJ_NAME, + "product_domain": "medical_device", + "device_type": "二类有源医疗器械(神经刺激)", + "intended_use": "无创神经电刺激辅助改善目标症状", + "target_market": "中国 NMPA", + "customer_name": "水木东方测试客户", + }, + ) + log(f"create project: http={st} ms={ms}") + if st not in (200, 201): + raise SystemExit(f"create project failed: {raw[:800]}") + project_id = int(proj["id"]) + save_json("project.json", proj) + + # Ensure creator ownership via admin; upsert duty users as members + for uid, role in ((admin_id, "owner"), (author_id, "member"), (reviewer_id, "member"), (approver_id, "member")): + st, _, raw, _ = req( + "POST", f"/api/v1/projects/{project_id}/members", admin_token, + {"user_id": uid, "role": role}, + ) + log(f"member user={uid} role={role} http={st}") + + fields = { + "customer_name": "水木东方测试客户", + "device_type": "二类有源医疗器械(神经刺激)", + "intended_use": "无创神经电刺激辅助改善目标症状", + "target_market": "中国 NMPA", + } + + activated = plan_and_activate( + admin_token, project_id, "main", ["intake"], author_id, reviewer_id, approver_id, + ) + instance = activated.get("instance") or activated + instance_id = int(instance["id"]) + + intake = run_pi(author_token, project_id, "intake", fields, author_id, reviewer_id, approver_id) + intake_md = extract_markdown(intake) + (ASSETS / "intake-final.md").write_text(intake_md, encoding="utf-8") + doc_id = ((intake.get("doc_record") or {}).get("doc_id")) or f"doc_proj{project_id}_intake" + # StageKey regression sample + save_json("intake-doc-id.json", {"doc_id": doc_id}) + + approve_chain( + author_token, reviewer_token, approver_token, doc_id, + author_id, reviewer_id, approver_id, + ) + main_final = advance_stages(admin_token, instance_id, ["V0.1_triage", "V0.5_feasibility", "V1.0_quoting"]) + save_json("main-instance-final.json", main_final) + main_status = ((main_final.get("instance") or main_final).get("status")) + log(f"main final status={main_status}") + + activated2 = plan_and_activate( + admin_token, project_id, "rdev", ["rd_design_input_spec"], + author_id, reviewer_id, approver_id, + ) + instance2 = activated2.get("instance") or activated2 + instance2_id = int(instance2["id"]) + + # Advance empty early stages to RD03 if needed + rdev_stage_order = [] + for s in (activated2.get("plan") or {}).get("stages") or []: + sk = s.get("stage_key") or s.get("key") + if sk: + rdev_stage_order.append(sk) + # fallback known order prefix + if not rdev_stage_order: + rdev_stage_order = [ + "RD01_planning", "RD02_planning_review", "RD03_design_input", + ] + # advance until design input stage is current / reachable + for sk in rdev_stage_order: + if sk == "RD03_design_input": + break + try: + advance_stages(admin_token, instance2_id, [sk]) + except SystemExit as e: + log(f"advance skip/fail {sk}: {e}") + break + + di = run_pi( + author_token, project_id, "rd_design_input_spec", fields, + author_id, reviewer_id, approver_id, + materials=[{ + "filename": "design_input_brief.md", + "content": b64( + "# 设计输入摸底\n\n" + "## 功能\n脉冲刺激、强度可调、会话计时、紧急停止。\n" + "## 性能\n刺激频率 1-100Hz,脉宽 50-500us,强度步进待确认。\n" + "## 接口与环境\nUSB 充电;工作温度 5-40C;电极接口待确认。\n" + "## 安全\n过流保护、电极脱落检测、风险输入引用 ISO 14971。\n" + "## 法规\nGB 9706.1、YY 9706.102、网络安全指导原则(非强制)。\n" + ), + }], + ) + di_md = extract_markdown(di) + (ASSETS / "rd_design_input_spec-final.md").write_text(di_md, encoding="utf-8") + work_dir = di.get("work_dir") or "" + agents_path = find_leader_agents(work_dir) + if agents_path and Path(agents_path).is_file(): + text = Path(agents_path).read_text(encoding="utf-8") + (ASSETS / "rdev-leader-AGENTS.md").write_text(text, encoding="utf-8") + log(f"archived leader AGENTS from {agents_path}") + if "设计控制" not in text: + log("WARN: leader AGENTS missing 设计控制") + if "商务对接负责人" in text or "CDO" in text: + log("WARN: leader AGENTS still has business tone") + else: + log("WARN: leader AGENTS.md not found") + + st, docs, raw, _ = req("GET", f"/api/v1/projects/{project_id}/documents", admin_token) + save_json("documents.json", docs if docs is not None else {"raw": raw[:2000]}) + # try document detail for stage key + st, doc_detail, raw, _ = req("GET", f"/api/v1/docgov/document?doc_id=doc_proj{project_id}_rd_design_input_spec", admin_token) + save_json("rd_design_input_spec-docgov.json", doc_detail if doc_detail is not None else {"http": st, "raw": raw[:1000]}) + + (ASSETS / "console.log").write_text("\n".join(LOG) + "\n", encoding="utf-8") + summary = { + "project_id": project_id, + "project_name": PROJ_NAME, + "main_instance_id": instance_id, + "main_status": main_status, + "rdev_instance_id": instance2_id, + "intake_chars": len(intake_md), + "di_chars": len(di_md), + "agents_path": agents_path, + "work_dir": work_dir, + "has_five_sections": all(h in di_md for h in ("## 功能要求", "## 性能要求", "## 接口与环境要求", "## 安全与风险输入", "## 法规与标准符合性")), + "no_placeholder": "按本阶段专业结构写" not in di_md, + "no_business_tone": ("商务对接负责人" not in di_md) and ("CDO" not in di_md), + } + save_json("summary.json", summary) + log(f"DONE {json.dumps(summary, ensure_ascii=False)}") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/save-rdev-plan-error.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/save-rdev-plan-error.json new file mode 100644 index 00000000..fd696a50 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/save-rdev-plan-error.json @@ -0,0 +1 @@ +{"code":"INTERNAL","message":"请求无法完成","request_id":"req_cdebce820010fd8b","error":"INTERNAL"} diff --git a/.doc/test-reports/assets/2026-09-17-rdev-template-depth/summary-rdev.json b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/summary-rdev.json new file mode 100644 index 00000000..c228b3f3 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-rdev-template-depth/summary-rdev.json @@ -0,0 +1,18 @@ +{ + "main_project_id": 6424, + "rdev_project_id": 6425, + "main_intake_run_id": "run_1789635054471569201", + "main_intake_duration_ms": 220109, + "rdev_run_id": "run_1789635960880707641", + "rdev_status": "completed", + "workspace": "/app/data/workspaces/bizdev/rd_design_input_spec_1789635960900711737", + "work_dir": "/home/weichen/projects/Agent-System/data/dev/workspaces/bizdev/rd_design_input_spec_1789635960900711737", + "di_chars": 15988, + "stage": "RD03_design_input", + "has_five_sections": true, + "no_placeholder": true, + "agents_ok": true, + "no_business_in_agents": true, + "no_business_in_final": true, + "duration_wall": "09:06:00Z–review output 09:11 (~5min squad); run status=completed" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-18-wave0-p0-bug-fixes/chat-stream-long.meta.json b/.doc/test-reports/assets/2026-09-18-wave0-p0-bug-fixes/chat-stream-long.meta.json new file mode 100644 index 00000000..bde6a6c9 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave0-p0-bug-fixes/chat-stream-long.meta.json @@ -0,0 +1,12 @@ +{ + "session_prefix": "wave0-sse-", + "duration_ms": 70164, + "heartbeats": 4, + "heartbeat_format": ": heartbeat ", + "done": true, + "error_event": false, + "agent_backend": "pi-agent", + "model": "MiniMax-M2.7-highspeed", + "log": "chat-stream-long.sse.txt", + "note": "Silent PI generation interval produced 4 heartbeats; stream completed with event:done. Absolute wall-clock >120s covered by TestSSEHeartbeat_HelpersSurviveShortWriteTimeout (same helpers)." +} diff --git a/.doc/test-reports/assets/2026-09-18-wave0-p0-bug-fixes/chat-stream-long.sse.txt b/.doc/test-reports/assets/2026-09-18-wave0-p0-bug-fixes/chat-stream-long.sse.txt new file mode 100644 index 00000000..91d06ae3 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave0-p0-bug-fixes/chat-stream-long.sse.txt @@ -0,0 +1,32 @@ +event: phase +data: {"id":"evt_1789663519038949547_1","type":"phase","timestamp":"2026-09-17T16:45:19.038950507Z","phase":{"phase_id":"intent_recognizing","message":"正在研判您的意图与项目上下文...","role":"coordinator"}} + +event: turn_ack +data: {"id":"evt_1789663519046202771_2","type":"turn_ack","timestamp":"2026-09-17T16:45:19.046205441Z","turn":{"user_message_id":3627,"status":"processing"}} + +event: status +data: {"id":"evt_1789663519048229581_3","type":"status","timestamp":"2026-09-17T16:45:19.048230441Z","status":{"intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","current_stage":"V0.1_triage","message":"识别分支: AMBIGUOUS_FALLBACK (置信度: 20%)"}} + +event: phase +data: {"id":"evt_1789663520016667046_4","type":"phase","timestamp":"2026-09-17T16:45:20.016668616Z","phase":{"phase_id":"dialogue_generating","message":"正在整理回复...","role":"assistant","progress":40}} + +event: status +data: {"id":"evt_1789663520016723644_5","type":"status","timestamp":"2026-09-17T16:45:20.016724074Z","status":{"intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","current_stage":"V0.1_triage","message":"意图判定: CASUAL_CHAT (置信度: 20%)"}} + +event: phase +data: {"id":"evt_1789663520016913708_6","type":"phase","timestamp":"2026-09-17T16:45:20.016913968Z","phase":{"phase_id":"dialogue_generating","message":"正在生成项目助手回复...","role":"assistant","progress":60}} + +: heartbeat 1789663534 + +: heartbeat 1789663549 + +: heartbeat 1789663564 + +: heartbeat 1789663579 + +event: delta +data: {"id":"evt_1789663589203412243_7","type":"delta","timestamp":"2026-09-17T16:46:29.203413813Z","delta":"# IEC 62304 与 ISO 13485 交叉合规要点——可执行检查项清单\n\n\u003e **适用说明**:本清单聚焦软件类医疗器械(SA/SB级)开发与质量管理体系的核心交叉节点,适用于注册前内部审计或委托方核查参考。具体证据要求请以官方审核意见及实际检测结果为准。\n\n---\n\n## 一、顶层体系架构(对应 ISO 13485 Cl.4 / IEC 62304 Cl.4)\n\n### 检查项 1:软件质量管理体系覆盖完整性\n\n**理由**:IEC 62304 将软件生命周期过程嵌入质量管理体系框架,若QMS未明确覆盖软件特定要求(如变更控制、配置管理),两者即存在体系缝隙。\n\n**证据要求**:\n- 质量手册中明确引用 IEC 62304 作为支持性标准\n- 程序文件覆盖软件计划、需求、设计、验证、确认、维护全过程\n- 质量目标分解至软件关键过程\n\n**常见失败模式**:\n- QMS文件仅笼统提及\"软件开发\"但未细化软件特定过程\n- IEC 62304要求被作为\"参考\"而非\"强制\"嵌入体系\n\n---\n\n### 检查项 2:风险管理整合(ISO 14971 × IEC 62304)\n\n**理由**:IEC 62304要求软件相关风险控制措施纳入软件计划,并与ISO 14971风险管理体系对接。软件安全分类(SA/SB/SC)直接影响开发过程要求深度。\n\n**证据要求**:\n- 风险管理文档明确软件危险源识别与控制措施\n- 软件安全分类决策记录及依据\n- 危险情况与软件需求的追溯矩阵\n\n**常见失败模式**:\n- 软件风险分析脱离产品整体风险管理文档\n- 安全分类无充分依据或分类决策无记录\n\n---\n\n## 二、软件生命周期策划(对应 ISO 13485 Cl.5.4 / IEC 62304 Cl.4.1-4.2)\n\n### 检查项 3:软件开发计划与质量计划的对应\n\n**理由**:软件开发计划(SDP)是IEC 62304核心交付物,需与ISO 13485质量计划形成映射,确保资源、责任、里程碑一致。\n\n**证据要求**:\n- SDP涵盖范围、角色职责、阶段入口/出口准则、交付物清单\n- SDP与项目总体质量计划引用关系明确\n- 计划变更记录与审批记录完整\n\n**常见失败模式**:\n- SDP与质量计划各自独立,缺乏交叉引用\n- 里程碑与设计历史文件(DHF)节点不对应\n\n---\n\n### 检查项 4:软件相关供应商/分包商控制\n\n**理由**:若软件组件由第三方开发(含开源组件、OEM模块),ISO 13485要求对供方进行控制,IEC 62304要求在开发计划中明确委托开发范围。\n\n**证据要求**:\n- 供应商评估记录(含软件开发能力、质量体系符合性)\n- 委托开发协议明确需求、验收准则、交付物\n- 来料/接收检验记录或等效确认记录\n\n**常见失败模式**:\n- 采购订单未明确软件验收标准\n- 使用开源组件但无许可证合规性评估\n- 第三方代码无配置管理纳入记录\n\n---\n\n## 三、软件需求管理(对应 ISO 13485 Cl.7.2 / IEC 62304 Cl.4.3)\n\n### 检查项 5:软件需求的可追溯性\n\n**理由**:IEC 62304强制要求软件需求与上层系统/用户需求、后续设计、测试用例形成双向追溯链,ISO 13485的CAPA与设计变更同样依赖追溯性。\n\n**证据要求**:\n- 软件需求规格(SRS)包含唯一标识\n- 追溯矩阵覆盖:用户需求→软件需求→软件架构→详细设计→单元测试→集成测试\n- 需求变更影响分析记录\n\n**常见失败模式**:\n- 追溯矩阵缺失或仅单向追溯\n- 需求变更未触发影响分析\n- 追溯链断裂(设计/测试无法回溯至原始需求)\n\n---\n\n### 检查项 6:软件需求变更控制\n\n**理由**:软件需求变更若无受控流程,将导致设计漂移、验证不充分,与ISO 13485设计控制变更要求和CAPA机制直接关联。\n\n**证据要求**:\n- 需求变更申请(CR)记录及审批记录\n- 变更影响评估文档\n- 变更实施后的验证/确认记录\n\n**常见失败模式**:\n- 变更未走正式审批流程(口头变更或邮件替代文件)\n- 变更实施后未重新执行相关测试\n- 变更记录与实际代码版本不对应\n\n---\n\n## 四、软件设计与开发(对应 ISO 13485 Cl.7.3 / IEC 62304 Cl.4.4-4.5)\n\n### 检查项 7:软件架构设计文档完整性\n\n**理由**:软件架构是IEC 62304对B/C级软件的强制要求,也是评估软件可维护性、可验证性的基础。\n\n**证据要求**:\n- 软件架构规格文档(含模块划分、接口定义、数据流)\n- 软件安全架构设计(若适用)\n- 架构设计与需求的一致性确认记录\n\n**常见失败模式**:\n- 架构文档缺失或过于简略(仅框图无说明)\n- 接口定义不清晰导致集成阶段问题\n- 未体现容错/冗余等安全机制\n\n---\n\n### 检查项 8:软件详细设计与代码规范执行\n\n**理由**:IEC 62304要求C级软件必须有详细设计,代码应符合编码规范;ISO 13485要求生产与服务控制覆盖软件交付。\n\n**证据要求**:\n- 详细设计规格文档(DDSD)或等效记录\n- 代码规范/编程规则文档\n- 代码评审记录(含静态分析结果)\n- 编译器/工具链版本记录\n\n**常见失败模式**:\n- 详细设计与代码实现不一致\n- 代码评审流于形式(无实质问题记录)\n- 使用未经确认的工具版本\n\n---\n\n### 检查项 9:SOUP组件管理(IEC 62304专有要求)\n\n**理由**:使用现成软件(SOUP)时需证明其适用于预期用途,SOUP管理是IEC 62304对软件安全性评估的关键环节。\n\n**证据要求**:\n- SOUP清单(含名称、版本、供应商、用途)\n- SOUP适用性分析记录\n- SOUP已知问题/漏洞清单及风险评估\n- SOUP更新验证记录\n\n**常见失败模式**:\n- SOUP未纳入配置管理\n- 适用性分析缺失或照抄厂商声明\n- SOUP版本变更未重新评估影响\n\n---\n\n## 五、软件验证与确认(对应 ISO 13485 Cl.7.3.4 / IEC 62304 Cl.4.6-4.7)\n\n### 检查项 10:单元测试与集成测试的充分性\n\n**理由**:IEC 62304根据安全分类规定测试覆盖率要求(B/C级需结构覆盖率分析),测试记录是验证软件满足需求的核心证据。\n\n**证据要求**:\n- 测试计划/用例规格文档\n- 测试执行记录(通过/失败记录)\n- 结构覆盖率分析报告(C/B级)\n- 缺陷管理与回归测试记录\n\n**常见失败模式**:\n- 测试用例与软件需求无追溯关系\n- 结构覆盖率不满足A级要求(B/C级)\n- 失败的测试用例未完成根本原因分析即关闭\n\n---\n\n### 检查项 11:软件确认(用户现场测试/临床测试)\n\n**理由**:软件确认需证明产品在真实使用环境下满足用户需求,与临床评价、可用性工程紧密关联。\n\n**证据要求**:\n- 确认计划与用例\n- 模拟使用环境测试记录\n- 用户代表参与确认的签到/反馈记录\n- 确认报告与需求符合性声明\n\n**常见失败模式**:\n- 确认环境未真实模拟临床使用场景\n- 确认样本量不足以支持临床评价结论\n- 确认发现的问题未纳入CAPA\n\n---\n\n## 六、软件维护与变更(对应 ISO 13485 Cl.8.5 / IEC 62304 Cl.4.8-4.9)\n\n### 检查项 12:软件变更控制流程\n\n**理由**:软件维护阶段变更若无受控流程,可能引入新风险;ISO 13485的变更控制与CAPA机制需覆盖软件变更。\n\n**证据要求**:\n- 变更请求记录与影响评估\n- 变更实施记录(含代码、文档更新)\n- 变更后验证/回归测试记录\n- 变更版本发布记录\n\n**常见失败模式**:\n- 紧急修补未走变更流程即发布\n- 变更记录与实际版本不匹配\n- 变更未重新执行完整测试\n\n---\n\n### 检查项 13:软件问题报告与纠正措施\n\n**理由**:运行阶段软件问题需通过CAPA系统处理,与ISO 13485不合格品控制、客户抱怨管理对接。\n\n**证据要求**:\n- 软件问题/缺陷追踪记录\n- 问题根本原因分析(适宜时采用FMEA/FTA)\n- CAPA记录与有效性验证\n- 问题关闭审批记录\n\n**常见失败模式**:\n- 问题追踪系统未与QMS CAPA系统对接\n- 根本原因分析流于表面(仅描述现象未查根因)\n- CAPA有效性验证缺失或敷衍\n\n---\n\n## 七、配置管理与交付(对应 ISO 13485 Cl.7.5 / IEC 62304 Cl.4.10)\n\n### 检查项 14:软件配置管理系统有效性\n\n**理由**:配置管理是IEC 62304强制要求,需唯一标识软件版本、保持可追溯性,防止未授权变更。\n\n**证据要求**:\n- 配置管理计划(含配置项定义、变更控制流程)\n- 软件版本记录表(BOM清单)\n- 基线建立与变更审计记录\n- 配置状态报告\n\n**常见失败模式**:\n- 配置管理工具与开发环境未集成\n- 多版本并存无版本隔离控制\n- 基线变更无正式审批记录\n\n---\n\n### 检查项 15:软件交付与安装确认\n\n**理由**:软件交付物需受控,确保临床环境安装版本与注册确认版本一致。\n\n**证据要求**:\n- 交付物清单与签收记录\n- 安装验证/确认记录\n- 客户环境兼容性声明\n- 交付后支持与维护协议\n\n**常见失败模式**:\n- 交付版本与注册版本不一致\n- 客户现场安装无记录\n- 交付物清单与实际提供内容不符\n\n---\n\n## 八、文档与记录(对应 ISO 13485 Cl.4.2 / IEC 62304 Cl.9)\n\n### 检查项 16:软件文档完整性核查\n\n**理由**:IEC 62304规定软件生命周期各阶段的文档输出要求,监管审核时需完整提供;文档缺失是常见审核开项。\n\n**证据要求**:\n- DHF(设计历史文档)清单完整性\n- 各阶段技术文档与 IEC 62304 Table 3-9 对照检查记录\n- 文档审阅与批准记录\n\n**常见失败模式**:\n- 文档版本与软件版本不对应\n- 阶段交付物缺失(如无架构设计文档)\n- 文档签署不完整(缺审核/批准)\n\n---\n\n### 检查项 17:设计转移与制造确认\n\n**理由**:软件从开发向生产的转移需受控,确保批量生产软件与确认软件一致。\n\n**证据要求**:\n- 设计转移检查清单\n- 生产和检验设备确认记录\n- 首批/首批次生产记录\n- 软件版本放行记录\n\n**常见失败模式**:\n- 生产版本未经验证即放行\n- 放行记录无责任人签署\n- 生产环境与开发环境差异未评估\n\n---\n\n## 九、总结建议\n\n以上 17 条检查项覆盖 IEC 62304 与 ISO 13485 交叉合规的核心高风险点。建议贵司在正式审核前按以下优先级开展自查:\n\n| 优先级 | 领域 | 关键风险 |\n|--------|------|----------|\n| P1 | 需求追溯与变更控制 | 设计漂移、验证不充分 |\n| P1 | 文档完整性 | 审核开项重灾区 |\n| P2 | SOUP管理 | 安全分类与风险控制 |\n| P2 | 确认与交付 | 临床使用合规 |\n| P3 | 供应商控制 | 供应链合规漏洞 |\n\n\u003e **提示**:以上内容基于 IEC 62304:2006/AMD1:2015 与 ISO 13485:2016 通用理解,具体项目应以注册申报受理意见及官方检测报告为准。如需针对贵司产品(device_type 未明确)细化检查项,我再进一步补充。"} + +event: done +data: {"id":"evt_1789663589203505600_8","type":"done","timestamp":"2026-09-17T16:46:29.20350667Z","done":{"session_id":"wave0-sse-1789663519","duration_ms":70164,"total_events":7,"user_message_id":3627,"message_id":3628,"status":"completed","content_revision":1,"content_sha256":"0abbdab3223ec9aa0c2b42b73384c88d45f58fe5e562fb82fbe00b5f5dcf4661","export_capability":{"policy":"full","formats":["rich_clipboard","markdown","docx","pdf","private_document"],"body_selector":"content_only","content_revision":1,"content_sha256":"0abbdab3223ec9aa0c2b42b73384c88d45f58fe5e562fb82fbe00b5f5dcf4661"}}} + diff --git a/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/api_v1_admin_system_overview.txt b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/api_v1_admin_system_overview.txt new file mode 100644 index 00000000..c57d8906 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/api_v1_admin_system_overview.txt @@ -0,0 +1,3 @@ +status=200 +{"status":"ok","env":"main","agentBackend":"pi-agent","databaseReady":true,"schemaVersion":52,"taskStoreReady":true,"kbOnline":true,"uptimeSeconds":30} + diff --git a/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/api_v1_projects.txt b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/api_v1_projects.txt new file mode 100644 index 00000000..7d924c4c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/api_v1_projects.txt @@ -0,0 +1,3 @@ +status=200 +[] + diff --git a/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/consumer-modes.txt b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/consumer-modes.txt new file mode 100644 index 00000000..7a06cf5f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/consumer-modes.txt @@ -0,0 +1,9 @@ +=== consumer modes === + environment | consumer | mode | revision +-------------+------------------+------+---------- + main | fav | off | 1 + main | notify | off | 1 + main | projection | off | 1 + main | workflow_advance | off | 1 +(4 rows) + diff --git a/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/health-app.txt b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/health-app.txt new file mode 100644 index 00000000..69997c25 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/health-app.txt @@ -0,0 +1,2 @@ +=== app health === +{"status":"ok","env":"main","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":52,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/health-events.txt b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/health-events.txt new file mode 100644 index 00000000..8ff80415 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/health-events.txt @@ -0,0 +1,2 @@ +=== events health === +{"env":"main","owner":"events-1","status":"ok"} diff --git a/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/login.txt b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/login.txt new file mode 100644 index 00000000..615c40d5 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/login.txt @@ -0,0 +1 @@ +login_ok env=main diff --git a/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/ready-app.txt b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/ready-app.txt new file mode 100644 index 00000000..e24e8975 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/ready-app.txt @@ -0,0 +1,2 @@ +=== app ready === +{"status":"ok","env":"main","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":52,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/schema-version.txt b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/schema-version.txt new file mode 100644 index 00000000..0691f67b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/schema-version.txt @@ -0,0 +1 @@ +52 diff --git a/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/system-settings.txt b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/system-settings.txt new file mode 100644 index 00000000..7d5750ec --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave1-main-env-fix/system-settings.txt @@ -0,0 +1,3 @@ +status=200 +{"settings":[{"key":"ui.maintenance_notice","label":"维护公告","group":"ui","type":"string","value":"","default":"","writable":true,"requiresRestart":false,"description":"全站维护公告横幅文案;空字符串表示关闭","revision":0},{"key":"ui.read_only_mode","label":"只读模式提示","group":"ui","type":"bool","value":false,"default":false,"writable":true,"requiresRestart":false,"description":"开启后前端进入只读提示模式,阻断写操作入口","revision":0},{"key":"chat.default_session_role","label":"新会话默认职责","group":"chat","type":"string","value":"engineer","default":"engineer","writable":true,"requiresRestart":false,"description":"未显式指定职责时新会话的默认角色","revision":0},{"key":"tasks.default_auto_repair","label":"任务默认自动修复","group":"tasks","type":"bool","value":false,"default":false,"writable":true,"requiresRestart":false,"description":"独立任务运行未传 auto_repair 时的默认值","revision":0},{"key":"kb.default_search_limit","label":"KB 检索默认条数","group":"kb","type":"number","value":8,"default":8,"writable":true,"requiresRestart":false,"description":"知识库检索未指定 limit 时的默认返回条数","revision":0},{"key":"notifications.stage_advance_reminder","label":"阶段推进提醒总开关","group":"notification","type":"bool","value":true,"default":true,"writable":true,"requiresRestart":false,"description":"阶段就绪 24h 提醒的总开关(一期先落机制)","revision":0},{"key":"workflow.allow_author_only_approval","label":"允许编制人兼任审核批准","group":"workflow","type":"bool","value":false,"default":false,"writable":true,"requiresRestart":false,"description":"授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。","revision":0,"updatedAt":"2026-09-17T17:06:38.893967Z"}]} + diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/compose-config-flags.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/compose-config-flags.txt new file mode 100644 index 00000000..2121db81 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/compose-config-flags.txt @@ -0,0 +1,26 @@ +=== dev === + MULTI_INTENT_PLAN_TIER: legacy + MULTI_INTENT_U9_SEGMENT: "false" + MULTI_INTENT_U10_MUTATE: "false" + MULTI_INTENT_U11_DOCGOV: "false" + MULTI_INTENT_U12_RUN_CONTROL: "false" + PI_CHANGE_REPORT_NARRATIVE: "false" + SEMANTIC_SUMMARY_SHADOW: "false" +=== test === + MULTI_INTENT_PLAN_TIER: legacy + MULTI_INTENT_U9_SEGMENT: "false" + MULTI_INTENT_U10_MUTATE: "false" + MULTI_INTENT_U11_DOCGOV: "false" + MULTI_INTENT_U12_RUN_CONTROL: "false" + PI_CHANGE_REPORT_NARRATIVE: "false" + SEED_DEMO_USERS: "false" + SEMANTIC_SUMMARY_SHADOW: "false" +=== main === + MULTI_INTENT_PLAN_TIER: legacy + MULTI_INTENT_U9_SEGMENT: "false" + MULTI_INTENT_U10_MUTATE: "false" + MULTI_INTENT_U11_DOCGOV: "false" + MULTI_INTENT_U12_RUN_CONTROL: "false" + PI_CHANGE_REPORT_NARRATIVE: "false" + SEED_DEMO_USERS: "false" + SEMANTIC_SUMMARY_SHADOW: "false" diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/cursor-agent-ps.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/cursor-agent-ps.txt new file mode 100644 index 00000000..1fb6dfeb --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/cursor-agent-ps.txt @@ -0,0 +1 @@ +cursor-agent 容器已清理,符合预期 diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/env-dev.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/env-dev.txt new file mode 100644 index 00000000..2bbda172 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/env-dev.txt @@ -0,0 +1,4 @@ +SEMANTIC_SUMMARY_SHADOW=false +MULTI_INTENT_PLAN_TIER=legacy +PI_CHANGE_REPORT_NARRATIVE=false +MULTI_INTENT_U9_SEGMENT=false diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/env-main.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/env-main.txt new file mode 100644 index 00000000..3533abe6 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/env-main.txt @@ -0,0 +1,5 @@ +MULTI_INTENT_U9_SEGMENT=false +PI_CHANGE_REPORT_NARRATIVE=false +MULTI_INTENT_PLAN_TIER=legacy +SEMANTIC_SUMMARY_SHADOW=false +SEED_DEMO_USERS=false diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/env-test.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/env-test.txt new file mode 100644 index 00000000..96f21f6a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/env-test.txt @@ -0,0 +1,5 @@ +PI_CHANGE_REPORT_NARRATIVE=false +SEED_DEMO_USERS=false +MULTI_INTENT_PLAN_TIER=legacy +MULTI_INTENT_U9_SEGMENT=false +SEMANTIC_SUMMARY_SHADOW=false diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/go-test-short.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/go-test-short.txt new file mode 100644 index 00000000..4719476b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/go-test-short.txt @@ -0,0 +1,67 @@ +? chat-agent-system/cmd/artifact-vault [no test files] +ok chat-agent-system/cmd/bizdev-cli 2.818s +? chat-agent-system/cmd/dataset [no test files] +ok chat-agent-system/cmd/document-content-reconcile 0.004s +? chat-agent-system/cmd/document-revision-backfill [no test files] +? chat-agent-system/cmd/engineering-dashboard [no test files] +? chat-agent-system/cmd/eventmode [no test files] +? chat-agent-system/cmd/events [no test files] +? chat-agent-system/cmd/recategorize [no test files] +? chat-agent-system/cmd/server [no test files] +ok chat-agent-system/internal/agent 3.615s +ok chat-agent-system/internal/app 0.019s +ok chat-agent-system/internal/app/chatflow 0.005s +ok chat-agent-system/internal/app/docadvance 0.004s +ok chat-agent-system/internal/app/docflow 0.004s +ok chat-agent-system/internal/app/docnotify 0.004s +ok chat-agent-system/internal/app/docproject 0.004s +ok chat-agent-system/internal/app/fav 0.003s +ok chat-agent-system/internal/app/intentflow 0.015s +ok chat-agent-system/internal/app/stageflow 0.004s +ok chat-agent-system/internal/app/uow 0.003s +ok chat-agent-system/internal/archtest 0.178s +ok chat-agent-system/internal/artifactvault 0.006s +ok chat-agent-system/internal/auth 0.995s +ok chat-agent-system/internal/authz 0.004s +ok chat-agent-system/internal/bizdev 0.147s +ok chat-agent-system/internal/chat 0.018s +ok chat-agent-system/internal/config 0.005s +ok chat-agent-system/internal/coordinator 0.013s +ok chat-agent-system/internal/ctxmgr 0.087s +ok chat-agent-system/internal/dataset 0.005s +ok chat-agent-system/internal/db 0.005s +ok chat-agent-system/internal/doccontent 0.006s +ok chat-agent-system/internal/docduty 0.005s +ok chat-agent-system/internal/docgov 0.028s +ok chat-agent-system/internal/docquery 0.005s +ok chat-agent-system/internal/docrevision 0.009s +ok chat-agent-system/internal/engineeringdashboard 0.011s +ok chat-agent-system/internal/events 0.006s +ok chat-agent-system/internal/freedoc 0.028s +ok chat-agent-system/internal/fsutil 0.005s +ok chat-agent-system/internal/httpx 0.003s +ok chat-agent-system/internal/intent 0.026s +ok chat-agent-system/internal/kb 0.010s +ok chat-agent-system/internal/maturity 0.004s +ok chat-agent-system/internal/notification 0.083s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext 0.009s +ok chat-agent-system/internal/projectinstance 0.009s +ok chat-agent-system/internal/projectworkflow 0.007s +ok chat-agent-system/internal/quote 0.012s +ok chat-agent-system/internal/replyexport 0.004s +ok chat-agent-system/internal/server 13.647s +ok chat-agent-system/internal/squad 0.031s +ok chat-agent-system/internal/storage 0.018s +ok chat-agent-system/internal/stream 0.003s +ok chat-agent-system/internal/stream/transport 0.002s +ok chat-agent-system/internal/swboot 0.002s +ok chat-agent-system/internal/sysadmin 0.003s +ok chat-agent-system/internal/taskrun 0.193s +ok chat-agent-system/internal/template 0.792s +ok chat-agent-system/internal/upload 0.002s +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime 0.002s +ok chat-agent-system/internal/workflowdef 0.004s +ok chat-agent-system/internal/workspace 0.003s +? chat-agent-system/templates [no test files] diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/health-dev.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/health-dev.txt new file mode 100644 index 00000000..10283cb2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/health-dev.txt @@ -0,0 +1 @@ +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":52,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/health-main.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/health-main.txt new file mode 100644 index 00000000..8b1182b9 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/health-main.txt @@ -0,0 +1 @@ +{"status":"ok","env":"main","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":52,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/health-test.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/health-test.txt new file mode 100644 index 00000000..51800ac1 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/health-test.txt @@ -0,0 +1 @@ +{"status":"ok","env":"test","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":52,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/no-cursor-runner.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/no-cursor-runner.txt new file mode 100644 index 00000000..81e08fd2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/no-cursor-runner.txt @@ -0,0 +1,6 @@ +== shuimu-agent-app-dev == +(no CURSOR_RUNNER) +== shuimu-agent-app-test == +(no CURSOR_RUNNER) +== shuimu-agent-app-main == +(no CURSOR_RUNNER) diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/ready-dev.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/ready-dev.txt new file mode 100644 index 00000000..10283cb2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/ready-dev.txt @@ -0,0 +1 @@ +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":52,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/ready-main.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/ready-main.txt new file mode 100644 index 00000000..8b1182b9 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/ready-main.txt @@ -0,0 +1 @@ +{"status":"ok","env":"main","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":52,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/ready-test.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/ready-test.txt new file mode 100644 index 00000000..51800ac1 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/ready-test.txt @@ -0,0 +1 @@ +{"status":"ok","env":"test","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":52,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/rebuild-dev.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/rebuild-dev.txt new file mode 100644 index 00000000..b9937345 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/rebuild-dev.txt @@ -0,0 +1,241 @@ +bash: _comp_complete_longopt: line 6: syntax error near unexpected token `(' +bash: _comp_complete_longopt: line 6: ` --!(no-*)dir*)' +bash: error importing function definition for `_comp_complete_longopt' +bash: _comp_compgen_usage: line 11: syntax error near unexpected token `(' +bash: _comp_compgen_usage: line 11: ` -?(\[)+([a-zA-Z0-9?]))' +bash: error importing function definition for `_comp_compgen_usage' +bash: _comp_abspath: line 11: syntax error near unexpected token `(' +bash: _comp_abspath: line 11: ` /..?(/*))' +bash: error importing function definition for `_comp_abspath' +==> [dev] 重建镜像并重建容器... + Image shuimu-pi-agent:dev Building + Image agent-dev-app Building + Image agent-dev-events Building +#1 [internal] load local bake definitions +#1 reading from stdin 1.44kB done +#1 DONE 0.0s + +#2 [events internal] load build definition from Dockerfile +#2 transferring dockerfile: 1.53kB done +#2 DONE 0.0s + +#3 [pi-agent internal] load build definition from Dockerfile +#3 transferring dockerfile: 1.58kB done +#3 DONE 0.0s + +#4 [auth] library/node:pull token for docker.1ms.run +#4 DONE 0.0s + +#5 [auth] library/golang:pull token for docker.1ms.run +#5 DONE 0.0s + +#6 [events internal] load metadata for docker.io/library/debian:bookworm-slim +#6 DONE 0.2s + +#7 [app internal] load metadata for docker.io/library/node:22-bookworm +#7 DONE 0.3s + +#8 [pi-agent internal] load metadata for docker.io/library/node:22-bookworm-slim +#8 DONE 0.3s + +#9 [pi-agent internal] load .dockerignore +#9 transferring context: 234B done +#9 DONE 0.0s + +#10 [pi-agent 1/11] FROM docker.io/library/node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 +#10 resolve docker.io/library/node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 done +#10 DONE 0.0s + +#11 [pi-agent internal] load build context +#11 transferring context: 31.86kB done +#11 DONE 0.0s + +#12 [pi-agent 2/11] RUN set -eux; apt-get update; apt-get install -y --no-install-recommends ca-certificates curl git bash; rm -rf /var/lib/apt/lists/* +#12 CACHED + +#13 [pi-agent 4/11] RUN pi --version +#13 CACHED + +#14 [pi-agent 10/11] COPY deploy/pi-agent/entrypoint.sh /app/entrypoint.sh +#14 CACHED + +#15 [pi-agent 3/11] RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 --omit=dev +#15 CACHED + +#16 [pi-agent 9/11] COPY deploy/pi-agent/settle-policy.mjs /app/settle-policy.mjs +#16 CACHED + +#17 [pi-agent 5/11] WORKDIR /app +#17 CACHED + +#18 [pi-agent 6/11] COPY deploy/pi-agent/extensions/ /app/extensions/ +#18 CACHED + +#19 [pi-agent 8/11] COPY deploy/pi-agent/result-v1.mjs /app/result-v1.mjs +#19 CACHED + +#20 [pi-agent 7/11] COPY deploy/pi-agent/runner.mjs /app/runner.mjs +#20 CACHED + +#21 [pi-agent 11/11] RUN chmod +x /app/entrypoint.sh /app/runner.mjs +#21 CACHED + +#22 [pi-agent] exporting to image +#22 exporting layers done +#22 exporting manifest sha256:84554c4311e3d33663a2349a68614db0f2e6a0192c0b3abfed729f1ac76b8d13 done +#22 exporting config sha256:14f5c203947655983caf00baf53d78ff49d250b4f21afda352e293cfd5f72ed0 done +#22 exporting attestation manifest sha256:878049c8092819af2815f4776e9c5350e796915e21c53664f626c34a06165cd2 done +#22 exporting manifest list sha256:550b5c654b4cb7f7416a4a31a01a910c7406a6aff8f198316a017872c9399400 done +#22 naming to docker.io/library/shuimu-pi-agent:dev done +#22 unpacking to docker.io/library/shuimu-pi-agent:dev done +#22 DONE 0.0s + +#23 [pi-agent] resolving provenance for metadata file +#23 DONE 0.0s + +#24 [events internal] load metadata for docker.io/library/golang:1.23-bookworm +#24 DONE 0.4s + +#9 [app internal] load .dockerignore +#9 CACHED + +#25 [events stage-2 1/9] FROM docker.io/library/debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 +#25 resolve docker.io/library/debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 done +#25 DONE 0.0s + +#26 [events go-builder 1/7] FROM docker.io/library/golang:1.23-bookworm@sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db +#26 resolve docker.io/library/golang:1.23-bookworm@sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db done +#26 DONE 0.0s + +#27 [events web-builder 1/6] FROM docker.io/library/node:22-bookworm@sha256:8a34c4ab3ea2c5cd194f07e317b2a8f09461d3c8b05c4e34c8ccd56d56024c4d +#27 resolve docker.io/library/node:22-bookworm@sha256:8a34c4ab3ea2c5cd194f07e317b2a8f09461d3c8b05c4e34c8ccd56d56024c4d done +#27 DONE 0.0s + +#28 [app internal] load build context +#28 transferring context: 222.49MB 1.3s done +#28 DONE 1.3s + +#29 [app go-builder 2/7] WORKDIR /src +#29 CACHED + +#30 [app go-builder 3/7] COPY go.mod go.sum ./ +#30 CACHED + +#31 [app go-builder 4/7] RUN go mod download +#31 CACHED + +#30 [events go-builder 3/7] COPY go.mod go.sum ./ +#30 CACHED + +#29 [events go-builder 2/7] WORKDIR /src +#29 CACHED + +#31 [events go-builder 4/7] RUN go mod download +#31 CACHED + +#32 [app go-builder 5/7] COPY . . +#32 DONE 0.6s + +#33 [app go-builder 6/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server +#33 DONE 5.6s + +#34 [app go-builder 7/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /events ./cmd/events +#34 DONE 0.4s + +#35 [events web-builder 3/6] COPY web/package.json web/package-lock.json* ./ +#35 CACHED + +#36 [events web-builder 4/6] RUN npm ci +#36 CACHED + +#37 [events web-builder 2/6] WORKDIR /web +#37 CACHED + +#38 [events web-builder 5/6] COPY web/ ./ +#38 CACHED + +#39 [events web-builder 6/6] RUN npm run build +#39 CACHED + +#35 [app web-builder 3/6] COPY web/package.json web/package-lock.json* ./ +#35 CACHED + +#36 [app web-builder 4/6] RUN npm ci +#36 CACHED + +#38 [app web-builder 5/6] COPY web/ ./ +#38 CACHED + +#37 [app web-builder 2/6] WORKDIR /web +#37 CACHED + +#40 [events stage-2 2/9] RUN set -eux; apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/* +#40 CACHED + +#41 [events stage-2 3/9] WORKDIR /app +#41 CACHED + +#42 [events stage-2 4/9] COPY --from=go-builder /server /app/server +#42 DONE 0.0s + +#43 [app stage-2 5/9] COPY --from=go-builder /events /app/events +#43 DONE 0.0s + +#44 [app stage-2 6/9] COPY --from=web-builder /web/dist /app/web/dist +#44 DONE 0.0s + +#45 [app stage-2 7/9] COPY deploy/postgres/goose /app/deploy/postgres/goose +#45 DONE 0.0s + +#46 [app stage-2 8/9] COPY templates /app/templates +#46 DONE 0.0s + +#47 [app stage-2 9/9] COPY configs /app/configs +#47 DONE 0.0s + +#48 [app] exporting to image +#48 exporting layers +#48 exporting layers 0.5s done +#48 exporting manifest sha256:04c67391828ee8b47f1db71a0d4519617c24f888e6a05d0b99a7e36743812a4b done +#48 exporting config sha256:989a25456db60c359f9f580764d1eb14d98b3452ca79e378e66b67afb07a1c6c done +#48 exporting attestation manifest sha256:3073ce8f927cb9767fef8e1a2dc6c40b3ff8be9dbacb40282be97fb17ed0e545 done +#48 exporting manifest list sha256:798407858486b71b8a619dbfb9e9d2c60825a39103c1d62cbc34b58a4e074007 done +#48 naming to docker.io/library/agent-dev-app:latest done +#48 unpacking to docker.io/library/agent-dev-app:latest +#48 unpacking to docker.io/library/agent-dev-app:latest 0.1s done +#48 DONE 0.7s + +#49 [events] exporting to image +#49 exporting layers 0.5s done +#49 exporting manifest sha256:d83a8cee4049dde5b224f565d601ea19c2a95601f3f7530907aa216c4c20ff1d done +#49 exporting config sha256:3265b044a67eb4c1eee4657796e7b41587c58cabbf05c746358d874f12c1e4e9 done +#49 exporting attestation manifest sha256:517c658d51110717fc6dbcd9fa05345f896599ef7ccfb82319f132a9f214ef1c done +#49 exporting manifest list sha256:a6ae9ee66df8b9efa7e78c6022b141fb3acf28dcde06df66e61658e5236a4135 done +#49 naming to docker.io/library/agent-dev-events:latest done +#49 unpacking to docker.io/library/agent-dev-events:latest 0.1s done +#49 DONE 0.7s + +#50 [events] resolving provenance for metadata file +#50 DONE 0.0s + +#51 [app] resolving provenance for metadata file +#51 DONE 0.0s + Image agent-dev-app Built + Image agent-dev-events Built + Image shuimu-pi-agent:dev Built +time="2026-09-18T02:27:30Z" level=warning msg="Found orphan containers (shuimu-cursor-agent-dev) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up." + Container shuimu-context-pg-dev Running + Container shuimu-pi-agent-dev Running + Container shuimu-agent-app-dev Recreate + Container shuimu-agent-events-dev Recreate + Container shuimu-agent-events-dev Recreated + Container shuimu-agent-app-dev Recreated + Container shuimu-context-pg-dev Waiting + Container shuimu-context-pg-dev Waiting + Container shuimu-context-pg-dev Healthy + Container shuimu-agent-app-dev Starting + Container shuimu-context-pg-dev Healthy + Container shuimu-agent-events-dev Starting + Container shuimu-agent-app-dev Started + Container shuimu-agent-events-dev Started +==> [dev] 镜像与容器已更新 diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/rebuild-main.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/rebuild-main.txt new file mode 100644 index 00000000..d117a2d8 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/rebuild-main.txt @@ -0,0 +1,273 @@ +bash: _comp_complete_longopt: line 6: syntax error near unexpected token `(' +bash: _comp_complete_longopt: line 6: ` --!(no-*)dir*)' +bash: error importing function definition for `_comp_complete_longopt' +bash: _comp_compgen_usage: line 11: syntax error near unexpected token `(' +bash: _comp_compgen_usage: line 11: ` -?(\[)+([a-zA-Z0-9?]))' +bash: error importing function definition for `_comp_compgen_usage' +bash: _comp_abspath: line 11: syntax error near unexpected token `(' +bash: _comp_abspath: line 11: ` /..?(/*))' +bash: error importing function definition for `_comp_abspath' +==> [main] 重建镜像并重建容器... + Image shuimu-pi-agent:main Building + Image shuimu-agent-app:main Building + Image shuimu-agent-events:main Building +#1 [internal] load local bake definitions +#1 reading from stdin 1.46kB done +#1 DONE 0.0s + +#2 [app internal] load build definition from Dockerfile +#2 transferring dockerfile: 1.53kB done +#2 DONE 0.0s + +#3 [pi-agent internal] load build definition from Dockerfile +#3 transferring dockerfile: 1.58kB done +#3 DONE 0.0s + +#4 [events internal] load metadata for docker.io/library/golang:1.23-bookworm +#4 DONE 0.2s + +#5 [pi-agent internal] load metadata for docker.io/library/node:22-bookworm-slim +#5 DONE 0.2s + +#6 [app internal] load metadata for docker.io/library/debian:bookworm-slim +#6 DONE 0.2s + +#7 [events internal] load metadata for docker.io/library/node:22-bookworm +#7 DONE 0.2s + +#8 [pi-agent internal] load .dockerignore +#8 transferring context: 234B done +#8 DONE 0.0s + +#9 [events web-builder 1/6] FROM docker.io/library/node:22-bookworm@sha256:8a34c4ab3ea2c5cd194f07e317b2a8f09461d3c8b05c4e34c8ccd56d56024c4d +#9 resolve docker.io/library/node:22-bookworm@sha256:8a34c4ab3ea2c5cd194f07e317b2a8f09461d3c8b05c4e34c8ccd56d56024c4d 0.0s done +#9 DONE 0.0s + +#10 [events go-builder 1/7] FROM docker.io/library/golang:1.23-bookworm@sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db +#10 resolve docker.io/library/golang:1.23-bookworm@sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db 0.0s done +#10 DONE 0.0s + +#11 [pi-agent 1/11] FROM docker.io/library/node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 +#11 resolve docker.io/library/node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 0.0s done +#11 DONE 0.0s + +#12 [app stage-2 1/9] FROM docker.io/library/debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 +#12 resolve docker.io/library/debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 0.0s done +#12 DONE 0.0s + +#13 [events internal] load build context +#13 ... + +#14 [pi-agent internal] load build context +#14 transferring context: 370B 0.2s done +#14 DONE 0.2s + +#15 [pi-agent 5/11] WORKDIR /app +#15 CACHED + +#16 [pi-agent 10/11] COPY deploy/pi-agent/entrypoint.sh /app/entrypoint.sh +#16 CACHED + +#17 [pi-agent 2/11] RUN set -eux; apt-get update; apt-get install -y --no-install-recommends ca-certificates curl git bash; rm -rf /var/lib/apt/lists/* +#17 CACHED + +#18 [pi-agent 6/11] COPY deploy/pi-agent/extensions/ /app/extensions/ +#18 CACHED + +#19 [pi-agent 7/11] COPY deploy/pi-agent/runner.mjs /app/runner.mjs +#19 CACHED + +#20 [pi-agent 9/11] COPY deploy/pi-agent/settle-policy.mjs /app/settle-policy.mjs +#20 CACHED + +#21 [pi-agent 4/11] RUN pi --version +#21 CACHED + +#22 [pi-agent 8/11] COPY deploy/pi-agent/result-v1.mjs /app/result-v1.mjs +#22 CACHED + +#23 [pi-agent 3/11] RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 --omit=dev +#23 CACHED + +#24 [pi-agent 11/11] RUN chmod +x /app/entrypoint.sh /app/runner.mjs +#24 CACHED + +#13 [events internal] load build context +#13 ... + +#25 [pi-agent] exporting to image +#25 exporting layers done +#25 exporting manifest sha256:00dd6f420c5889e9f052aa89d79841c1a0f83049615b4e4323b2d82f6c8d5883 done +#25 exporting config sha256:6fb3b41374fb6603f230764ea3b571057aedbb8f203750d82a4f952f2eef32a6 done +#25 exporting attestation manifest sha256:9fd15be8e4581d337ad168939b929303c8720a2b8ca992180f1b7f45a408b5c7 done +#25 exporting manifest list sha256:bbbd412b6257c88566d2fddadd4c1d15c75d5c7b84f4741d2959d792c6e9ef07 done +#25 naming to docker.io/library/shuimu-pi-agent:main done +#25 unpacking to docker.io/library/shuimu-pi-agent:main done +#25 DONE 0.0s + +#26 [pi-agent] resolving provenance for metadata file +#26 DONE 0.0s + +#13 [events internal] load build context +#13 transferring context: 222.49MB 1.2s done +#13 DONE 1.3s + +#27 [events stage-2 4/9] COPY --from=go-builder /server /app/server +#27 CACHED + +#28 [events stage-2 5/9] COPY --from=go-builder /events /app/events +#28 CACHED + +#29 [events stage-2 7/9] COPY deploy/postgres/goose /app/deploy/postgres/goose +#29 CACHED + +#30 [events web-builder 3/6] COPY web/package.json web/package-lock.json* ./ +#30 CACHED + +#31 [events go-builder 3/7] COPY go.mod go.sum ./ +#31 CACHED + +#32 [events stage-2 6/9] COPY --from=web-builder /web/dist /app/web/dist +#32 CACHED + +#33 [events stage-2 8/9] COPY templates /app/templates +#33 CACHED + +#34 [events go-builder 5/7] COPY . . +#34 CACHED + +#35 [events go-builder 7/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /events ./cmd/events +#35 CACHED + +#36 [events stage-2 2/9] RUN set -eux; apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/* +#36 CACHED + +#37 [events go-builder 4/7] RUN go mod download +#37 CACHED + +#38 [events web-builder 5/6] COPY web/ ./ +#38 CACHED + +#39 [events go-builder 6/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server +#39 CACHED + +#40 [events stage-2 3/9] WORKDIR /app +#40 CACHED + +#41 [events web-builder 6/6] RUN npm run build +#41 CACHED + +#42 [events web-builder 4/6] RUN npm ci +#42 CACHED + +#43 [events go-builder 2/7] WORKDIR /src +#43 CACHED + +#44 [events web-builder 2/6] WORKDIR /web +#44 CACHED + +#45 [events stage-2 9/9] COPY configs /app/configs +#45 CACHED + +#43 [app go-builder 2/7] WORKDIR /src +#43 CACHED + +#35 [app go-builder 7/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /events ./cmd/events +#35 CACHED + +#32 [app stage-2 6/9] COPY --from=web-builder /web/dist /app/web/dist +#32 CACHED + +#31 [app go-builder 3/7] COPY go.mod go.sum ./ +#31 CACHED + +#37 [app go-builder 4/7] RUN go mod download +#37 CACHED + +#27 [app stage-2 4/9] COPY --from=go-builder /server /app/server +#27 CACHED + +#28 [app stage-2 5/9] COPY --from=go-builder /events /app/events +#28 CACHED + +#36 [app stage-2 2/9] RUN set -eux; apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/* +#36 CACHED + +#29 [app stage-2 7/9] COPY deploy/postgres/goose /app/deploy/postgres/goose +#29 CACHED + +#33 [app stage-2 8/9] COPY templates /app/templates +#33 CACHED + +#40 [app stage-2 3/9] WORKDIR /app +#40 CACHED + +#34 [app go-builder 5/7] COPY . . +#34 CACHED + +#41 [app web-builder 6/6] RUN npm run build +#41 CACHED + +#44 [app web-builder 2/6] WORKDIR /web +#44 CACHED + +#30 [app web-builder 3/6] COPY web/package.json web/package-lock.json* ./ +#30 CACHED + +#42 [app web-builder 4/6] RUN npm ci +#42 CACHED + +#38 [app web-builder 5/6] COPY web/ ./ +#38 CACHED + +#39 [app go-builder 6/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server +#39 CACHED + +#45 [app stage-2 9/9] COPY configs /app/configs +#45 CACHED + +#46 [app] exporting to image +#46 exporting layers done +#46 exporting manifest sha256:6495f26cc3b1335b4c8eb2469266eb5bac4a0036fe8ff31a7b728d386812d229 done +#46 exporting config sha256:47f00c764b23f97ffcce9a6992d111a761ef23029f9083e21ff97b7a5040820c done +#46 exporting attestation manifest sha256:4ac1f23e728744a6677ce74d360def0ed60122f97aceaa0305d712be3ec197da done +#46 exporting manifest list sha256:0ffe0550461c2b75c783292994d75e71083f7c20c7e4a9a52ad9e5959580c2eb done +#46 naming to docker.io/library/shuimu-agent-app:main done +#46 unpacking to docker.io/library/shuimu-agent-app:main done +#46 DONE 0.0s + +#47 [events] exporting to image +#47 exporting layers done +#47 exporting manifest sha256:41d097641b18678300381e1bfbe15f25b550cbe0c1872373ec9a42579ad0d7a0 done +#47 exporting config sha256:331e62d9860e78be499377641a85267f36985cd553bd54c437ea16f6c7d8e8ae done +#47 exporting attestation manifest sha256:de7420d084201e8e26f0461498c6117d766abc025c54308b31712c3bb1d8edc5 done +#47 exporting manifest list sha256:0ad0e4d55a4de05a3ea0dc93e578028826530f5cbc1af81588662b90d03dbba3 done +#47 naming to docker.io/library/shuimu-agent-events:main done +#47 unpacking to docker.io/library/shuimu-agent-events:main done +#47 DONE 0.0s + +#48 [app] resolving provenance for metadata file +#48 DONE 0.0s + +#49 [events] resolving provenance for metadata file +#49 DONE 0.0s + Image shuimu-agent-events:main Built + Image shuimu-agent-app:main Built + Image shuimu-pi-agent:main Built + Container shuimu-system-pg-main Running + Container shuimu-pi-agent-main Running + Container shuimu-agent-events-main Recreate + Container shuimu-agent-events-main Recreated + Container shuimu-agent-app-main Recreate + Container shuimu-agent-app-main Recreated + Container shuimu-system-pg-main Waiting + Container shuimu-system-pg-main Healthy + Container shuimu-agent-events-main Starting + Container shuimu-agent-events-main Started + Container shuimu-agent-events-main Waiting + Container shuimu-system-pg-main Waiting + Container shuimu-system-pg-main Healthy + Container shuimu-agent-events-main Healthy + Container shuimu-agent-app-main Starting + Container shuimu-agent-app-main Started +==> [main] 镜像与容器已更新 diff --git a/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/rebuild-test.txt b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/rebuild-test.txt new file mode 100644 index 00000000..d6b61a47 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave2-config-flag-align/rebuild-test.txt @@ -0,0 +1,211 @@ +bash: _comp_complete_longopt: line 6: syntax error near unexpected token `(' +bash: _comp_complete_longopt: line 6: ` --!(no-*)dir*)' +bash: error importing function definition for `_comp_complete_longopt' +bash: _comp_compgen_usage: line 11: syntax error near unexpected token `(' +bash: _comp_compgen_usage: line 11: ` -?(\[)+([a-zA-Z0-9?]))' +bash: error importing function definition for `_comp_compgen_usage' +bash: _comp_abspath: line 11: syntax error near unexpected token `(' +bash: _comp_abspath: line 11: ` /..?(/*))' +bash: error importing function definition for `_comp_abspath' +==> [test] 重建镜像并重建容器... + Image shuimu-agent-app:test Building + Image shuimu-agent-events:test Building + Image shuimu-pi-agent:test Building +#1 [internal] load local bake definitions +#1 reading from stdin 1.46kB done +#1 DONE 0.0s + +#2 [pi-agent internal] load build definition from Dockerfile +#2 transferring dockerfile: 1.58kB done +#2 DONE 0.0s + +#3 [app internal] load build definition from Dockerfile +#3 transferring dockerfile: 1.53kB done +#3 DONE 0.0s + +#4 [events internal] load metadata for docker.io/library/node:22-bookworm +#4 DONE 0.1s + +#5 [events internal] load metadata for docker.io/library/golang:1.23-bookworm +#5 DONE 0.1s + +#6 [pi-agent internal] load metadata for docker.io/library/node:22-bookworm-slim +#6 DONE 0.1s + +#7 [app internal] load .dockerignore +#7 transferring context: 234B done +#7 DONE 0.0s + +#8 [pi-agent 1/11] FROM docker.io/library/node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 +#8 resolve docker.io/library/node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 done +#8 DONE 0.0s + +#9 [events internal] load metadata for docker.io/library/debian:bookworm-slim +#9 DONE 0.2s + +#10 [events stage-2 1/9] FROM docker.io/library/debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 +#10 resolve docker.io/library/debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 done +#10 DONE 0.0s + +#11 [events web-builder 1/6] FROM docker.io/library/node:22-bookworm@sha256:8a34c4ab3ea2c5cd194f07e317b2a8f09461d3c8b05c4e34c8ccd56d56024c4d +#11 resolve docker.io/library/node:22-bookworm@sha256:8a34c4ab3ea2c5cd194f07e317b2a8f09461d3c8b05c4e34c8ccd56d56024c4d done +#11 DONE 0.0s + +#12 [events go-builder 1/7] FROM docker.io/library/golang:1.23-bookworm@sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db +#12 resolve docker.io/library/golang:1.23-bookworm@sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db done +#12 DONE 0.0s + +#13 [events internal] load build context +#13 ... + +#14 [pi-agent internal] load build context +#14 transferring context: 370B 0.2s done +#14 DONE 0.2s + +#15 [pi-agent 7/11] COPY deploy/pi-agent/runner.mjs /app/runner.mjs +#15 CACHED + +#16 [pi-agent 6/11] COPY deploy/pi-agent/extensions/ /app/extensions/ +#16 CACHED + +#17 [pi-agent 9/11] COPY deploy/pi-agent/settle-policy.mjs /app/settle-policy.mjs +#17 CACHED + +#18 [pi-agent 4/11] RUN pi --version +#18 CACHED + +#19 [pi-agent 5/11] WORKDIR /app +#19 CACHED + +#20 [pi-agent 10/11] COPY deploy/pi-agent/entrypoint.sh /app/entrypoint.sh +#20 CACHED + +#21 [pi-agent 8/11] COPY deploy/pi-agent/result-v1.mjs /app/result-v1.mjs +#21 CACHED + +#22 [pi-agent 2/11] RUN set -eux; apt-get update; apt-get install -y --no-install-recommends ca-certificates curl git bash; rm -rf /var/lib/apt/lists/* +#22 CACHED + +#23 [pi-agent 3/11] RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 --omit=dev +#23 CACHED + +#24 [pi-agent 11/11] RUN chmod +x /app/entrypoint.sh /app/runner.mjs +#24 CACHED + +#25 [pi-agent] exporting to image +#25 exporting layers done +#25 exporting manifest sha256:1565757be5855280b35ef7e3901496c75270397a465f7789201be925706df172 done +#25 exporting config sha256:e52bdca1a5b6048f4ccb007889c7cfce3a61c45e006a093444fba756eb5108f0 done +#25 exporting attestation manifest sha256:ec324b442933cb70139ea0c431a0b209bfef902a666aaa923462056dcb406f57 done +#25 exporting manifest list sha256:8bc634170db637f31011804e24643905a8382bb71e3429b71349d2a731e75643 done +#25 naming to docker.io/library/shuimu-pi-agent:test done +#25 unpacking to docker.io/library/shuimu-pi-agent:test done +#25 DONE 0.0s + +#26 [pi-agent] resolving provenance for metadata file +#26 DONE 0.0s + +#13 [events internal] load build context +#13 transferring context: 1.96MB 0.6s done +#13 DONE 0.6s + +#27 [app go-builder 5/7] COPY . . +#27 CACHED + +#28 [app go-builder 7/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /events ./cmd/events +#28 CACHED + +#29 [app stage-2 2/9] RUN set -eux; apt-get update; apt-get install -y --no-install-recommends ca-certificates curl; rm -rf /var/lib/apt/lists/* +#29 CACHED + +#30 [app stage-2 6/9] COPY --from=web-builder /web/dist /app/web/dist +#30 CACHED + +#31 [app stage-2 8/9] COPY templates /app/templates +#31 CACHED + +#32 [app web-builder 2/6] WORKDIR /web +#32 CACHED + +#33 [app stage-2 5/9] COPY --from=go-builder /events /app/events +#33 CACHED + +#34 [app stage-2 3/9] WORKDIR /app +#34 CACHED + +#35 [app go-builder 3/7] COPY go.mod go.sum ./ +#35 CACHED + +#36 [app go-builder 2/7] WORKDIR /src +#36 CACHED + +#37 [app stage-2 4/9] COPY --from=go-builder /server /app/server +#37 CACHED + +#38 [app web-builder 3/6] COPY web/package.json web/package-lock.json* ./ +#38 CACHED + +#39 [app go-builder 6/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server +#39 CACHED + +#40 [app web-builder 5/6] COPY web/ ./ +#40 CACHED + +#41 [app web-builder 6/6] RUN npm run build +#41 CACHED + +#42 [app stage-2 7/9] COPY deploy/postgres/goose /app/deploy/postgres/goose +#42 CACHED + +#43 [app go-builder 4/7] RUN go mod download +#43 CACHED + +#44 [app web-builder 4/6] RUN npm ci +#44 CACHED + +#45 [events stage-2 9/9] COPY configs /app/configs +#45 CACHED + +#46 [events] exporting to image +#46 exporting layers done +#46 exporting manifest sha256:823be86c38b4ddeaeea085fd254c29390c69c36a0cbbd30616b2c0d9df838262 done +#46 exporting config sha256:6c371cb7268766601efc1e6947676ea4846b7ac82320f54aa96f52c3c1328860 done +#46 exporting attestation manifest sha256:b861cf5a213926c5f6258dd975ccc97fe576ae62ec8d0c80375a9613f0c7b48e done +#46 exporting manifest list sha256:cf79bda494564f70f3297a664da6602d33383e515b89e0fe0af8286af8e5086b done +#46 naming to docker.io/library/shuimu-agent-events:test done +#46 unpacking to docker.io/library/shuimu-agent-events:test done +#46 DONE 0.0s + +#47 [app] exporting to image +#47 exporting layers done +#47 exporting manifest sha256:781ce6516cf999a0d980d030dff13e882b48e9f3d013fa20d979684bf53f64e9 done +#47 exporting config sha256:531d36816f6924d866c81f3b7fb596cd84c8b9325a0267890eb2e469af832399 done +#47 exporting attestation manifest sha256:32cc3adb55860a6602c08955b64a55c00c81a3dbb7d480faaf6ff8f0b561a045 done +#47 exporting manifest list sha256:63deba11174d0148e47d9e61ce31031ff0de302ba4e398edc45d751e7ec9b0eb done +#47 naming to docker.io/library/shuimu-agent-app:test done +#47 unpacking to docker.io/library/shuimu-agent-app:test done +#47 DONE 0.0s + +#48 [app] resolving provenance for metadata file +#48 DONE 0.0s + +#49 [events] resolving provenance for metadata file +#49 DONE 0.0s + Image shuimu-pi-agent:test Built + Image shuimu-agent-app:test Built + Image shuimu-agent-events:test Built + Container shuimu-system-pg-test Running + Container shuimu-pi-agent-test Running + Container shuimu-agent-app-test Recreate + Container shuimu-agent-events-test Recreate + Container shuimu-agent-events-test Recreated + Container shuimu-agent-app-test Recreated + Container shuimu-system-pg-test Waiting + Container shuimu-system-pg-test Waiting + Container shuimu-system-pg-test Healthy + Container shuimu-agent-app-test Starting + Container shuimu-system-pg-test Healthy + Container shuimu-agent-events-test Starting + Container shuimu-agent-app-test Started + Container shuimu-agent-events-test Started +==> [test] 镜像与容器已更新 diff --git a/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/dev-health.txt b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/dev-health.txt new file mode 100644 index 00000000..10283cb2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/dev-health.txt @@ -0,0 +1 @@ +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":52,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-plan-owner-skip.txt b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-plan-owner-skip.txt new file mode 100644 index 00000000..574c2395 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-plan-owner-skip.txt @@ -0,0 +1,5 @@ +=== RUN TestTaskGenerateRealPIStopGate + plan_owner_test.go:129: redirect: run TestMultiIntentU8_RealPI_ConfirmGenerate_PG with TEST_DATABASE_URL + runner +--- SKIP: TestTaskGenerateRealPIStopGate (0.00s) +PASS +ok chat-agent-system/internal/app 0.004s diff --git a/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-taskstream.txt b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-taskstream.txt new file mode 100644 index 00000000..cb247c96 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-taskstream.txt @@ -0,0 +1,14 @@ +=== RUN TestTaskStream_TransientInjection_DeadPiAgent +2026/09/18 03:01:42 [kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment +{"time":"2026-09-18T03:01:42.246819372Z","level":"INFO","msg":"taskrun_status_transition","run_id":"run_1789700502245333756","status":"failed","workspace_id":"/tmp/TestTaskStream_TransientInjection_DeadPiAgent4118796318/001/workspaces/bizdev/intake_1789700502245392744","error":"step step_01_organize failed: pi remote runner call failed: Post \"http://127.0.0.1:1/v1/run\": dial tcp 127.0.0.1:1: connect: connection refused"} +--- PASS: TestTaskStream_TransientInjection_DeadPiAgent (0.12s) +=== RUN TestTaskStream_SoftFailure_ClassifiedResumable +{"time":"2026-09-18T03:01:42.247185161Z","level":"INFO","msg":"[kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment"} +{"time":"2026-09-18T03:01:42.367560797Z","level":"INFO","msg":"taskrun_status_transition","run_id":"run_1789700502364855997","status":"failed","workspace_id":"/tmp/TestTaskStream_SoftFailure_ClassifiedResumable3905120337/001/workspaces/bizdev/intake_1789700502364947735","error":"step step_01_organize failed: typed result rejected"} +--- PASS: TestTaskStream_SoftFailure_ClassifiedResumable (0.12s) +=== RUN TestTaskStream_MaterialsInjection +{"time":"2026-09-18T03:01:42.368028583Z","level":"INFO","msg":"[kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment"} +{"time":"2026-09-18T03:01:42.49039527Z","level":"INFO","msg":"taskrun_status_transition","run_id":"run_1789700502486605432","status":"completed","workspace_id":"/tmp/TestTaskStream_MaterialsInjection3867346817/001/workspaces/bizdev/intake_1789700502486703409","error":""} +--- PASS: TestTaskStream_MaterialsInjection (0.12s) +PASS +ok chat-agent-system/internal/server 0.370s diff --git a/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-test-reconcile-pg.txt b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-test-reconcile-pg.txt new file mode 100644 index 00000000..5a77b659 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-test-reconcile-pg.txt @@ -0,0 +1,57 @@ +=== RUN TestReconcile_LegacyRepair_PG +2026/09/18 02:59:05 OK 00001_baseline.sql (61.75ms) +2026/09/18 02:59:05 OK 00002_2026-09-09-doc-edit-lease.sql (2.84ms) +2026/09/18 02:59:05 OK 00003_2026-09-09-uq-context-memory-chunk-hash.sql (1.93ms) +2026/09/18 02:59:05 OK 00004_2026-09-10-chat-daily-digests-and-search.sql (1.04ms) +2026/09/18 02:59:05 OK 00005_2026-09-10-chat-messages-day-window.sql (975.81µs) +2026/09/18 02:59:05 OK 00006_2026-09-11-chat-read-positions.sql (581.01µs) +2026/09/18 02:59:05 OK 00007_2026-09-11-chat-turns.sql (507.12µs) +2026/09/18 02:59:05 OK 00008_2026-09-11-freeform-documents.sql (8.85ms) +2026/09/18 02:59:05 OK 00009_2026-09-11-message-export-policy.sql (3.64ms) +2026/09/18 02:59:05 OK 00010_2026-09-11-project-workflows-and-lineage.sql (15.65ms) +2026/09/18 02:59:05 OK 00011_2026-09-11-seed-ten-projects.sql (10.4ms) +2026/09/18 02:59:05 OK 00012_2026-09-11-seed-ten-projects-chat.sql (44.21ms) +2026/09/18 02:59:05 OK 00013_2026-09-11-task-run-lineage.sql (1.65ms) +2026/09/18 02:59:05 OK 00014_project_members.sql (3.94ms) +2026/09/18 02:59:05 OK 00015_2026-09-12-iam-core.sql (18.27ms) +2026/09/18 02:59:05 OK 00016_2026-09-12-workflow-definitions-and-templates.sql (12.1ms) +2026/09/18 02:59:05 OK 00017_2026-09-12-project-workflow-instances.sql (15.99ms) +2026/09/18 02:59:05 OK 00018_2026-09-12-outbox-notifications-stage-advance.sql (9.27ms) +2026/09/18 02:59:05 OK 00019_2026-09-12-task-runs-events.sql (5.46ms) +2026/09/18 02:59:05 OK 00020_2026-09-12-task-run-worker-lease.sql (1.27ms) +2026/09/18 02:59:05 OK 00021_2026-09-12-iam-hardening.sql (2.84ms) +2026/09/18 02:59:05 OK 00022_2026-09-12-workflow-schema-v2.sql (1.98ms) +2026/09/18 02:59:05 OK 00023_2026-09-12-instance-integrity.sql (1.12ms) +2026/09/18 02:59:05 OK 00024_2026-09-12-workflow-catalog-governance.sql (6.65ms) +2026/09/18 02:59:05 OK 00025_2026-09-13-sync-seed-sequences.sql (915.48µs) +2026/09/18 02:59:05 OK 00026_2026-09-13-chat-message-scope.sql (2.67ms) +2026/09/18 02:59:05 OK 00027_2026-09-13-chat-message-scope-backfill.sql (2.29ms) +2026/09/18 02:59:05 OK 00028_2026-09-13-document-revisions.sql (16.09ms) +2026/09/18 02:59:05 OK 00029_2026-09-13-freeform-client-key.sql (1.16ms) +2026/09/18 02:59:05 OK 00030_2026-09-13-task-run-pending-author-confirmation.sql (1.01ms) +2026/09/18 02:59:05 OK 00031_2026-09-13-project-member-document-duty.sql (1.76ms) +2026/09/18 02:59:05 OK 00032_2026-09-14-document-revision-migration-repair.sql (694.9µs) +2026/09/18 02:59:05 OK 00033_2026-09-14-task-run-blocked-pending-governance.sql (519.23µs) +2026/09/18 02:59:05 OK 00034_2026-09-14-workspace-lease-fencing.sql (517.29µs) +2026/09/18 02:59:05 OK 00035_2026-09-14-durable-event-delivery.sql (5.53ms) +2026/09/18 02:59:05 OK 00036_2026-09-14-final-artifact-vault.sql (8.93ms) +2026/09/18 02:59:05 OK 00037_2026-09-15-task-run-manifests.sql (2.43ms) +2026/09/18 02:59:05 OK 00038_2026-09-15-dataset-lifecycle.sql (8.22ms) +2026/09/18 02:59:05 OK 00039_2026-09-15-event-inventory-and-principals.sql (5.94ms) +2026/09/18 02:59:05 OK 00040_2026-09-15-event-dispatcher.sql (6.57ms) +2026/09/18 02:59:05 OK 00041_2026-09-15-notification-visible-outcome.sql (1.27ms) +2026/09/18 02:59:05 OK 00042_2026-09-15-workflow-advance-outcome.sql (892.49µs) +2026/09/18 02:59:05 OK 00043_2026-09-15-projection-outcome.sql (2.02ms) +2026/09/18 02:59:05 OK 00044_2026-09-15-intent-plans.sql (10.62ms) +2026/09/18 02:59:05 OK 00045_2026-09-16-intent-plan-projection.sql (3.61ms) +2026/09/18 02:59:05 OK 00046_2026-09-16-retire-demo-ten-projects.sql (997.96µs) +2026/09/18 02:59:05 OK 00047_2026-09-16-retire-demo-ten-project-sessions.sql (419.52µs) +2026/09/18 02:59:05 OK 00048_2026-09-16-software-project-bootstrap-fields.sql (11.91ms) +2026/09/18 02:59:05 OK 00049_2026-09-17-system-settings.sql (1.49ms) +2026/09/18 02:59:05 OK 00050_2026-09-17-workflow-author-only-policy.sql (2.25ms) +2026/09/18 02:59:05 OK 00051_2026-09-17-activation-token-author-only-revision.sql (335.77µs) +2026/09/18 02:59:05 OK 00052_2026-09-17-document-duty-locked.sql (657.03µs) +2026/09/18 02:59:05 goose: successfully migrated database to version: 52 +--- PASS: TestReconcile_LegacyRepair_PG (0.37s) +PASS +ok chat-agent-system/internal/docrevision 0.370s diff --git a/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-test-short.txt b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-test-short.txt new file mode 100644 index 00000000..7cc4c861 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/go-test-short.txt @@ -0,0 +1,67 @@ +? chat-agent-system/cmd/artifact-vault [no test files] +ok chat-agent-system/cmd/bizdev-cli 2.157s +? chat-agent-system/cmd/dataset [no test files] +ok chat-agent-system/cmd/document-content-reconcile 0.008s +? chat-agent-system/cmd/document-revision-backfill [no test files] +? chat-agent-system/cmd/engineering-dashboard [no test files] +? chat-agent-system/cmd/eventmode [no test files] +? chat-agent-system/cmd/events [no test files] +? chat-agent-system/cmd/recategorize [no test files] +? chat-agent-system/cmd/server [no test files] +ok chat-agent-system/internal/agent 3.599s +ok chat-agent-system/internal/app 0.031s +ok chat-agent-system/internal/app/chatflow 0.008s +ok chat-agent-system/internal/app/docadvance 0.008s +ok chat-agent-system/internal/app/docflow 0.008s +ok chat-agent-system/internal/app/docnotify 0.008s +ok chat-agent-system/internal/app/docproject 0.007s +ok chat-agent-system/internal/app/fav 0.007s +ok chat-agent-system/internal/app/intentflow 0.032s +ok chat-agent-system/internal/app/stageflow 0.009s +ok chat-agent-system/internal/app/uow 0.006s +ok chat-agent-system/internal/archtest 0.211s +ok chat-agent-system/internal/artifactvault 0.005s +ok chat-agent-system/internal/auth 0.986s +ok chat-agent-system/internal/authz 0.014s +ok chat-agent-system/internal/bizdev 0.155s +ok chat-agent-system/internal/chat 0.020s +ok chat-agent-system/internal/config 0.007s +ok chat-agent-system/internal/coordinator 0.014s +ok chat-agent-system/internal/ctxmgr 0.096s +ok chat-agent-system/internal/dataset 0.004s +ok chat-agent-system/internal/db 0.007s +ok chat-agent-system/internal/doccontent 0.003s +ok chat-agent-system/internal/docduty 0.004s +ok chat-agent-system/internal/docgov 0.031s +ok chat-agent-system/internal/docquery 0.005s +ok chat-agent-system/internal/docrevision 0.010s +ok chat-agent-system/internal/engineeringdashboard 0.011s +ok chat-agent-system/internal/events 0.007s +ok chat-agent-system/internal/freedoc 0.028s +ok chat-agent-system/internal/fsutil 0.003s +ok chat-agent-system/internal/httpx 0.005s +ok chat-agent-system/internal/intent 0.027s +ok chat-agent-system/internal/kb 0.013s +ok chat-agent-system/internal/maturity 0.003s +ok chat-agent-system/internal/notification 0.094s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext 0.006s +ok chat-agent-system/internal/projectinstance 0.006s +ok chat-agent-system/internal/projectworkflow 0.006s +ok chat-agent-system/internal/quote 0.011s +ok chat-agent-system/internal/replyexport 0.003s +ok chat-agent-system/internal/server 13.768s +ok chat-agent-system/internal/squad 0.029s +ok chat-agent-system/internal/storage 0.014s +ok chat-agent-system/internal/stream 0.003s +ok chat-agent-system/internal/stream/transport 0.002s +ok chat-agent-system/internal/swboot 0.002s +ok chat-agent-system/internal/sysadmin 0.003s +ok chat-agent-system/internal/taskrun 0.195s +ok chat-agent-system/internal/template 0.740s +ok chat-agent-system/internal/upload 0.003s +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime 0.003s +ok chat-agent-system/internal/workflowdef 0.004s +ok chat-agent-system/internal/workspace 0.003s +? chat-agent-system/templates [no test files] diff --git a/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/npm-build.txt b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/npm-build.txt new file mode 100644 index 00000000..ebed125e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/npm-build.txt @@ -0,0 +1,236 @@ + +> shuimu-workbench@1.0.0 build +> tsc && vite build && node scripts/precompress-dist.mjs + +vite v7.3.6 building client environment for production... +transforming... +✓ 4123 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.72 kB │ gzip: 0.45 kB +dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB +dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB +dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB +dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB +dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB +dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB +dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB +dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB +dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB +dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB +dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB +dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB +dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB +dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB +dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB +dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB +dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB +dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB +dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB +dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB +dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB +dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB +dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB +dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB +dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB +dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB +dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB +dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB +dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB +dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB +dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB +dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB +dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB +dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB +dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB +dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB +dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB +dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB +dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB +dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB +dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB +dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB +dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB +dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB +dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB +dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB +dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB +dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB +dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB +dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB +dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB +dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB +dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB +dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB +dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB +dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB +dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB +dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB +dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB +dist/assets/index-BYHTgAJJ.css 36.82 kB │ gzip: 10.03 kB +dist/assets/channel-BU0U2mrE.js 0.11 kB │ gzip: 0.12 kB +dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB +dist/assets/chunk-2Q5K7J3B-BrRJiyRC.js 0.19 kB │ gzip: 0.16 kB +dist/assets/chunk-JWPE2WC7-BbB8JYyB.js 0.22 kB │ gzip: 0.17 kB +dist/assets/chunk-XXDRQBXY-DeebxiEq.js 0.22 kB │ gzip: 0.20 kB +dist/assets/chunk-5VM5RSS4-DH71ojHD.js 0.36 kB │ gzip: 0.27 kB +dist/assets/stateDiagram-v2-MP3YSRHH-CTPd6I2k.js 0.39 kB │ gzip: 0.28 kB +dist/assets/classDiagram-ZZMXUADV-BRi1WSSl.js 0.43 kB │ gzip: 0.30 kB +dist/assets/classDiagram-v2-VYDZK3BY-BRi1WSSl.js 0.43 kB │ gzip: 0.30 kB +dist/assets/chunk-POPQ4Y6H-F1eUCQUt.js 0.50 kB │ gzip: 0.36 kB +dist/assets/swimlanesDiagram-VR7AAH4N-C_Ma8_WN.js 0.51 kB │ gzip: 0.35 kB +dist/assets/infoDiagram-27XIBGKW-CptwKA52.js 0.55 kB │ gzip: 0.38 kB +dist/assets/sizeCapture-INFHLROL-ttVBeEmx.js 0.90 kB │ gzip: 0.51 kB +dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB +dist/assets/railroadDiagram-O6MQD6OU-D_cuFmOe.js 1.57 kB │ gzip: 0.74 kB +dist/assets/abnfDiagram-VCTEODGH-CmO9Yw3i.js 1.77 kB │ gzip: 0.87 kB +dist/assets/chunk-F27PBJKO-CU0eGPkO.js 1.88 kB │ gzip: 0.83 kB +dist/assets/pegDiagram-XKGWAZYB-CvrplaV8.js 1.92 kB │ gzip: 0.90 kB +dist/assets/ebnfDiagram-PWID7BFC-D2Vt6aU-.js 1.97 kB │ gzip: 0.86 kB +dist/assets/arc-DmxstecQ.js 3.43 kB │ gzip: 1.48 kB +dist/assets/diagram-Z3DM3KII-DUS12Pj1.js 4.18 kB │ gzip: 1.82 kB +dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB +dist/assets/linear-CZN4dYL9.js 5.65 kB │ gzip: 2.30 kB +dist/assets/diagram-UQ7AKVKN-XeKXP9hN.js 6.04 kB │ gzip: 2.60 kB +dist/assets/pieDiagram-E7YTZNPT-nPjKT_2q.js 6.26 kB │ gzip: 2.60 kB +dist/assets/diagram-S7CK7UJ4-KQlOFv-j.js 8.18 kB │ gzip: 3.66 kB +dist/assets/cynefinDiagram-5FMLGOSQ-4jpqDyHD.js 10.06 kB │ gzip: 3.61 kB +dist/assets/stateDiagram-D77RDMKH-D8z_5wV8.js 10.33 kB │ gzip: 3.61 kB +dist/assets/dagre-GXQ25YYZ-CDTkvl4M.js 10.64 kB │ gzip: 4.01 kB +dist/assets/diagram-VSXAHHWV-C4qsb062.js 10.68 kB │ gzip: 4.11 kB +dist/assets/chunk-SVP7TREG-DvAL8o3E.js 15.73 kB │ gzip: 4.41 kB +dist/assets/diagram-VX7I27RA-qAGIKB86.js 15.92 kB │ gzip: 5.68 kB +dist/assets/ishikawaDiagram-5VMMS53U-VJOyh3e4.js 17.43 kB │ gzip: 6.62 kB +dist/assets/kanban-definition-UXKFOSKX-wobvSbOw.js 20.26 kB │ gzip: 7.18 kB +dist/assets/mindmap-definition-YA3MSWOX-C8Q9dvOK.js 23.14 kB │ gzip: 7.80 kB +dist/assets/sankeyDiagram-P5KCCOFB-DXwsYDQi.js 23.16 kB │ gzip: 8.54 kB +dist/assets/journeyDiagram-3NMN7TZE-CqGR23gX.js 23.54 kB │ gzip: 8.33 kB +dist/assets/wardleyDiagram-VM6X3IG4-DHyRRN95.js 25.47 kB │ gzip: 6.77 kB +dist/assets/gitGraphDiagram-WWUBYQGX-CT_yETct.js 29.38 kB │ gzip: 8.68 kB +dist/assets/erDiagram-RLTQ6QDP-sCxe6Q38.js 30.76 kB │ gzip: 10.57 kB +dist/assets/timeline-definition-24CTP7MA-DPJkKbyY.js 30.79 kB │ gzip: 10.13 kB +dist/assets/requirementDiagram-BXWQKSXE-CDfucG1b.js 30.97 kB │ gzip: 9.71 kB +dist/assets/quadrantDiagram-AXDQQJYC-DbMVE9Gd.js 33.98 kB │ gzip: 9.96 kB +dist/assets/layout-Bviwq0iM.js 35.43 kB │ gzip: 12.74 kB +dist/assets/chunk-IMKFNOWR-DwxAWDJr.js 38.07 kB │ gzip: 12.51 kB +dist/assets/blockDiagram-I7D4REHJ-C5UUB1D3.js 41.72 kB │ gzip: 13.56 kB +dist/assets/vennDiagram-4TSXK5OY-9GW76nJr.js 41.93 kB │ gzip: 15.67 kB +dist/assets/xychartDiagram-S5SC5T6Z-DDk6Gun7.js 44.47 kB │ gzip: 12.58 kB +dist/assets/chunk-TICWLB2K-BHQH33V8.js 49.17 kB │ gzip: 15.74 kB +dist/assets/flowDiagram-HODETNUW-Cw7thQvJ.js 61.84 kB │ gzip: 19.87 kB +dist/assets/c4Diagram-7LVT6UL2-DlzAJv6o.js 65.44 kB │ gzip: 18.77 kB +dist/assets/ganttDiagram-EL5Y4UJY-iciq8lZO.js 69.66 kB │ gzip: 23.54 kB +dist/assets/cose-bilkent-JH36ORCC-BMviVy7P.js 81.66 kB │ gzip: 22.46 kB +dist/assets/swimlanes-42K2YHIH-Dtpm931N.js 115.76 kB │ gzip: 42.49 kB +dist/assets/sequenceDiagram-WJ2MYXX4-COmJJ-iZ.js 116.93 kB │ gzip: 31.11 kB +dist/assets/architectureDiagram-5GKGNRK7-DtCJl9Cg.js 151.79 kB │ gzip: 43.13 kB +dist/assets/katex-HP8lGamR.js 261.31 kB │ gzip: 77.57 kB +dist/assets/cytoscape.esm-BB4DxJjf.js 443.04 kB │ gzip: 141.98 kB +dist/assets/cynefin-OW5HDTMX-CZKzay6A.js 687.91 kB │ gzip: 154.14 kB +dist/assets/index-BRQo931A.js 2,828.45 kB │ gzip: 805.36 kB + +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +✓ built in 7.56s +precompress: 49 candidate(s) under /home/weichen/projects/Agent-System/web/dist + br assets/abnfDiagram-VCTEODGH-CmO9Yw3i.js 1774 → 799 (−55.0%) + gzip assets/abnfDiagram-VCTEODGH-CmO9Yw3i.js 1774 → 878 (−50.5%) + br assets/arc-DmxstecQ.js 3434 → 1287 (−62.5%) + gzip assets/arc-DmxstecQ.js 3434 → 1451 (−57.7%) + br assets/architectureDiagram-5GKGNRK7-DtCJl9Cg.js 151788 → 36890 (−75.7%) + gzip assets/architectureDiagram-5GKGNRK7-DtCJl9Cg.js 151788 → 42821 (−71.8%) + br assets/blockDiagram-I7D4REHJ-C5UUB1D3.js 41722 → 11936 (−71.4%) + gzip assets/blockDiagram-I7D4REHJ-C5UUB1D3.js 41722 → 13449 (−67.8%) + br assets/c4Diagram-7LVT6UL2-DlzAJv6o.js 65438 → 15139 (−76.9%) + gzip assets/c4Diagram-7LVT6UL2-DlzAJv6o.js 65438 → 18661 (−71.5%) + br assets/chunk-F27PBJKO-CU0eGPkO.js 1876 → 732 (−61.0%) + gzip assets/chunk-F27PBJKO-CU0eGPkO.js 1876 → 826 (−56.0%) + br assets/chunk-IMKFNOWR-DwxAWDJr.js 38069 → 10969 (−71.2%) + gzip assets/chunk-IMKFNOWR-DwxAWDJr.js 38069 → 12426 (−67.4%) + br assets/chunk-SVP7TREG-DvAL8o3E.js 15728 → 3960 (−74.8%) + gzip assets/chunk-SVP7TREG-DvAL8o3E.js 15728 → 4412 (−71.9%) + br assets/chunk-TICWLB2K-BHQH33V8.js 49171 → 13029 (−73.5%) + gzip assets/chunk-TICWLB2K-BHQH33V8.js 49171 → 15642 (−68.2%) + br assets/cose-bilkent-JH36ORCC-BMviVy7P.js 81662 → 19567 (−76.0%) + gzip assets/cose-bilkent-JH36ORCC-BMviVy7P.js 81662 → 22374 (−72.6%) + br assets/cynefin-OW5HDTMX-CZKzay6A.js 687914 → 124578 (−81.9%) + gzip assets/cynefin-OW5HDTMX-CZKzay6A.js 687914 → 153070 (−77.7%) + br assets/cynefinDiagram-5FMLGOSQ-4jpqDyHD.js 10062 → 3227 (−67.9%) + gzip assets/cynefinDiagram-5FMLGOSQ-4jpqDyHD.js 10062 → 3599 (−64.2%) + br assets/cytoscape.esm-BB4DxJjf.js 443036 → 119602 (−73.0%) + gzip assets/cytoscape.esm-BB4DxJjf.js 443036 → 141741 (−68.0%) + br assets/dagre-GXQ25YYZ-CDTkvl4M.js 10641 → 3663 (−65.6%) + gzip assets/dagre-GXQ25YYZ-CDTkvl4M.js 10641 → 4026 (−62.2%) + br assets/defaultLocale-DX6XiGOO.js 4688 → 2012 (−57.1%) + gzip assets/defaultLocale-DX6XiGOO.js 4688 → 2166 (−53.8%) + br assets/diagram-S7CK7UJ4-KQlOFv-j.js 8182 → 3328 (−59.3%) + gzip assets/diagram-S7CK7UJ4-KQlOFv-j.js 8182 → 3662 (−55.2%) + br assets/diagram-UQ7AKVKN-XeKXP9hN.js 6044 → 2348 (−61.2%) + gzip assets/diagram-UQ7AKVKN-XeKXP9hN.js 6044 → 2598 (−57.0%) + br assets/diagram-VSXAHHWV-C4qsb062.js 10681 → 3766 (−64.7%) + gzip assets/diagram-VSXAHHWV-C4qsb062.js 10681 → 4135 (−61.3%) + br assets/diagram-VX7I27RA-qAGIKB86.js 15922 → 5150 (−67.7%) + gzip assets/diagram-VX7I27RA-qAGIKB86.js 15922 → 5691 (−64.3%) + br assets/diagram-Z3DM3KII-DUS12Pj1.js 4176 → 1629 (−61.0%) + gzip assets/diagram-Z3DM3KII-DUS12Pj1.js 4176 → 1804 (−56.8%) + br assets/ebnfDiagram-PWID7BFC-D2Vt6aU-.js 1970 → 785 (−60.2%) + gzip assets/ebnfDiagram-PWID7BFC-D2Vt6aU-.js 1970 → 867 (−56.0%) + br assets/erDiagram-RLTQ6QDP-sCxe6Q38.js 30755 → 9147 (−70.3%) + gzip assets/erDiagram-RLTQ6QDP-sCxe6Q38.js 30755 → 10519 (−65.8%) + br assets/flowDiagram-HODETNUW-Cw7thQvJ.js 61840 → 16365 (−73.5%) + gzip assets/flowDiagram-HODETNUW-Cw7thQvJ.js 61840 → 19712 (−68.1%) + br assets/ganttDiagram-EL5Y4UJY-iciq8lZO.js 69656 → 20697 (−70.3%) + gzip assets/ganttDiagram-EL5Y4UJY-iciq8lZO.js 69656 → 23406 (−66.4%) + br assets/gitGraphDiagram-WWUBYQGX-CT_yETct.js 29382 → 7765 (−73.6%) + gzip assets/gitGraphDiagram-WWUBYQGX-CT_yETct.js 29382 → 8676 (−70.5%) + br assets/index-BRQo931A.js 2828454 → 625162 (−77.9%) + gzip assets/index-BRQo931A.js 2828454 → 797961 (−71.8%) + br assets/index-BYHTgAJJ.css 36824 → 8557 (−76.8%) + gzip assets/index-BYHTgAJJ.css 36824 → 9952 (−73.0%) + br assets/ishikawaDiagram-5VMMS53U-VJOyh3e4.js 17434 → 5996 (−65.6%) + gzip assets/ishikawaDiagram-5VMMS53U-VJOyh3e4.js 17434 → 6611 (−62.1%) + br assets/journeyDiagram-3NMN7TZE-CqGR23gX.js 23536 → 7526 (−68.0%) + gzip assets/journeyDiagram-3NMN7TZE-CqGR23gX.js 23536 → 8315 (−64.7%) + br assets/kanban-definition-UXKFOSKX-wobvSbOw.js 20256 → 6411 (−68.4%) + gzip assets/kanban-definition-UXKFOSKX-wobvSbOw.js 20256 → 7151 (−64.7%) + br assets/katex-HP8lGamR.js 261313 → 63694 (−75.6%) + gzip assets/katex-HP8lGamR.js 261313 → 77109 (−70.5%) + br assets/layout-Bviwq0iM.js 35429 → 11550 (−67.4%) + gzip assets/layout-Bviwq0iM.js 35429 → 12741 (−64.0%) + br assets/linear-CZN4dYL9.js 5654 → 2116 (−62.6%) + gzip assets/linear-CZN4dYL9.js 5654 → 2311 (−59.1%) + br assets/mindmap-definition-YA3MSWOX-C8Q9dvOK.js 23140 → 7004 (−69.7%) + gzip assets/mindmap-definition-YA3MSWOX-C8Q9dvOK.js 23140 → 7775 (−66.4%) + br assets/ordinal-Cboi1Yqb.js 1189 → 497 (−58.2%) + gzip assets/ordinal-Cboi1Yqb.js 1189 → 574 (−51.7%) + br assets/pegDiagram-XKGWAZYB-CvrplaV8.js 1921 → 802 (−58.3%) + gzip assets/pegDiagram-XKGWAZYB-CvrplaV8.js 1921 → 909 (−52.7%) + br assets/pieDiagram-E7YTZNPT-nPjKT_2q.js 6259 → 2382 (−61.9%) + gzip assets/pieDiagram-E7YTZNPT-nPjKT_2q.js 6259 → 2610 (−58.3%) + br assets/quadrantDiagram-AXDQQJYC-DbMVE9Gd.js 33975 → 8514 (−74.9%) + gzip assets/quadrantDiagram-AXDQQJYC-DbMVE9Gd.js 33975 → 9855 (−71.0%) + br assets/railroadDiagram-O6MQD6OU-D_cuFmOe.js 1571 → 662 (−57.9%) + gzip assets/railroadDiagram-O6MQD6OU-D_cuFmOe.js 1571 → 739 (−53.0%) + br assets/requirementDiagram-BXWQKSXE-CDfucG1b.js 30965 → 8224 (−73.4%) + gzip assets/requirementDiagram-BXWQKSXE-CDfucG1b.js 30965 → 9637 (−68.9%) + br assets/sankeyDiagram-P5KCCOFB-DXwsYDQi.js 23163 → 7810 (−66.3%) + gzip assets/sankeyDiagram-P5KCCOFB-DXwsYDQi.js 23163 → 8545 (−63.1%) + br assets/sequenceDiagram-WJ2MYXX4-COmJJ-iZ.js 116927 → 25723 (−78.0%) + gzip assets/sequenceDiagram-WJ2MYXX4-COmJJ-iZ.js 116927 → 30935 (−73.5%) + br assets/stateDiagram-D77RDMKH-D8z_5wV8.js 10326 → 3309 (−68.0%) + gzip assets/stateDiagram-D77RDMKH-D8z_5wV8.js 10326 → 3604 (−65.1%) + br assets/swimlanes-42K2YHIH-Dtpm931N.js 115756 → 36607 (−68.4%) + gzip assets/swimlanes-42K2YHIH-Dtpm931N.js 115756 → 42151 (−63.6%) + br assets/timeline-definition-24CTP7MA-DPJkKbyY.js 30794 → 9110 (−70.4%) + gzip assets/timeline-definition-24CTP7MA-DPJkKbyY.js 30794 → 10123 (−67.1%) + br assets/vennDiagram-4TSXK5OY-9GW76nJr.js 41926 → 13978 (−66.7%) + gzip assets/vennDiagram-4TSXK5OY-9GW76nJr.js 41926 → 15621 (−62.7%) + br assets/wardleyDiagram-VM6X3IG4-DHyRRN95.js 25467 → 6070 (−76.2%) + gzip assets/wardleyDiagram-VM6X3IG4-DHyRRN95.js 25467 → 6736 (−73.6%) + br assets/xychartDiagram-S5SC5T6Z-DDk6Gun7.js 44474 → 11029 (−75.2%) + gzip assets/xychartDiagram-S5SC5T6Z-DDk6Gun7.js 44474 → 12519 (−71.9%) + br fonts/OFL.txt 4474 → 1437 (−67.9%) + gzip fonts/OFL.txt 4474 → 1980 (−55.7%) +precompress: wrote 49 .br and 49 .gz diff --git a/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/npm-test.txt b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/npm-test.txt new file mode 100644 index 00000000..ab72fa6b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/npm-test.txt @@ -0,0 +1,501 @@ + +> shuimu-workbench@1.0.0 test +> tsx --test "src/**/!(*InteractiveCard*|*chatWindowArtifact*|*deliverableSync*).test.ts" && vitest run src/components/chat/InteractiveCard.test.ts src/components/chat/chatWindowArtifact.test.ts src/components/chat/deliverableSync.test.ts + +✔ AuthClient: 基础登录与 .env 超级管理员鉴权 (2.034199ms) +✔ AuthClient: 自主注册受控流程(仅填用户名密码姓名,职位强制绑定访客待指派) (0.546764ms) +✔ AuthClient: 开启注册需审批时,注册账号处于待审状态并禁止登录 (0.713809ms) +✔ AuthClient: 管理员创建账号必须在首次登录修改密码 (0.362569ms) +✔ mapUserAccount: snake_case Go contract → camelCase (3.250703ms) +✔ mapUserAccount: prefers snake_case when both present (0.230133ms) +✔ mapLoginResult + mapMeResult map nested user and top-level must_change_password (0.98476ms) +✔ mapUserAccountList maps admin users list (0.439726ms) +✔ AuthClient.login + fetchMe map real snake_case wire payloads (31.163394ms) +✔ shouldAutoRetryChat: disabled until idempotency (0.994961ms) +✔ shouldAutoRetryTask: transient only, max 2 (0.219673ms) +✔ shouldAutoRetryTask: document busy never retries (0.134136ms) +✔ shouldAutoRetry alias delegates to task path (0.177825ms) +✔ autoRetryDelayMs: 1s then 2s (0.137256ms) +✔ fallbackErrorClass: network → transient, workspace → resumable (0.160915ms) +✔ upsertChatSession surfaces safe INTERNAL envelope instead of raw JSON text (28.394537ms) +✔ parseSSEChunk: parses event and data lines (1.147526ms) +✔ parseSSEChunk: keeps incomplete frame in rest (0.280022ms) +✔ parseSSEChunk: flush parses final frame without trailing delimiter (0.308231ms) +✔ parseSSEChunk: ignores heartbeat comments and keeps last id (0.195585ms) +▶ docgov lifecycle API client + ✔ claimDocument posts to /api/v1/docgov/claim with author role (26.020368ms) + ✔ submitForReview posts to /api/v1/docgov/submit-review (3.961062ms) + ✔ reviewDecision posts action and optional reason (1.02857ms) + ✔ approverDecision posts action and rollback_to (1.69846ms) + ✔ proposeModification posts to /api/v1/docgov/proposal (1.907293ms) + ✔ decideModificationProposal posts to /api/v1/docgov/proposal/decide (0.873284ms) +✔ docgov lifecycle API client (37.001941ms) +✔ updateFreeformDoc exposes CONCURRENCY_CONFLICT on 409 (31.68295ms) +✔ addProjectMember exposes structured ProjectSettingsApiError on 403 (20.304557ms) +✔ updateProjectMember surfaces safe INTERNAL message on 500 duty-sync failure (1.955283ms) +▶ projectSettingsError + ✔ keeps status, code, message and issues on PLAN_FROZEN (2.118127ms) + ✔ maps revision and concurrent conflicts to distinct recovery (0.208044ms) + ✔ joins validation issues but still keeps typed codes (0.227973ms) + ✔ prefers envelope code over legacy error field (0.194604ms) + ✔ unwraps nested business JSON inside VERSION_CONFLICT envelope (0.199834ms) + ✔ maps bare VERSION_CONFLICT envelope to retry_after_switch (0.157235ms) + ✔ retains the local draft when a late tab hits PLAN_FROZEN (0.142606ms) + ✔ prefers error_code over legacy bare error string (DOC_BUSY) (0.31175ms) +✔ projectSettingsError (5.734349ms) +✔ Project workflow types validation (1.327901ms) +✔ createTaskRun exposes DOC_BUSY detail without a retryable run (26.729047ms) +✔ parseSSEChunk tracks id seq for after_seq reconnect (3.311952ms) +✔ subscribeTaskRunEvents reconnects on EOF with advancing after_seq (2.249163ms) +✔ readTaskRunSSEOnce marks non-terminal EOF for reconnect (0.515205ms) +✔ buildTaskRequest: maps five project fields + task_key + user identity (1.184335ms) +✔ buildTaskRequest: omits optional fields when absent (0.208253ms) +✔ buildTaskRequest: includes materials when provided and non-empty (1.32871ms) +✔ buildResumeRequest: adds work_dir on top of task request (0.195254ms) +✔ buildTaskRequest: canonicalizes legacy doc-p doc_id (0.267802ms) +✔ buildTaskRequest: UI-05 pinned intake keeps task_key + doc_proj6109_intake (0.169565ms) +✔ buildResumeRequest: forwards add_steps when provided (0.148006ms) +✔ CategoryDock: 渲染顶部 🔔 未读提醒项及角标数字 (19.311767ms) +✔ CategoryDock: isAllReadCompleted 为 true 时渲染完成态提示 (2.877615ms) +✔ CategoryDock: 0 未读时不渲染红色未读角标 (2.624412ms) +✔ CategoryDock: 分类项包含依次跳转与遍历提示信息 (2.68152ms) +✔ InputArea keeps draft when send preflight fails (1.270642ms) +✔ InputArea clears draft only after accepted send (0.573713ms) +✔ InputArea shows thinking block message (0.182355ms) +✔ InputArea keeps draft when attachment registration fails (0.289551ms) +✔ isTemplateOnly 交付物应正确识别为初始模板展示态 (0.891524ms) +✔ 左侧栏鼠标严密绝对跟随与夹紧测试 (Left Rail Exact Pointer Following & Clamping) (0.869095ms) +✔ 右侧栏鼠标严密绝对跟随与反向位移测试 (Right Rail Exact Pointer Following & Inverted Delta) (0.196544ms) +✔ detects legacy mock and neuropulse/ten-projects ids (0.867854ms) +✔ detects colliding demo project ids (0.225914ms) +✔ applyRestoredFileRaw: 以 prev 为权威,只补丁缺失 fileRaw,不丢新增条目 (1.419408ms) +✔ applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用 (0.258752ms) +✔ 每人独立私聊:同一项目不同用户拥有不同 sessionId (1.089598ms) +✔ 左侧会话列表按当前用户过滤,不可见他人私聊 (0.304431ms) +✔ 消息严格归属各自私聊 session,无跨用户混聊 (2.554115ms) +✔ 切换用户时 resolve 到该用户同项目私聊会话 (0.366169ms) +✔ resolve 优先有消息的私聊,跳过空壳 sess-p{N}-u{id} (0.620421ms) +✔ ensurePrivateSessionsForUser 为缺失用户补齐空私聊 (0.6765ms) +✔ 多项目会话消息隔离测试仍成立(endo vs ultrasound) (0.399178ms) +✔ window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤儿条目 (1.007ms) +✔ window cache: 读写同一 userId 前缀,跨用户不串读 (0.229293ms) +✔ parseAnchorHash: 正确解析标准深链 hash (2.177045ms) +✔ formatAnchorHash: 正确构造深链格式 (0.206664ms) +✔ navigateToAnchor: 快路径直接命中 DOM 节点 (0.528235ms) +✔ navigateToAnchor: 慢路径拉取分片后命中 (80.910047ms) +✔ resolveTurnAnchorId: 方案 A 轮次归一化测试 (0.485246ms) +✔ 全链路场景 1: 用户自主受控注册(仅填用户名/密码/姓名,职位严格绑定访客待指派) (1.66765ms) +✔ 全链路场景 2: 系统准入策略(关闭自主注册与开启强制审批流转) (0.962862ms) +✔ 全链路场景 3: 管理员开通账号首次强制改密闭环 (1.570374ms) +✔ 全链路场景 4: 环境标识随登录会话返回(dev/test/main) (0.261912ms) +✔ cleanHeadingContent: 手工多级序号前缀清洗与专业词汇保护 (1.446307ms) +✔ 标题多层级自动编号:单 H1 文档总标题自适应模式 (1 -> 1.1 -> 1.1.1) (0.487195ms) +✔ 标题多层级自动编号:多 H1 标准大章模式 (0.234963ms) +✔ 跨章节图、表、公式多级按章重编号测试 (表 1-1, 表 2-1, 图 3-1, 式 (4-1)) (0.235353ms) +✔ 有序列表与无序列表编号与渲染功能测试 (2.285072ms) +✔ 动态块操作闭环重编号验证 (Insert, Move, Delete) (0.239673ms) +✔ findMatchingBlockForReview: 能够精准按审查关键词(电气安全/EMC/竞品)定位段落块 (1.084587ms) +✔ findMatchingBlockForReview: 能够按外部指定 hint 线索精确命中目标块 (1.023459ms) +✔ createRejectConfirmationCard: 生成合规危险级驳回门禁卡片 (0.68659ms) +✔ isAffirmativeResponse: 准确识别双轨交互中的肯定意图输入 (0.238043ms) +✔ findLastActiveCard: 正确在会话历史中提取最新的待决策卡片 (0.209104ms) +✔ decrementActiveCardsTtl: 多轮对话超时递减与自动失效归档 (0.176705ms) +✔ applyConfirmRejectToDoc & applyUndoRejectToDoc: 驳回挂载批注与60s窗口期撤销完整闭环 (0.34294ms) +✔ resolveCardInMessages & cancelCardInMessages: 消息流中卡片终态沉淀 (0.190015ms) +✔ classifyRejectUtterance: 询问原因 → QUERY_REJECT_REASON (1.526025ms) +✔ classifyRejectUtterance: 明确执行 → EXECUTE_REJECT (0.197874ms) +✔ classifyRejectUtterance: 模糊 → OTHER (0.227713ms) +✔ resolveDocGovCard: 编制人查询 → 信息卡且无 confirm_reject (1.756368ms) +✔ resolveDocGovCard: 审核人执行 + reviewing → 确认卡 (0.560554ms) +✔ resolveDocGovCard: 编制人误说执行驳回 → 无权纯文本 (0.190124ms) +✔ resolveDocGovCard: 批准人 + approving → 终审确认卡 (0.159875ms) +✔ resolveDocGovCard: 编制人 OTHER → 引导查询无危险按钮 (0.110746ms) +✔ canConfirmReject: 仅审核/批准 (0.149846ms) +✔ deriveFallbackCategory identifies categories correctly (0.881484ms) +✔ enrichMessagesWithCategories backfills categories without overwriting existing ones (0.310071ms) +✔ Markdown ➔ Block AST 解析测试 (标题自适应编号与图表公式全链路) (4.233044ms) +✔ 动态重编号引擎测试 (多 H1 规范章节重排验证) (0.479196ms) +✔ Block AST ➔ Markdown 双向无损往返还原测试 (0.720528ms) +✔ 三层交付物多用户隐私绝对隔离测试 (Multi-user Strict Privacy Isolation) (1.457446ms) +✔ 一键共享至项目参考库流转测试 (Promote to Shared Reference Test) (0.31269ms) +✔ 撤回共享恢复私有草稿测试 (Revoke Shared Reference Test) (0.605442ms) +✔ 受控工作流纯净性红线检验 (Workflow Purity Security Red Line) (0.224294ms) +✔ isSameMessage: clientTurnId match (0.973431ms) +✔ isSameMessage: serverId match without turn (0.224513ms) +✔ filterPendingLiveMessages: real temp vs server id (0.210714ms) +✔ mergeLiveOverHistory preserves server id from live (0.171645ms) +✔ applyPhaseEvent: appends first phase as running node (1.036569ms) +✔ applyPhaseEvent: completes predecessors when a new phase arrives (0.211994ms) +✔ applyPhaseEvent: same phase refreshes label in place (no duplicate node) (0.196464ms) +✔ applyPhaseEvent: unknown phase falls back to hourglass meta (0.109347ms) +✔ applyPhaseEvent: prefers backend message over short label (0.107677ms) +✔ applyPhaseEvent: step_completed closes running entry without appending (0.123596ms) +✔ applyPhaseEvent: stage_completed closes entries (0.111697ms) +✔ applyPhaseEvent: task_failed appends warning node (0.116246ms) +✔ applyPhaseEvent: decorates label with step index / total steps (0.118057ms) +✔ applyPhaseEvent: repeated step_executing with step_index gets separate nodes (0.233463ms) +✔ applyPhaseEvent: ignores event without phase payload (0.079978ms) +✔ applyPhaseEvent: step_failed marks running entry as failed (keeps step label) (0.110967ms) +✔ applyPhaseEvent: step_failed appends failed node when no running entry exists (0.096467ms) +✔ applyPhaseEvent: retry of a failed step resets it to running (0.104077ms) +✔ PLAN_CONFIRM 卡片来自 PlanView 且不触达 AST (1.132557ms) +✔ 409 冲突把卡片标为 outdated 并换上最新 revision (0.247232ms) +✔ GET reconcile 用权威 PlanView 覆盖本地 executing,不假装成功 (0.203974ms) +✔ 过期计划映射 expired (0.124597ms) +✔ 非计划卡不被误判 (0.149846ms) +✔ 集成验证:项目切换时交付物严格隔离,新建项目即刻包含 7 份模板副本 (4.72263ms) +✔ 集成验证:三层隐私与项目物理隔离双重约束 (2.0206ms) +✔ 用例 1: DEFAULT_ROLES 包含 5 种默认角色且具备预期权限 (0.904913ms) +✔ 用例 2: 系统管理员 (ADMIN) 与具备 perm:manage 用户的通配豁免特权 (0.208344ms) +✔ 用例 3: 常规工作流基线可见性 (workflow:view_all) 判定 (0.181694ms) +✔ 用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截) (0.123686ms) +✔ 用例 5: 单文档覆盖 - role_whitelist (仅白名单内角色可见,其他角色被精确拦截) (0.147296ms) +✔ 用例 6: 单文档覆盖 - public (放宽可见性,无 workflow:view_all 权限的访客也能查阅) (0.088438ms) +✔ 用例 7: 私密文档隔离强红线 (category === private 无论任何管理员或单文档覆盖,非创建者绝对不可见) (0.221623ms) +✔ 用例 8: 核心业务动作权限拦截判定 (canPerformDocAction & hasPermission) (0.149816ms) +✔ 用例 9: filterVisibleDeliverablesWithRBAC 能够批处理过滤大盘交付物 (1.01168ms) +✔ 集成场景 1: 动态新增合规审计员角色,并配置其仅能查阅特定单文档覆盖 (1.965932ms) +✔ 集成场景 2: 服务端 capabilities 变更后权限即刻生效 (0.251343ms) +✔ 集成场景 3: 单文档覆盖 designated_only 严格收紧与撤回恢复全流程 (0.256982ms) +✔ renderInlineMarkdown: renders bold, italic, strikethrough (1.01895ms) +✔ renderInlineMarkdown: escapes raw HTML tags to prevent XSS (0.429787ms) +✔ renderInlineMarkdown: blocks javascript: link hrefs (0.126306ms) +✔ renderInlineMarkdown: renders inline code and preserved line breaks (0.197234ms) +✔ renderInlineMarkdown: renders headings lists and tables for chat replies (0.376799ms) +✔ sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators (0.303171ms) +(node:193515) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. +(Use `node --trace-warnings ...` to show where the warning was created) +✔ DOCX 渲染器行内文本与公式解析测试 (parseInlineContent) (6.312783ms) +✔ DOCX 渲染器 LaTeX 原生 OMML 转换引擎测试 (latexToOmml) (2.69041ms) +✔ DOCX 渲染器 Mermaid 语法净化与容错测试 (sanitizeMermaidCode) (0.290272ms) +✔ DOCX 渲染器 SVG 净化防 Canvas 污染测试 (cleanSvgForCanvas) (0.369509ms) +✔ DOCX 渲染器全量编译测试 (buildDocxModel & exportDocumentToDocx) (48.761152ms) +✔ XLSX 渲染器多表提取与工作簿编译测试 (buildXlsxWorkbook & exportDocumentToXlsx) (5.995472ms) +▶ Reply Document IR & Export Engine + ✔ deriveReplyTitle and sanitizeFilename derive title and safe file name (1.163185ms) + ✔ deriveReplyTitle handles text without heading (0.208954ms) + ✔ parseReplyMarkdown parses headers, lists, code, and tables (1.901133ms) + ✔ renderClipboardHtml renders self-contained styling for office tools (0.497565ms) + ✔ renderClipboardHtml converts LaTeX formulas to native MathML for Word/Office (3.075419ms) + ✔ renderPlainText strips formatting cleanly (0.452687ms) + ✔ serializeMarkdown round-trips to valid markdown (0.297191ms) + ✔ stripUnsafeHtml strips script tags and dangerous event handlers (0.428898ms) +✔ Reply Document IR & Export Engine (9.209457ms) +✔ createProjectTemplateDeliverables: 必须精确生成 7 份全生命周期受控文档副本 (4.270323ms) +✔ resolveRequirementsTaskKey prefers intake for 初始需求表 (1.531514ms) +✔ resolveRequirementsTaskKey selects user_requirements for explicit URS (0.218124ms) +✔ UI-02 first-round prompt with 首轮+用户需求+摸底 must be intake not user_requirements (0.783826ms) +✔ 首轮 / 需求摸底 / 摸底 beat bare 用户需求 (0.160975ms) +✔ bare 用户需求 without V0.1 markers stays user_requirements (0.124797ms) +✔ matchTemplateDeliverable uses intake key when pool only has URS template (0.165735ms) +✔ resolveTaskTemplateForMessage keeps intake key with URS-only pool (0.200384ms) +✔ matchTemplateDeliverable prefers intake when both templates exist (0.156096ms) +✔ read-only request does not start a document task: 总结一下用户初始需求表 (1.059559ms) +✔ read-only request does not start a document task: 查看用户初始需求表 (0.212343ms) +✔ read-only request does not start a document task: 用户初始需求表现在谁在编辑 (0.790577ms) +✔ read-only request does not start a document task: 分析一下用户需求表里的风险 (0.098577ms) +✔ read-only request does not start a document task: 用户初始需求表 (0.628012ms) +✔ explicit write action still starts intake (0.152685ms) +✔ explicit update action still starts URS (0.206134ms) +✔ UI-05 prompt without active doc still keyword-routes to feasibility_reg (0.316581ms) +✔ UI-05 prompt + registered intake pins intake and doc_proj6109_intake (0.308181ms) +✔ read-only current-doc request does not start a task (0.079858ms) +✔ template-only active slot is not pinned; keywords still apply (0.123216ms) +✔ explicit named deliverable can switch away from active intake (0.301551ms) +✔ domain 注册法规 without current-doc deixis still routes feasibility_reg (0.108417ms) +✔ mismatched title vs docId suffix prefers docId taskKey (0.125726ms) +✔ deixis 当前文档 pins registered intake (0.130916ms) +✔ deixis 本文件 pins registered intake (0.065358ms) +✔ deixis 这份文档 pins registered intake (0.054238ms) +✔ deixis 本文档 pins registered intake (0.057938ms) +✔ deixis 本受控文档 pins registered intake (0.057258ms) +✔ no active doc does not invent a pin (0.115167ms) +✔ UI-11 named 系统设计输入 beats negated 需求 background (0.098547ms) +✔ mixed generate-design + do-not-cover-requirements stays prelim_design (0.076247ms) +✔ current-doc pin still wins when no other deliverable is named (0.088977ms) +▶ document revision overlay modes + ✔ pure hides all decorations (0.823366ms) + ✔ manual only marks human hunks (0.131146ms) + ✔ all marks human and agent differently (0.093927ms) + ✔ same human hunk stays amber in manual and all (no pairwise style guarantee) (0.285922ms) + ✔ exposes needs-content error code for empty orphan docs (0.118026ms) + ✔ matches overlay hunks by section or text when ids differ (0.368539ms) + ✔ aligns client block ids from head_block_map by order (0.224733ms) +✔ document revision overlay modes (3.151246ms) +▶ toolbar refresh + report entry reachability + ✔ pickLatestChangeReportId prefers highest version with report (0.304231ms) + ✔ first squad run alone still exposes a reachable report id (0.145636ms) + ✔ second squad run keeps both history report entries and latest shortcut (0.182305ms) + ✔ revisionRefreshKey is an independent refresh signal from docId (0.115317ms) +✔ toolbar refresh + report entry reachability (1.039689ms) +▶ change report view model + ✔ surfaces risk items, project impact, doc impacts with advice, and open questions (0.733489ms) + ✔ marks complete analysis as PI narrative vs factual fields (0.68603ms) + ✔ preferReportId prioritizes pending over committed latest (0.102347ms) +✔ change report view model (1.670841ms) +▶ revisionToolbarMount + ✔ exposes clear non-error template preview hint copy (1.02621ms) + ✔ template doc-p + isTemplateOnly=true does not mount (0.231324ms) + ✔ legacy doc-001 does not mount (0.230013ms) + ✔ unregistered doc-upload orphan does not mount (must register first) (0.197194ms) + ✔ server-registered upload (doc-free) and freeform mount (0.214103ms) + ✔ canonical doc_proj and generated non-template doc-p mount (0.163635ms) +✔ revisionToolbarMount (3.345851ms) +✔ useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId (1.113457ms) +✔ window fetch race: stale response ignored by generation (3.307542ms) +✔ mapRecordToMessage: 正确映射服务端消息记录为前端 ChatMessage (1.67179ms) +✔ refresh window: 空 messages 日桶不算有效窗口 (0.260122ms) +✔ mapRawBucket: 正确映射服务端按日分片桶 (0.247113ms) +✔ Category navigation: chip 选中后准确匹配目标分类最新轮次并触发归一化定位 (0.273752ms) +✔ turn runtime: session A stream survives session B switch (0.884393ms) +✔ syncAdminUserDirectories calls local reload then parent onRefreshUsers (1.381709ms) +✔ syncAdminUserDirectories fails closed when onRefreshUsers is a dead/missing prop (0.597542ms) +✔ syncAdminUserDirectories surfaces parent refresh errors (no silent swallow) (0.242903ms) +✔ afterProjectMemberPersisted syncs members then refreshes workflow docs (1.575113ms) +✔ afterProjectMemberPersisted does not refresh when member sync fails (0.67772ms) +▶ blockEditorDialogPolicy + ✔ pins an accessible dialog label and exact save name (0.634751ms) + ✔ rejects page-wide fuzzy /保存/ that would hit private-library buttons (0.253823ms) +✔ blockEditorDialogPolicy (1.781807ms) +▶ resolveSaveTargetBlock + ✔ keeps the editor and does not fake success when the block id is gone (0.875944ms) + ✔ never retargets another block that happens to share the same content (0.930772ms) +✔ resolveSaveTargetBlock (3.474337ms) +▶ editor session document binding + ✔ fail-closes when activeDocId drifts away from the opened editor doc (2.279142ms) + ✔ accepts a save only when the editor is still bound to the active doc (0.202444ms) + ✔ blocks switching documents while the editor is open (0.262452ms) +✔ editor session document binding (3.211005ms) +▶ interpretBlockSaveError + ✔ 409 keeps the draft in the editor (1.272222ms) + ✔ other PATCH failures also keep the editor (0.531114ms) +✔ interpretBlockSaveError (2.36762ms) +▶ blockedGovernanceCopy + ✔ recognizes blocked run status and fixed hint, not generation success (0.624891ms) +✔ blockedGovernanceCopy (1.398298ms) +▶ commentPolicy + ✔ lets author/reviewer/approver annotate and keeps their comment role (1.027209ms) + ✔ keeps viewer read-only: no add entry and no reviewer/approver cast (0.192985ms) +✔ commentPolicy (2.478727ms) +▶ controlledContentInvalidation + ✔ hydrate success then late list_sync keeps cache/ready and does not bump resume (1.67773ms) + ✔ post_task invalidation drops active cache and requires a new hydrate epoch (0.249542ms) + ✔ editor open / save epoch keeps draft and rejects a stale GET after PATCH (0.491675ms) + ✔ never drops the active doc without signaling rehydrate (0.134696ms) + ✔ candidate helper keeps template / doc-001 / freeform / upload out of wipe lists (0.481595ms) + ✔ dropRecordKeys is a no-op when ids are absent (0.121536ms) +✔ controlledContentInvalidation (4.467157ms) +▶ controlledDocHydrate + ✔ shouldLoadControlledContent when non-template workflow doc (1.180875ms) + ✔ shouldLoadControlledContent rejects local legacy/mock ids like doc-001 (0.167285ms) + ✔ shouldLoadControlledContent keeps canonical, freeform-off, and template preview rules (0.162735ms) + ✔ hydrateControlledDocumentContent never requests content/revision for doc-001 (0.272702ms) + ✔ hydrateControlledDocumentContent never requests for template preview or registered freeform (0.147615ms) + ✔ shouldLoadControlledContent stays inside toolbar server-backed eligibility (0.189674ms) + ✔ resolveCanonicalDocId maps doc-p56-intake to doc_proj56_intake (0.532884ms) + ✔ canonicalDocIdForTask maps doc-p56-research to doc_proj56_research (0.267122ms) + ✔ hydrate treats projection missing as ledger content, not 404 (1.774877ms) + ✔ attaches hydrate ETag as PATCH baseline and ignores a later GET (1.050309ms) + ✔ rejects hydrate when revision-state network or 5xx fails (0.66096ms) + ✔ aligns markdownToBlockAST random ids onto revision-state head_block_map (1.733939ms) + ✔ leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain) (0.877414ms) + ✔ rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT (0.299421ms) + ✔ fingerprint stays stable when only object identity changes (0.122136ms) + ✔ dedupes in-flight hydrate for the same doc fingerprint (0.246292ms) + ✔ aligned late hydrate still cannot overwrite a PATCH-success baseline (0.429968ms) +✔ controlledDocHydrate (12.4711ms) +✔ createClientTurnId uses randomUUID when available (0.849025ms) +✔ createClientTurnId falls back to getRandomValues UUID v4 (0.508615ms) +✔ createClientTurnId throws when no secure random API exists (0.418307ms) +✔ selectNonCreatorInitialMembers skips creator only (1.489196ms) +✔ buildManagedMemberUpsertPayload sends document_duty on add/update/restore (0.272792ms) +✔ buildInitialMemberUpsertPayload maps reviewer UI role to member API role (0.157546ms) +✔ persistInitialProjectMembers awaits each add and stops on failure (2.253973ms) +✔ ensureCreatedProjectWithMembers skips recreate on bootstrap id and retries members (0.778967ms) +✔ beginCreateProjectSubmit rejects concurrent second entry until end (31.154445ms) +✔ resolveCreateProjectSubmitPhase uses bootstrap to choose syncing label (0.399628ms) +✔ createProjectSubmitButtonLabel and close block while submitting (0.234703ms) +✔ shouldCloneMedicalTemplates skips the 7-pack for software domain (0.158606ms) +▶ resolveCurrentDocRole + ✔ resolves A as author by authoritative user id (0.726319ms) + ✔ does not promote reviewer B or stranger to author (0.146886ms) + ✔ resolves real USER_B id as reviewer and never name-promotes placeholder 2 (0.127356ms) + ✔ fail-closes unknown identity to viewer even if projectPermission says author (0.105207ms) + ✔ matches name only for legacy local docs without duty ids (0.103057ms) + ✔ does not name-promote when server controlled duties are zero ids (0.104037ms) +✔ resolveCurrentDocRole (2.385589ms) +▶ currentProjectRestore + ✔ rejects empty project names (1.106127ms) + ✔ rejects page-wide project name when banner and sidebar both match (0.212644ms) + ✔ scopes the visible entry to complementary exact title (0.703189ms) + ✔ scopes the current project assertion to banner exact text (0.167655ms) +✔ currentProjectRestore (3.294692ms) +▶ deliverableLeaseSync + ✔ returns the same array reference when leases are unchanged (0.67409ms) + ✔ returns a new array only when a lease actually changes (0.196794ms) +✔ deliverableLeaseSync (1.980591ms) +▶ docGovActionVisibility + ✔ hides all approval actions on unmanaged docs (0.68914ms) + ✔ gates separated duties by user id, not folded role label (0.160495ms) + ✔ author_only: same user holds all three duties and sees all actions (0.105077ms) + ✔ author_only: non-duty user sees no actions (0.077288ms) +✔ docGovActionVisibility (1.944413ms) +▶ documentBlockDomAttrs + ✔ marks a real DocumentBlock in pure mode without overlay kind (0.747818ms) + ✔ keeps the same stable attrs when overlay is later attached separately (0.205174ms) +✔ documentBlockDomAttrs (1.860125ms) +▶ durableBodySnippet + ✔ rejects empty persisted snippets (0.938043ms) + ✔ lets a later successful revision replace the old snippet (0.184145ms) + ✔ does not promote a conflict-failed draft to the expected body (0.104767ms) +✔ durableBodySnippet (2.144007ms) +▶ dutyCellAssertion + ✔ parses author, reviewer, and viewer from select value or badge role (0.891724ms) + ✔ must not infer author from a row whose option labels include 编制负责人 (0.471676ms) + ✔ accepts only a disabled non-assignable reviewer select (0.134366ms) + ✔ rejects an enabled reviewer select when the reviewer cannot assign leads (0.114066ms) + ✔ rejects canAssignLead=true even if the reviewer select is disabled (0.087937ms) + ✔ rejects viewer, author, approver, and unknown values for USER_B (0.201634ms) +✔ dutyCellAssertion (3.093108ms) +▶ decideHydrationApply + ✔ applies only when gen, doc, save epoch match and editor is closed (0.812346ms) + ✔ rejects a response for another document (0.142636ms) + ✔ rejects a superseded generation (0.115787ms) +✔ decideHydrationApply (1.825136ms) +▶ createEditorHydrationGate + ✔ does not start a new hydrate while the editor is open (UI-06 fill-window GET) (0.569033ms) + ✔ deferred hydrate that resolves after the editor opens does not replace the session (0.240113ms) + ✔ covers hydrate resolving between fill and save (0.189724ms) + ✔ asks for a hydrate resume after editor_open blocked the first apply (0.162015ms) + ✔ asks for a hydrate resume when beginHydration is skipped while editing (0.133576ms) + ✔ requestHydrationResume is consumed once after a protected edit session (0.127416ms) + ✔ late hydrate after a successful PATCH does not roll back the new baseline (0.154706ms) +✔ createEditorHydrationGate (2.985541ms) +✔ mergeSessionUser prefers session identity over stale catalog entry (1.206564ms) +▶ playwrightPhaseArtifacts + ✔ defaults unknown or empty UI_PHASE to main (0.596423ms) + ✔ keeps post-restart distinct from main so the second run cannot overwrite traces (0.167465ms) + ✔ keeps main results at the historical filename and isolates post-restart recordPass (0.092747ms) + ✔ shares phase-state and secrets across phases (0.063188ms) +✔ playwrightPhaseArtifacts (1.990091ms) +✔ resolvePostTaskActiveDocId prefers server doc_proj for taskKey (1.238863ms) +✔ resolvePostTaskActiveDocId honors preferred server doc id (0.299231ms) +✔ shouldSwitchTemplateSlotToServer when active is local template slot (0.225523ms) +✔ pollUntilPostTaskServerDoc retries until same taskKey server doc appears (0.65511ms) +✔ pollUntilPostTaskServerDoc returns null after maxAttempts without relying on setState (0.384049ms) +✔ mapServerProjectToProjectInfo maps creator and preserves local members (0.687079ms) +✔ mergeProjectLists drops local-only demo seed projects by default (0.292231ms) +✔ mergeProjectLists can retain local-only demo when explicitly opted in (0.113797ms) +✔ documentDutyFromMemberRole maps UI reviewer and creator (0.762377ms) +✔ exclusiveMemberRoleChange keeps a single reviewer (0.228733ms) +✔ memberRoleFromDocumentDuty does not invent reviewer from access role (0.139236ms) +✔ toApiProjectRole maps doc roles to member and creator to owner (0.734378ms) +✔ fromApiProjectRole maps owner to creator for project creator (0.169205ms) +✔ mapApiMemberToProjectMember builds UI record (0.286971ms) +▶ projectSettingsDuties + ✔ toggles full to none by atomically clearing duties (1.104327ms) + ✔ blocks partial saves and locates required/governance issue codes (0.35257ms) + ✔ allows an all-none configurable draft without three members (0.145796ms) + ✔ requires three members once any document is managed (0.824536ms) + ✔ resolves author_only to A/A/A without three-member requirement (0.299091ms) + ✔ rejects author_only without author or with conflicting reviewers (0.171585ms) + ✔ mixed full + author_only still requires three members for the full doc (0.113477ms) + ✔ toggleAuthorOnly clears review/approve slots and restores on disable (0.144466ms) +✔ projectSettingsDuties (4.213264ms) +▶ projectSettingsMode + ✔ enters frozen when can_edit_plan is false or the project is active (0.66885ms) + ✔ stays planning only for an editable draft (0.144075ms) + ✔ lists enabled duties for the frozen snapshot and skips disabled tasks (0.742178ms) + ✔ keeps the local draft after PLAN_FROZEN refresh and switches to frozen (0.156575ms) +✔ projectSettingsMode (2.731539ms) +▶ revisionEtag + ✔ uses stale hydrate ETag for PATCH and never the latest GET (0.527664ms) + ✔ does not invent 0:0 when controlled hydrate misses etag or errors (0.140626ms) + ✔ refuses controlled save without a hydrate ETag (0.393338ms) + ✔ allows freeform bootstrap 0:0 only (0.098847ms) +✔ revisionEtag (1.979661ms) +▶ saveControlledBlockEdit + ✔ successful save calls PATCH once with bound docId, hydrate ETag, body and client_block_map (1.746718ms) + ✔ does not PATCH when editorDocId and activeDocId diverge (0.158485ms) + ✔ does not PATCH on stale block id and keeps the draft (0.099717ms) + ✔ 409 does not close the editor or drop the draft (0.567033ms) + ✔ network errors do not close the editor or drop the draft (0.216564ms) +✔ saveControlledBlockEdit (3.70734ms) +✔ parseProjectIdFromSessionId decodes sess-p{id}-u{user} (0.894913ms) +✔ resolveSessionProjectId prefers explicit id then session id encoding (0.197115ms) +✔ mapServerSessionToProjectSession fills project metadata (0.595552ms) +✔ mergeSessionLists keeps local stage while applying server counts (0.192965ms) +▶ squadCompletionObservation + ✔ accepts a directly completed adjacent assistant without requiring inflight (0.812386ms) + ✔ accepts a turn that passed through inflight then completed (0.172555ms) + ✔ rejects error terminals, missing correspondence, and visible errors (0.152685ms) + ✔ keeps waiting while still inflight and does not treat inflight as success (0.131586ms) +✔ squadCompletionObservation (2.297622ms) +✔ resolveReconnectRunId prefers explicit opts and ignores attempt without opts (0.618252ms) +✔ shouldSubscribeExistingRun only for in-flight statuses (0.142306ms) +✔ isRemoteTaskTerminal covers confirmation and quality_failed (0.084658ms) +✔ nextAttemptOpts carries retained runId for reconnect without new POST (0.079878ms) +▶ uploadFreeformRegister + ✔ buildUploadClientKey prefers explicit hint then doc-upload stamp (1.475666ms) + ✔ registerUploadAttachments hard-fails without creating orphan on API error (11.151259ms) + ✔ registerUploadAttachments rejects local doc-upload id from server (0.308221ms) + ✔ registerUploadAttachments returns server doc-free ids and freeform facts (0.480816ms) + ✔ deliverableFromFreeformUpload maps server id and upload facts (0.152796ms) +✔ uploadFreeformRegister (14.732962ms) +▶ workflowCategoryNavigation + ✔ private menu cannot see doc_proj* so it must switch to workflow before exact select (1.255773ms) + ✔ fail-closes when the workflow menu is showing but the exact doc is missing (0.187644ms) + ✔ label and taskKey opens always require the workflow category (0.077478ms) +✔ workflowCategoryNavigation (2.263063ms) +▶ no external Google Fonts on login shell + ✔ index.html has no fonts.googleapis / fonts.gstatic (0.563684ms) + ✔ styles reference self-hosted fonts.css (0.206844ms) +✔ no external Google Fonts on login shell (1.408128ms) +▶ 用户上传文档多格式在线预览体系与渲染分流测试 + ✔ 1. 上传附件时正确注入 sourceType: "uploaded_file" 与完整 fileMeta 元数据 (0.654511ms) + ✔ 2. 扩展名分类规则准确分发至对应的高保真渲染器类型 (0.174895ms) + ✔ 3. 受控工作流交付物严格隔离:绝不命中 FilePreviewPanel,保持 BlockRenderer 纯净 (0.108977ms) + ✔ 4. 上传文档支持受控流转:从个人私密一键共享至项目参考库后,在线预览依然可用且权限标态正确 (0.094037ms) +✔ 用户上传文档多格式在线预览体系与渲染分流测试 (1.841435ms) +▶ 项目立项、成员职责分配与退出留痕治理测试套件 + ✔ 1. 默认项目库包含完整元数据与法定三权成员 (active 状态) (0.450287ms) + ✔ 2. 跨用户会话可见性严格隔离:未加入成员完全不可见 (0.187594ms) + ✔ 3. 创建者添加新成员并分配职责后,新成员即刻可见该项目会话 (0.109617ms) + ✔ 4. 中途加入后期退出 (exited):保留项目会话但标记已退出只读留痕 (0.105847ms) + ✔ 5. 超级管理员 (ADMIN) 享有全量通配可见与管理兜底特权 (0.081007ms) +✔ 项目立项、成员职责分配与退出留痕治理测试套件 (1.773327ms) +▶ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) + ✔ 1. 必须精确生成 7 份官方全流程受控工作流模板且标记纯净 (0.563873ms) + ✔ 2. 杜绝旧项目 CUSA-NeuroPulse 串号泄露,动态注入新项目信息 (0.566783ms) + ✔ 3. 严格对标参考系统模板规范要素,绝非 3 行残缺摘要 (0.33153ms) + ✔ 4. 缓存丢失场景下即时自愈重建 AST 能力验证 (0.218554ms) +✔ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) (4.72805ms) +▶ 用户上传文档私密归档与共享流转测试 + ✔ 10 个评审项目均包含已共享的 URS 交付物与 AST,支持跨角色合规查阅且会话均以 assistant 闭环 (1.193724ms) + ✔ 用户上传附件自动生成私密交付物并在 AST 中建立隔离,支持流转至项目参考库 (1.68991ms) + ✔ 单轮对话支持一次上传多个文档(上限5个),批量生成私密交付物并遵循数量约束 (0.566183ms) +✔ 用户上传文档私密归档与共享流转测试 (4.193945ms) +▶ 工作流切换与消息展示时序测试套件 + ✔ 1. 当存在历史 seed 消息且追加新实时消息时,新消息必须严格沉淀在末尾 (0.618261ms) + ✔ 2. 切回项目时相同正文不应因 temp/server id 差异重复展示 (0.127157ms) +✔ 工作流切换与消息展示时序测试套件 (1.34515ms) +✔ DeliverableItem 必须包含 projectId 与 isTemplateOnly 属性 (0.489505ms) +ℹ tests 402 +ℹ suites 36 +ℹ pass 402 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 925.732052 + + RUN v5.0.0 /home/weichen/projects/Agent-System/web + +(node:193983) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. +(Use `node --trace-warnings ...` to show where the warning was created) + + Test Files 3 passed (3) + Tests 32 passed (32) + Start at 03:01:36 + Duration 554ms (import 50%, transform 44%, tests 5%, worker 1%) + diff --git a/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/playwright-list.txt b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/playwright-list.txt new file mode 100644 index 00000000..30fc6f44 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave3-test-debt-blindspots/playwright-list.txt @@ -0,0 +1,33 @@ +Listing tests: + [chromium] › admin-console.spec.ts:22:1 › admin console smoke: enter, nav renders, exit back to workbench + [chromium] › controlled-document-reload.spec.ts:200:3 › controlled document reload M1 live › project 143 content is 200 with consistency; anonymous is 401; unknown is 404 + [chromium] › controlled-document-reload.spec.ts:266:3 › controlled document reload M1 live › non-member GET of project 143 is the same 404 as unknown + [chromium] › controlled-document-reload.spec.ts:310:3 › controlled document reload M1 live › new project PI compile then reload keeps ledger markdown + [chromium] › controlled-document-reload.spec.ts:443:3 › controlled document reload M1 live › cross-project and wrong-stage GET stay DOCUMENT_NOT_FOUND + [chromium] › conversation-intent-dual-tab.spec.ts:36:3 › conversation intent dual-tab busy › tab B shows busy without reload and keeps empty actions + [chromium] › document-revision-runtime.spec.ts:629:3 › document-revision-runtime UI @post-restart › UI-15 restore after container restart (page login only) + [chromium] › document-revision-runtime.spec.ts:690:3 › document-revision-runtime UI acceptance › UI-00 bootstrap: admin creates A/B/C via page + [chromium] › document-revision-runtime.spec.ts:750:3 › document-revision-runtime UI acceptance › UI-01 login A → page create project → template hint, no revision-state for doc-p + [chromium] › document-revision-runtime.spec.ts:809:3 › document-revision-runtime UI acceptance › UI-02 send real squad task + [chromium] › document-revision-runtime.spec.ts:856:3 › document-revision-runtime UI acceptance › UI-03 wait first round; open controlled doc with V/r + [chromium] › document-revision-runtime.spec.ts:893:3 › document-revision-runtime UI acceptance › UI-04 open change report with full fields + [chromium] › document-revision-runtime.spec.ts:922:3 › document-revision-runtime UI acceptance › UI-05 high-risk candidate #1 pending (version must not advance) + [chromium] › document-revision-runtime.spec.ts:947:3 › document-revision-runtime UI acceptance › UI-05a confirm high-risk candidate → version advances + [chromium] › document-revision-runtime.spec.ts:992:3 › document-revision-runtime UI acceptance › UI-05b second high-risk candidate → reject; version stays + [chromium] › document-revision-runtime.spec.ts:1041:3 › document-revision-runtime UI acceptance › UI-06 edit block → save → hard refresh persists r bump + [chromium] › document-revision-runtime.spec.ts:1072:3 › document-revision-runtime UI acceptance › UI-07/08/09 overlay modes differ in computed styles + [chromium] › document-revision-runtime.spec.ts:1113:3 › document-revision-runtime UI acceptance › UI-10 version history + compare + [chromium] › document-revision-runtime.spec.ts:1149:3 › document-revision-runtime UI acceptance › UI-11 chat second controlled doc + page upload attachment + [chromium] › document-revision-runtime.spec.ts:1246:3 › document-revision-runtime UI acceptance › UI-12 user C (not a member) must not see project + [chromium] › document-revision-runtime.spec.ts:1271:3 › document-revision-runtime UI acceptance › UI-13a reviewer B has no body-edit affordance + [chromium] › document-revision-runtime.spec.ts:1315:3 › document-revision-runtime UI acceptance › UI-13b two author-A contexts conflict on same revision + [chromium] › document-revision-runtime.spec.ts:1429:3 › document-revision-runtime UI acceptance › UI-14 logout via UI then login again + [chromium] › document-revision-runtime.spec.ts:1457:3 › document-revision-runtime UI acceptance › UI-15 phase-main: write evidence then exit (host restarts docker before post-restart run) + [chromium] › document-revision-runtime.spec.ts:1489:3 › document-revision-runtime UI acceptance › UI-16 registered upload uses server doc-free ledger (no doc-upload orphan) + [chromium] › governance-action-level-authz.spec.ts:57:3 › governance action-level authz › real page matrix G5 + [chromium] › intent-plan-cards.spec.ts:292:3 › intent-plan PLAN_CONFIRM dual-tab › dual-tab confirm mutual exclusion + reload/relogin matches PG + [chromium] › project143-regression.spec.ts:272:3 › Project 143 Regression UI Acceptance › full sequential verification of Project 143 (A1-A7) + [chromium] › workflow-plan-409-frozen-duties.spec.ts:93:3 › workflow-plan 409 frozen + optional duties › real page matrix +Total: 29 tests in 8 files + +Error: EACCES: permission denied, open '/home/weichen/projects/Agent-System/.doc/test-reports/assets/2026-09-14-controlled-content-ui-acceptance/playwright-report-main/index.html' diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-build.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-build.txt new file mode 100644 index 00000000..cb40029a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-build.txt @@ -0,0 +1 @@ +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-race.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-race.txt new file mode 100644 index 00000000..bfa8e4b7 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-race.txt @@ -0,0 +1,3 @@ +ok chat-agent-system/internal/events 1.020s +ok chat-agent-system/internal/ctxmgr 1.137s +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-test-short.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-test-short.txt new file mode 100644 index 00000000..eb566e3d --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-test-short.txt @@ -0,0 +1,68 @@ +? chat-agent-system/cmd/artifact-vault [no test files] +ok chat-agent-system/cmd/bizdev-cli 2.927s +? chat-agent-system/cmd/dataset [no test files] +ok chat-agent-system/cmd/document-content-reconcile 0.007s +? chat-agent-system/cmd/document-revision-backfill [no test files] +? chat-agent-system/cmd/engineering-dashboard [no test files] +? chat-agent-system/cmd/eventmode [no test files] +? chat-agent-system/cmd/events [no test files] +? chat-agent-system/cmd/recategorize [no test files] +? chat-agent-system/cmd/server [no test files] +ok chat-agent-system/internal/agent 3.645s +ok chat-agent-system/internal/app 0.018s +ok chat-agent-system/internal/app/chatflow 0.006s +ok chat-agent-system/internal/app/docadvance 0.003s +ok chat-agent-system/internal/app/docflow 0.005s +ok chat-agent-system/internal/app/docnotify 0.003s +ok chat-agent-system/internal/app/docproject 0.003s +ok chat-agent-system/internal/app/fav 0.004s +ok chat-agent-system/internal/app/intentflow 0.013s +ok chat-agent-system/internal/app/stageflow 0.006s +ok chat-agent-system/internal/app/uow 0.006s +ok chat-agent-system/internal/archtest 0.177s +ok chat-agent-system/internal/artifactvault 0.006s +ok chat-agent-system/internal/auth 0.988s +ok chat-agent-system/internal/authz 0.003s +ok chat-agent-system/internal/bizdev 0.149s +ok chat-agent-system/internal/chat 0.015s +ok chat-agent-system/internal/config 0.005s +ok chat-agent-system/internal/coordinator 0.011s +ok chat-agent-system/internal/ctxmgr 0.085s +ok chat-agent-system/internal/dataset 0.005s +ok chat-agent-system/internal/db 0.004s +ok chat-agent-system/internal/doccontent 0.002s +ok chat-agent-system/internal/docduty 0.004s +ok chat-agent-system/internal/docgov 0.026s +ok chat-agent-system/internal/docquery 0.004s +ok chat-agent-system/internal/docrevision 0.008s +ok chat-agent-system/internal/engineeringdashboard 0.009s +ok chat-agent-system/internal/events 0.007s +ok chat-agent-system/internal/freedoc 0.026s +ok chat-agent-system/internal/fsutil 0.003s +ok chat-agent-system/internal/httpx 0.003s +ok chat-agent-system/internal/intent 0.023s +ok chat-agent-system/internal/kb 0.009s +ok chat-agent-system/internal/maturity 0.002s +ok chat-agent-system/internal/notification 0.074s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext 0.005s +ok chat-agent-system/internal/projectinstance 0.005s +ok chat-agent-system/internal/projectworkflow 0.003s +ok chat-agent-system/internal/quote 0.009s +ok chat-agent-system/internal/replyexport 0.004s +ok chat-agent-system/internal/server 14.127s +ok chat-agent-system/internal/squad 0.029s +ok chat-agent-system/internal/storage 0.012s +ok chat-agent-system/internal/stream 0.003s +ok chat-agent-system/internal/stream/transport 0.002s +ok chat-agent-system/internal/swboot 0.002s +ok chat-agent-system/internal/sysadmin 0.003s +ok chat-agent-system/internal/taskrun 0.171s +ok chat-agent-system/internal/template 0.934s +ok chat-agent-system/internal/upload 0.002s +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime 0.003s +ok chat-agent-system/internal/workflowdef 0.004s +ok chat-agent-system/internal/workspace 0.003s +? chat-agent-system/templates [no test files] +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-vet.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-vet.txt new file mode 100644 index 00000000..cb40029a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-vet.txt @@ -0,0 +1 @@ +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/hydrate-corrupt-pg.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/hydrate-corrupt-pg.txt new file mode 100644 index 00000000..e9ad30c2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/hydrate-corrupt-pg.txt @@ -0,0 +1,57 @@ +=== RUN TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG +2026/09/18 03:46:15 OK 00001_baseline.sql (51.97ms) +2026/09/18 03:46:15 OK 00002_2026-09-09-doc-edit-lease.sql (1.45ms) +2026/09/18 03:46:15 OK 00003_2026-09-09-uq-context-memory-chunk-hash.sql (1.77ms) +2026/09/18 03:46:15 OK 00004_2026-09-10-chat-daily-digests-and-search.sql (514.9µs) +2026/09/18 03:46:15 OK 00005_2026-09-10-chat-messages-day-window.sql (742.04µs) +2026/09/18 03:46:15 OK 00006_2026-09-11-chat-read-positions.sql (343.55µs) +2026/09/18 03:46:15 OK 00007_2026-09-11-chat-turns.sql (352.5µs) +2026/09/18 03:46:15 OK 00008_2026-09-11-freeform-documents.sql (6.79ms) +2026/09/18 03:46:15 OK 00009_2026-09-11-message-export-policy.sql (2.83ms) +2026/09/18 03:46:15 OK 00010_2026-09-11-project-workflows-and-lineage.sql (12.01ms) +2026/09/18 03:46:15 OK 00011_2026-09-11-seed-ten-projects.sql (5.09ms) +2026/09/18 03:46:15 OK 00012_2026-09-11-seed-ten-projects-chat.sql (31.36ms) +2026/09/18 03:46:15 OK 00013_2026-09-11-task-run-lineage.sql (1.79ms) +2026/09/18 03:46:15 OK 00014_project_members.sql (4.18ms) +2026/09/18 03:46:15 OK 00015_2026-09-12-iam-core.sql (20.02ms) +2026/09/18 03:46:15 OK 00016_2026-09-12-workflow-definitions-and-templates.sql (14.73ms) +2026/09/18 03:46:15 OK 00017_2026-09-12-project-workflow-instances.sql (14.29ms) +2026/09/18 03:46:15 OK 00018_2026-09-12-outbox-notifications-stage-advance.sql (7.94ms) +2026/09/18 03:46:15 OK 00019_2026-09-12-task-runs-events.sql (5.24ms) +2026/09/18 03:46:15 OK 00020_2026-09-12-task-run-worker-lease.sql (1.08ms) +2026/09/18 03:46:15 OK 00021_2026-09-12-iam-hardening.sql (2.36ms) +2026/09/18 03:46:15 OK 00022_2026-09-12-workflow-schema-v2.sql (1.7ms) +2026/09/18 03:46:15 OK 00023_2026-09-12-instance-integrity.sql (927.36µs) +2026/09/18 03:46:15 OK 00024_2026-09-12-workflow-catalog-governance.sql (5.79ms) +2026/09/18 03:46:15 OK 00025_2026-09-13-sync-seed-sequences.sql (829.13µs) +2026/09/18 03:46:15 OK 00026_2026-09-13-chat-message-scope.sql (2.31ms) +2026/09/18 03:46:15 OK 00027_2026-09-13-chat-message-scope-backfill.sql (1.92ms) +2026/09/18 03:46:15 OK 00028_2026-09-13-document-revisions.sql (14.56ms) +2026/09/18 03:46:15 OK 00029_2026-09-13-freeform-client-key.sql (1.25ms) +2026/09/18 03:46:15 OK 00030_2026-09-13-task-run-pending-author-confirmation.sql (1.17ms) +2026/09/18 03:46:15 OK 00031_2026-09-13-project-member-document-duty.sql (2.06ms) +2026/09/18 03:46:15 OK 00032_2026-09-14-document-revision-migration-repair.sql (654.16µs) +2026/09/18 03:46:15 OK 00033_2026-09-14-task-run-blocked-pending-governance.sql (437.41µs) +2026/09/18 03:46:15 OK 00034_2026-09-14-workspace-lease-fencing.sql (482.65µs) +2026/09/18 03:46:15 OK 00035_2026-09-14-durable-event-delivery.sql (5.25ms) +2026/09/18 03:46:15 OK 00036_2026-09-14-final-artifact-vault.sql (9.3ms) +2026/09/18 03:46:15 OK 00037_2026-09-15-task-run-manifests.sql (2.77ms) +2026/09/18 03:46:15 OK 00038_2026-09-15-dataset-lifecycle.sql (7.76ms) +2026/09/18 03:46:15 OK 00039_2026-09-15-event-inventory-and-principals.sql (5.63ms) +2026/09/18 03:46:15 OK 00040_2026-09-15-event-dispatcher.sql (6.92ms) +2026/09/18 03:46:15 OK 00041_2026-09-15-notification-visible-outcome.sql (903.88µs) +2026/09/18 03:46:15 OK 00042_2026-09-15-workflow-advance-outcome.sql (879.69µs) +2026/09/18 03:46:15 OK 00043_2026-09-15-projection-outcome.sql (2.04ms) +2026/09/18 03:46:15 OK 00044_2026-09-15-intent-plans.sql (9.2ms) +2026/09/18 03:46:15 OK 00045_2026-09-16-intent-plan-projection.sql (3.43ms) +2026/09/18 03:46:15 OK 00046_2026-09-16-retire-demo-ten-projects.sql (1.13ms) +2026/09/18 03:46:15 OK 00047_2026-09-16-retire-demo-ten-project-sessions.sql (404.49µs) +2026/09/18 03:46:15 OK 00048_2026-09-16-software-project-bootstrap-fields.sql (12.43ms) +2026/09/18 03:46:15 OK 00049_2026-09-17-system-settings.sql (1.54ms) +2026/09/18 03:46:15 OK 00050_2026-09-17-workflow-author-only-policy.sql (2.64ms) +2026/09/18 03:46:15 OK 00051_2026-09-17-activation-token-author-only-revision.sql (488.28µs) +2026/09/18 03:46:15 OK 00052_2026-09-17-document-duty-locked.sql (974.73µs) +2026/09/18 03:46:15 goose: successfully migrated database to version: 52 +--- PASS: TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG (0.32s) +PASS +ok chat-agent-system/internal/docrevision 0.321s diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-build.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-build.txt new file mode 100644 index 00000000..76a97fa0 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-build.txt @@ -0,0 +1,237 @@ + +> shuimu-workbench@1.0.0 build +> tsc && vite build && node scripts/precompress-dist.mjs + +vite v7.3.6 building client environment for production... +transforming... +✓ 4123 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.72 kB │ gzip: 0.45 kB +dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB +dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB +dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB +dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB +dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB +dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB +dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB +dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB +dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB +dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB +dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB +dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB +dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB +dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB +dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB +dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB +dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB +dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB +dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB +dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB +dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB +dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB +dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB +dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB +dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB +dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB +dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB +dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB +dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB +dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB +dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB +dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB +dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB +dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB +dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB +dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB +dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB +dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB +dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB +dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB +dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB +dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB +dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB +dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB +dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB +dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB +dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB +dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB +dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB +dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB +dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB +dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB +dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB +dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB +dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB +dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB +dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB +dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB +dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB +dist/assets/index-BYHTgAJJ.css 36.82 kB │ gzip: 10.03 kB +dist/assets/channel-BWaIGDQK.js 0.11 kB │ gzip: 0.12 kB +dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB +dist/assets/chunk-2Q5K7J3B-Bh00PV7B.js 0.19 kB │ gzip: 0.16 kB +dist/assets/chunk-JWPE2WC7-zllJnyes.js 0.22 kB │ gzip: 0.17 kB +dist/assets/chunk-XXDRQBXY-D7xLWH32.js 0.22 kB │ gzip: 0.20 kB +dist/assets/chunk-5VM5RSS4-DwRnlXdD.js 0.36 kB │ gzip: 0.27 kB +dist/assets/stateDiagram-v2-MP3YSRHH-CMni1PkL.js 0.39 kB │ gzip: 0.29 kB +dist/assets/classDiagram-ZZMXUADV-D-sblmQI.js 0.43 kB │ gzip: 0.30 kB +dist/assets/classDiagram-v2-VYDZK3BY-D-sblmQI.js 0.43 kB │ gzip: 0.30 kB +dist/assets/chunk-POPQ4Y6H-CV-hKTcv.js 0.50 kB │ gzip: 0.35 kB +dist/assets/swimlanesDiagram-VR7AAH4N-CSMO0mBI.js 0.51 kB │ gzip: 0.35 kB +dist/assets/infoDiagram-27XIBGKW-fa-zM8z6.js 0.55 kB │ gzip: 0.38 kB +dist/assets/sizeCapture-INFHLROL-DDBMTQik.js 0.90 kB │ gzip: 0.51 kB +dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB +dist/assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1.57 kB │ gzip: 0.73 kB +dist/assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1.77 kB │ gzip: 0.87 kB +dist/assets/chunk-F27PBJKO-usLA3fwD.js 1.88 kB │ gzip: 0.83 kB +dist/assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1.92 kB │ gzip: 0.90 kB +dist/assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1.97 kB │ gzip: 0.86 kB +dist/assets/arc-DzBaewGB.js 3.43 kB │ gzip: 1.48 kB +dist/assets/diagram-Z3DM3KII-DaRdRaxA.js 4.18 kB │ gzip: 1.81 kB +dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB +dist/assets/linear--qpe2Iaf.js 5.65 kB │ gzip: 2.30 kB +dist/assets/diagram-UQ7AKVKN-RZXoXHTp.js 6.04 kB │ gzip: 2.60 kB +dist/assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6.26 kB │ gzip: 2.60 kB +dist/assets/diagram-S7CK7UJ4-yML3LoUw.js 8.18 kB │ gzip: 3.65 kB +dist/assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10.06 kB │ gzip: 3.61 kB +dist/assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10.33 kB │ gzip: 3.61 kB +dist/assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10.64 kB │ gzip: 4.01 kB +dist/assets/diagram-VSXAHHWV-D-Jngocg.js 10.68 kB │ gzip: 4.11 kB +dist/assets/chunk-SVP7TREG-Xn5LeAbu.js 15.73 kB │ gzip: 4.41 kB +dist/assets/diagram-VX7I27RA-CqVb28LM.js 15.92 kB │ gzip: 5.68 kB +dist/assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17.43 kB │ gzip: 6.62 kB +dist/assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20.26 kB │ gzip: 7.18 kB +dist/assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23.14 kB │ gzip: 7.80 kB +dist/assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23.16 kB │ gzip: 8.54 kB +dist/assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23.54 kB │ gzip: 8.33 kB +dist/assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25.47 kB │ gzip: 6.77 kB +dist/assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29.38 kB │ gzip: 8.68 kB +dist/assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30.76 kB │ gzip: 10.57 kB +dist/assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30.79 kB │ gzip: 10.13 kB +dist/assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30.97 kB │ gzip: 9.71 kB +dist/assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33.98 kB │ gzip: 9.95 kB +dist/assets/layout-C2imKr5O.js 35.43 kB │ gzip: 12.74 kB +dist/assets/chunk-IMKFNOWR-C-Mq94A_.js 38.07 kB │ gzip: 12.51 kB +dist/assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41.72 kB │ gzip: 13.56 kB +dist/assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41.93 kB │ gzip: 15.67 kB +dist/assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44.47 kB │ gzip: 12.58 kB +dist/assets/chunk-TICWLB2K-yUyCyRlD.js 49.17 kB │ gzip: 15.74 kB +dist/assets/flowDiagram-HODETNUW-DwTSI7cN.js 61.84 kB │ gzip: 19.87 kB +dist/assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65.44 kB │ gzip: 18.77 kB +dist/assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69.66 kB │ gzip: 23.54 kB +dist/assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81.66 kB │ gzip: 22.46 kB +dist/assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115.76 kB │ gzip: 42.49 kB +dist/assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116.93 kB │ gzip: 31.11 kB +dist/assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151.79 kB │ gzip: 43.12 kB +dist/assets/katex-HP8lGamR.js 261.31 kB │ gzip: 77.57 kB +dist/assets/cytoscape.esm-BB4DxJjf.js 443.04 kB │ gzip: 141.98 kB +dist/assets/cynefin-OW5HDTMX-Dit8THDr.js 687.91 kB │ gzip: 154.14 kB +dist/assets/index-DhyNmxJE.js 2,829.14 kB │ gzip: 805.59 kB + +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +✓ built in 7.59s +precompress: 49 candidate(s) under /home/weichen/projects/Agent-System/web/dist + br assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 798 (−55.0%) + gzip assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 872 (−50.8%) + br assets/arc-DzBaewGB.js 3434 → 1285 (−62.6%) + gzip assets/arc-DzBaewGB.js 3434 → 1449 (−57.8%) + br assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151788 → 36902 (−75.7%) + gzip assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151788 → 42819 (−71.8%) + br assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41722 → 11938 (−71.4%) + gzip assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41722 → 13449 (−67.8%) + br assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65438 → 15174 (−76.8%) + gzip assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65438 → 18662 (−71.5%) + br assets/chunk-F27PBJKO-usLA3fwD.js 1876 → 730 (−61.1%) + gzip assets/chunk-F27PBJKO-usLA3fwD.js 1876 → 828 (−55.9%) + br assets/chunk-IMKFNOWR-C-Mq94A_.js 38069 → 10963 (−71.2%) + gzip assets/chunk-IMKFNOWR-C-Mq94A_.js 38069 → 12427 (−67.4%) + br assets/chunk-SVP7TREG-Xn5LeAbu.js 15728 → 3959 (−74.8%) + gzip assets/chunk-SVP7TREG-Xn5LeAbu.js 15728 → 4413 (−71.9%) + br assets/chunk-TICWLB2K-yUyCyRlD.js 49171 → 13023 (−73.5%) + gzip assets/chunk-TICWLB2K-yUyCyRlD.js 49171 → 15643 (−68.2%) + br assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81662 → 19567 (−76.0%) + gzip assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81662 → 22373 (−72.6%) + br assets/cynefin-OW5HDTMX-Dit8THDr.js 687914 → 124585 (−81.9%) + gzip assets/cynefin-OW5HDTMX-Dit8THDr.js 687914 → 153069 (−77.7%) + br assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10062 → 3233 (−67.9%) + gzip assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10062 → 3598 (−64.2%) + br assets/cytoscape.esm-BB4DxJjf.js 443036 → 119602 (−73.0%) + gzip assets/cytoscape.esm-BB4DxJjf.js 443036 → 141741 (−68.0%) + br assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10641 → 3659 (−65.6%) + gzip assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10641 → 4024 (−62.2%) + br assets/defaultLocale-DX6XiGOO.js 4688 → 2012 (−57.1%) + gzip assets/defaultLocale-DX6XiGOO.js 4688 → 2166 (−53.8%) + br assets/diagram-S7CK7UJ4-yML3LoUw.js 8182 → 3307 (−59.6%) + gzip assets/diagram-S7CK7UJ4-yML3LoUw.js 8182 → 3662 (−55.2%) + br assets/diagram-UQ7AKVKN-RZXoXHTp.js 6044 → 2343 (−61.2%) + gzip assets/diagram-UQ7AKVKN-RZXoXHTp.js 6044 → 2596 (−57.0%) + br assets/diagram-VSXAHHWV-D-Jngocg.js 10681 → 3764 (−64.8%) + gzip assets/diagram-VSXAHHWV-D-Jngocg.js 10681 → 4132 (−61.3%) + br assets/diagram-VX7I27RA-CqVb28LM.js 15922 → 5130 (−67.8%) + gzip assets/diagram-VX7I27RA-CqVb28LM.js 15922 → 5689 (−64.3%) + br assets/diagram-Z3DM3KII-DaRdRaxA.js 4176 → 1626 (−61.1%) + gzip assets/diagram-Z3DM3KII-DaRdRaxA.js 4176 → 1800 (−56.9%) + br assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1970 → 784 (−60.2%) + gzip assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1970 → 862 (−56.2%) + br assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30755 → 9152 (−70.2%) + gzip assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30755 → 10522 (−65.8%) + br assets/flowDiagram-HODETNUW-DwTSI7cN.js 61840 → 16342 (−73.6%) + gzip assets/flowDiagram-HODETNUW-DwTSI7cN.js 61840 → 19714 (−68.1%) + br assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69656 → 20654 (−70.3%) + gzip assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69656 → 23405 (−66.4%) + br assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29382 → 7766 (−73.6%) + gzip assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29382 → 8676 (−70.5%) + br assets/index-BYHTgAJJ.css 36824 → 8557 (−76.8%) + gzip assets/index-BYHTgAJJ.css 36824 → 9952 (−73.0%) + br assets/index-DhyNmxJE.js 2829141 → 625981 (−77.9%) + gzip assets/index-DhyNmxJE.js 2829141 → 798236 (−71.8%) + br assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17434 → 5995 (−65.6%) + gzip assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17434 → 6612 (−62.1%) + br assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23536 → 7492 (−68.2%) + gzip assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23536 → 8314 (−64.7%) + br assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20256 → 6416 (−68.3%) + gzip assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20256 → 7151 (−64.7%) + br assets/katex-HP8lGamR.js 261313 → 63694 (−75.6%) + gzip assets/katex-HP8lGamR.js 261313 → 77109 (−70.5%) + br assets/layout-C2imKr5O.js 35429 → 11552 (−67.4%) + gzip assets/layout-C2imKr5O.js 35429 → 12742 (−64.0%) + br assets/linear--qpe2Iaf.js 5654 → 2120 (−62.5%) + gzip assets/linear--qpe2Iaf.js 5654 → 2311 (−59.1%) + br assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23140 → 6990 (−69.8%) + gzip assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23140 → 7777 (−66.4%) + br assets/ordinal-Cboi1Yqb.js 1189 → 497 (−58.2%) + gzip assets/ordinal-Cboi1Yqb.js 1189 → 574 (−51.7%) + br assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1921 → 803 (−58.2%) + gzip assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1921 → 904 (−52.9%) + br assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6259 → 2379 (−62.0%) + gzip assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6259 → 2608 (−58.3%) + br assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33975 → 8492 (−75.0%) + gzip assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33975 → 9854 (−71.0%) + br assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1571 → 663 (−57.8%) + gzip assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1571 → 734 (−53.3%) + br assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30965 → 8236 (−73.4%) + gzip assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30965 → 9638 (−68.9%) + br assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23163 → 7806 (−66.3%) + gzip assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23163 → 8545 (−63.1%) + br assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116927 → 25725 (−78.0%) + gzip assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116927 → 30934 (−73.5%) + br assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10326 → 3305 (−68.0%) + gzip assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10326 → 3606 (−65.1%) + br assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115756 → 36638 (−68.3%) + gzip assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115756 → 42149 (−63.6%) + br assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30794 → 9120 (−70.4%) + gzip assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30794 → 10124 (−67.1%) + br assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41926 → 13970 (−66.7%) + gzip assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41926 → 15620 (−62.7%) + br assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25467 → 6076 (−76.1%) + gzip assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25467 → 6733 (−73.6%) + br assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44474 → 11050 (−75.2%) + gzip assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44474 → 12519 (−71.9%) + br fonts/OFL.txt 4474 → 1437 (−67.9%) + gzip fonts/OFL.txt 4474 → 1980 (−55.7%) +precompress: wrote 49 .br and 49 .gz +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-test.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-test.txt new file mode 100644 index 00000000..d2ad060d --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-test.txt @@ -0,0 +1,517 @@ + +> shuimu-workbench@1.0.0 test +> tsx --test "src/**/!(*InteractiveCard*|*chatWindowArtifact*|*deliverableSync*).test.ts" && vitest run src/components/chat/InteractiveCard.test.ts src/components/chat/chatWindowArtifact.test.ts src/components/chat/deliverableSync.test.ts + +✔ AuthClient: 基础登录与 .env 超级管理员鉴权 (2.36032ms) +✔ AuthClient: 自主注册受控流程(仅填用户名密码姓名,职位强制绑定访客待指派) (0.506655ms) +✔ AuthClient: 开启注册需审批时,注册账号处于待审状态并禁止登录 (0.586823ms) +✔ AuthClient: 管理员创建账号必须在首次登录修改密码 (0.308111ms) +✔ mapUserAccount: snake_case Go contract → camelCase (1.500635ms) +✔ mapUserAccount: prefers snake_case when both present (0.292481ms) +✔ mapLoginResult + mapMeResult map nested user and top-level must_change_password (0.980681ms) +✔ mapUserAccountList maps admin users list (0.205094ms) +✔ AuthClient.login + fetchMe map real snake_case wire payloads (31.29732ms) +✔ shouldAutoRetryChat: disabled until idempotency (0.871114ms) +✔ shouldAutoRetryTask: transient only, max 2 (0.218044ms) +✔ shouldAutoRetryTask: document busy never retries (0.290802ms) +✔ shouldAutoRetry alias delegates to task path (0.214073ms) +✔ autoRetryDelayMs: 1s then 2s (0.168625ms) +✔ fallbackErrorClass: network → transient, workspace → resumable (0.126596ms) +✔ upsertChatSession surfaces safe INTERNAL envelope instead of raw JSON text (30.437616ms) +[sse] 忽略无法解析的帧: {not valid json SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2) + at JSON.parse () + at parseFrame (/home/weichen/projects/Agent-System/web/src/api/chatStream.ts:123:26) + at parseSSEChunk (/home/weichen/projects/Agent-System/web/src/api/chatStream.ts:147:20) + at TestContext. (/home/weichen/projects/Agent-System/web/src/api/chatStream.test.ts:41:31) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) +✔ parseSSEChunk: parses event and data lines (1.014139ms) +✔ parseSSEChunk: keeps incomplete frame in rest (0.246162ms) +✔ parseSSEChunk: flush parses final frame without trailing delimiter (0.308091ms) +✔ parseSSEChunk: ignores heartbeat comments and keeps last id (0.165105ms) +✔ parseSSEChunk: skips a single malformed frame and keeps parsing the next valid frame (2.574834ms) +▶ docgov lifecycle API client + ✔ claimDocument posts to /api/v1/docgov/claim with author role (31.264031ms) + ✔ submitForReview posts to /api/v1/docgov/submit-review (7.375151ms) + ✔ reviewDecision posts action and optional reason (0.895863ms) + ✔ approverDecision posts action and rollback_to (0.745978ms) + ✔ proposeModification posts to /api/v1/docgov/proposal (0.755858ms) + ✔ decideModificationProposal posts to /api/v1/docgov/proposal/decide (0.65576ms) +✔ docgov lifecycle API client (46.846989ms) +✔ updateFreeformDoc exposes CONCURRENCY_CONFLICT on 409 (41.44241ms) +✔ addProjectMember exposes structured ProjectSettingsApiError on 403 (25.500913ms) +✔ updateProjectMember surfaces safe INTERNAL message on 500 duty-sync failure (0.733898ms) +▶ projectSettingsError + ✔ keeps status, code, message and issues on PLAN_FROZEN (1.809536ms) + ✔ maps revision and concurrent conflicts to distinct recovery (0.198244ms) + ✔ joins validation issues but still keeps typed codes (0.186784ms) + ✔ prefers envelope code over legacy error field (0.160185ms) + ✔ unwraps nested business JSON inside VERSION_CONFLICT envelope (0.180435ms) + ✔ maps bare VERSION_CONFLICT envelope to retry_after_switch (0.122906ms) + ✔ retains the local draft when a late tab hits PLAN_FROZEN (0.107517ms) + ✔ prefers error_code over legacy bare error string (DOC_BUSY) (0.253902ms) +✔ projectSettingsError (4.914584ms) +✔ Project workflow types validation (1.167705ms) +✔ createTaskRun exposes DOC_BUSY detail without a retryable run (19.844201ms) +✔ parseSSEChunk tracks id seq for after_seq reconnect (0.378759ms) +✔ subscribeTaskRunEvents reconnects on EOF with advancing after_seq (1.803177ms) +✔ readTaskRunSSEOnce marks non-terminal EOF for reconnect (0.461516ms) +✔ subscribeTaskRunEvents fails fast on 401 without retrying (0.366009ms) +✔ subscribeTaskRunEvents fails fast on 404 without retrying (0.31701ms) +✔ buildTaskRequest: maps five project fields + task_key + user identity (1.154226ms) +✔ buildTaskRequest: omits optional fields when absent (0.176735ms) +✔ buildTaskRequest: includes materials when provided and non-empty (0.747018ms) +✔ buildResumeRequest: adds work_dir on top of task request (0.156125ms) +✔ buildTaskRequest: canonicalizes legacy doc-p doc_id (0.250932ms) +✔ buildTaskRequest: UI-05 pinned intake keeps task_key + doc_proj6109_intake (0.165745ms) +✔ buildResumeRequest: forwards add_steps when provided (0.121796ms) +✔ CategoryDock: 渲染顶部 🔔 未读提醒项及角标数字 (18.299496ms) +✔ CategoryDock: isAllReadCompleted 为 true 时渲染完成态提示 (3.04884ms) +✔ CategoryDock: 0 未读时不渲染红色未读角标 (2.771678ms) +✔ CategoryDock: 分类项包含依次跳转与遍历提示信息 (3.096658ms) +✔ InputArea keeps draft when send preflight fails (3.293343ms) +✔ InputArea clears draft only after accepted send (0.65808ms) +✔ InputArea shows thinking block message (0.181135ms) +✔ InputArea keeps draft when attachment registration fails (0.277352ms) +✔ isTemplateOnly 交付物应正确识别为初始模板展示态 (0.722139ms) +✔ 左侧栏鼠标严密绝对跟随与夹紧测试 (Left Rail Exact Pointer Following & Clamping) (0.799306ms) +✔ 右侧栏鼠标严密绝对跟随与反向位移测试 (Right Rail Exact Pointer Following & Inverted Delta) (3.654992ms) +✔ detects legacy mock and neuropulse/ten-projects ids (1.01765ms) +✔ detects colliding demo project ids (0.183125ms) +✔ applyRestoredFileRaw: 以 prev 为权威,只补丁缺失 fileRaw,不丢新增条目 (1.089738ms) +✔ applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用 (0.158255ms) +✔ 每人独立私聊:同一项目不同用户拥有不同 sessionId (0.765487ms) +✔ 左侧会话列表按当前用户过滤,不可见他人私聊 (0.241232ms) +✔ 消息严格归属各自私聊 session,无跨用户混聊 (0.863025ms) +✔ 切换用户时 resolve 到该用户同项目私聊会话 (0.246963ms) +✔ resolve 优先有消息的私聊,跳过空壳 sess-p{N}-u{id} (0.3339ms) +✔ ensurePrivateSessionsForUser 为缺失用户补齐空私聊 (0.442747ms) +✔ 多项目会话消息隔离测试仍成立(endo vs ultrasound) (0.280472ms) +✔ window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤儿条目 (1.133546ms) +✔ window cache: 读写同一 userId 前缀,跨用户不串读 (0.259122ms) +✔ parseAnchorHash: 正确解析标准深链 hash (1.784847ms) +✔ formatAnchorHash: 正确构造深链格式 (0.202834ms) +✔ navigateToAnchor: 快路径直接命中 DOM 节点 (0.499195ms) +✔ navigateToAnchor: 慢路径拉取分片后命中 (81.15696ms) +✔ resolveTurnAnchorId: 方案 A 轮次归一化测试 (0.867534ms) +✔ 全链路场景 1: 用户自主受控注册(仅填用户名/密码/姓名,职位严格绑定访客待指派) (1.631291ms) +✔ 全链路场景 2: 系统准入策略(关闭自主注册与开启强制审批流转) (0.68869ms) +✔ 全链路场景 3: 管理员开通账号首次强制改密闭环 (0.254653ms) +✔ 全链路场景 4: 环境标识随登录会话返回(dev/test/main) (1.144106ms) +✔ cleanHeadingContent: 手工多级序号前缀清洗与专业词汇保护 (1.220074ms) +✔ 标题多层级自动编号:单 H1 文档总标题自适应模式 (1 -> 1.1 -> 1.1.1) (0.459877ms) +✔ 标题多层级自动编号:多 H1 标准大章模式 (0.222793ms) +✔ 跨章节图、表、公式多级按章重编号测试 (表 1-1, 表 2-1, 图 3-1, 式 (4-1)) (0.225843ms) +✔ 有序列表与无序列表编号与渲染功能测试 (2.181595ms) +✔ 动态块操作闭环重编号验证 (Insert, Move, Delete) (0.284481ms) +✔ findMatchingBlockForReview: 能够精准按审查关键词(电气安全/EMC/竞品)定位段落块 (2.512585ms) +✔ findMatchingBlockForReview: 能够按外部指定 hint 线索精确命中目标块 (0.243222ms) +✔ createRejectConfirmationCard: 生成合规危险级驳回门禁卡片 (0.637661ms) +✔ isAffirmativeResponse: 准确识别双轨交互中的肯定意图输入 (0.243303ms) +✔ findLastActiveCard: 正确在会话历史中提取最新的待决策卡片 (0.258322ms) +✔ decrementActiveCardsTtl: 多轮对话超时递减与自动失效归档 (0.205684ms) +✔ applyConfirmRejectToDoc & applyUndoRejectToDoc: 驳回挂载批注与60s窗口期撤销完整闭环 (0.35438ms) +✔ resolveCardInMessages & cancelCardInMessages: 消息流中卡片终态沉淀 (0.194655ms) +✔ classifyRejectUtterance: 询问原因 → QUERY_REJECT_REASON (1.236494ms) +✔ classifyRejectUtterance: 明确执行 → EXECUTE_REJECT (0.206654ms) +✔ classifyRejectUtterance: 模糊 → OTHER (0.222504ms) +✔ resolveDocGovCard: 编制人查询 → 信息卡且无 confirm_reject (0.864404ms) +✔ resolveDocGovCard: 审核人执行 + reviewing → 确认卡 (0.386939ms) +✔ resolveDocGovCard: 编制人误说执行驳回 → 无权纯文本 (1.904913ms) +✔ resolveDocGovCard: 批准人 + approving → 终审确认卡 (0.242293ms) +✔ resolveDocGovCard: 编制人 OTHER → 引导查询无危险按钮 (0.139546ms) +✔ canConfirmReject: 仅审核/批准 (0.166155ms) +✔ deriveFallbackCategory identifies categories correctly (1.057539ms) +✔ enrichMessagesWithCategories backfills categories without overwriting existing ones (0.33838ms) +✔ Markdown ➔ Block AST 解析测试 (标题自适应编号与图表公式全链路) (7.07526ms) +✔ 动态重编号引擎测试 (多 H1 规范章节重排验证) (0.489675ms) +✔ Block AST ➔ Markdown 双向无损往返还原测试 (0.521475ms) +✔ 三层交付物多用户隐私绝对隔离测试 (Multi-user Strict Privacy Isolation) (1.873875ms) +✔ 一键共享至项目参考库流转测试 (Promote to Shared Reference Test) (0.283601ms) +✔ 撤回共享恢复私有草稿测试 (Revoke Shared Reference Test) (0.524474ms) +✔ 受控工作流纯净性红线检验 (Workflow Purity Security Red Line) (0.515395ms) +✔ isSameMessage: clientTurnId match (5.167817ms) +✔ isSameMessage: serverId match without turn (0.255322ms) +✔ filterPendingLiveMessages: real temp vs server id (0.244913ms) +✔ mergeLiveOverHistory preserves server id from live (0.236403ms) +✔ applyPhaseEvent: appends first phase as running node (2.731859ms) +✔ applyPhaseEvent: completes predecessors when a new phase arrives (0.212283ms) +✔ applyPhaseEvent: same phase refreshes label in place (no duplicate node) (0.223033ms) +✔ applyPhaseEvent: unknown phase falls back to hourglass meta (0.104037ms) +✔ applyPhaseEvent: prefers backend message over short label (0.084508ms) +✔ applyPhaseEvent: step_completed closes running entry without appending (0.111846ms) +✔ applyPhaseEvent: stage_completed closes entries (0.084058ms) +✔ applyPhaseEvent: task_failed appends warning node (0.095717ms) +✔ applyPhaseEvent: decorates label with step index / total steps (0.118007ms) +✔ applyPhaseEvent: repeated step_executing with step_index gets separate nodes (0.217453ms) +✔ applyPhaseEvent: ignores event without phase payload (0.106837ms) +✔ applyPhaseEvent: step_failed marks running entry as failed (keeps step label) (0.120837ms) +✔ applyPhaseEvent: step_failed appends failed node when no running entry exists (0.100117ms) +✔ applyPhaseEvent: retry of a failed step resets it to running (0.085328ms) +✔ PLAN_CONFIRM 卡片来自 PlanView 且不触达 AST (0.759087ms) +✔ 409 冲突把卡片标为 outdated 并换上最新 revision (0.180015ms) +✔ GET reconcile 用权威 PlanView 覆盖本地 executing,不假装成功 (0.127966ms) +✔ 过期计划映射 expired (0.107397ms) +✔ 非计划卡不被误判 (0.078247ms) +✔ 集成验证:项目切换时交付物严格隔离,新建项目即刻包含 7 份模板副本 (4.826057ms) +✔ 集成验证:三层隐私与项目物理隔离双重约束 (1.486596ms) +✔ 用例 1: DEFAULT_ROLES 包含 5 种默认角色且具备预期权限 (1.00728ms) +✔ 用例 2: 系统管理员 (ADMIN) 与具备 perm:manage 用户的通配豁免特权 (0.234323ms) +✔ 用例 3: 常规工作流基线可见性 (workflow:view_all) 判定 (0.245353ms) +✔ 用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截) (0.235103ms) +✔ 用例 5: 单文档覆盖 - role_whitelist (仅白名单内角色可见,其他角色被精确拦截) (2.558334ms) +✔ 用例 6: 单文档覆盖 - public (放宽可见性,无 workflow:view_all 权限的访客也能查阅) (0.164935ms) +✔ 用例 7: 私密文档隔离强红线 (category === private 无论任何管理员或单文档覆盖,非创建者绝对不可见) (0.272462ms) +✔ 用例 8: 核心业务动作权限拦截判定 (canPerformDocAction & hasPermission) (0.182035ms) +✔ 用例 9: filterVisibleDeliverablesWithRBAC 能够批处理过滤大盘交付物 (3.571864ms) +✔ 集成场景 1: 动态新增合规审计员角色,并配置其仅能查阅特定单文档覆盖 (1.071709ms) +✔ 集成场景 2: 服务端 capabilities 变更后权限即刻生效 (0.241582ms) +✔ 集成场景 3: 单文档覆盖 designated_only 严格收紧与撤回恢复全流程 (0.232353ms) +✔ renderInlineMarkdown: renders bold, italic, strikethrough (1.370409ms) +✔ renderInlineMarkdown: escapes raw HTML tags to prevent XSS (0.517285ms) +✔ renderInlineMarkdown: blocks javascript: link hrefs (0.155095ms) +✔ renderInlineMarkdown: renders inline code and preserved line breaks (0.221124ms) +✔ renderInlineMarkdown: renders headings lists and tables for chat replies (0.380619ms) +✔ sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators (0.33966ms) +(node:367351) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. +(Use `node --trace-warnings ...` to show where the warning was created) +✔ DOCX 渲染器行内文本与公式解析测试 (parseInlineContent) (5.175546ms) +✔ DOCX 渲染器 LaTeX 原生 OMML 转换引擎测试 (latexToOmml) (2.148146ms) +✔ DOCX 渲染器 Mermaid 语法净化与容错测试 (sanitizeMermaidCode) (0.282702ms) +✔ DOCX 渲染器 SVG 净化防 Canvas 污染测试 (cleanSvgForCanvas) (0.380619ms) +✔ DOCX 渲染器全量编译测试 (buildDocxModel & exportDocumentToDocx) (53.321226ms) +✔ XLSX 渲染器多表提取与工作簿编译测试 (buildXlsxWorkbook & exportDocumentToXlsx) (6.375551ms) +▶ Reply Document IR & Export Engine + ✔ deriveReplyTitle and sanitizeFilename derive title and safe file name (1.434858ms) + ✔ deriveReplyTitle handles text without heading (0.302901ms) + ✔ parseReplyMarkdown parses headers, lists, code, and tables (2.02298ms) + ✔ renderClipboardHtml renders self-contained styling for office tools (0.579783ms) + ✔ renderClipboardHtml converts LaTeX formulas to native MathML for Word/Office (4.175486ms) + ✔ renderPlainText strips formatting cleanly (0.517545ms) + ✔ serializeMarkdown round-trips to valid markdown (0.387529ms) + ✔ stripUnsafeHtml strips script tags and dangerous event handlers (0.642771ms) +✔ Reply Document IR & Export Engine (11.944965ms) +✔ createProjectTemplateDeliverables: 必须精确生成 7 份全生命周期受控文档副本 (4.38929ms) +✔ resolveRequirementsTaskKey prefers intake for 初始需求表 (1.392979ms) +✔ resolveRequirementsTaskKey selects user_requirements for explicit URS (0.190325ms) +✔ UI-02 first-round prompt with 首轮+用户需求+摸底 must be intake not user_requirements (2.768718ms) +✔ 首轮 / 需求摸底 / 摸底 beat bare 用户需求 (0.185615ms) +✔ bare 用户需求 without V0.1 markers stays user_requirements (0.138556ms) +✔ matchTemplateDeliverable uses intake key when pool only has URS template (0.530825ms) +✔ resolveTaskTemplateForMessage keeps intake key with URS-only pool (0.229823ms) +✔ matchTemplateDeliverable prefers intake when both templates exist (0.859164ms) +✔ read-only request does not start a document task: 总结一下用户初始需求表 (0.235643ms) +✔ read-only request does not start a document task: 查看用户初始需求表 (0.159335ms) +✔ read-only request does not start a document task: 用户初始需求表现在谁在编辑 (1.497015ms) +✔ read-only request does not start a document task: 分析一下用户需求表里的风险 (0.090067ms) +✔ read-only request does not start a document task: 用户初始需求表 (0.608802ms) +✔ explicit write action still starts intake (0.129526ms) +✔ explicit update action still starts URS (0.092547ms) +✔ UI-05 prompt without active doc still keyword-routes to feasibility_reg (2.181215ms) +✔ UI-05 prompt + registered intake pins intake and doc_proj6109_intake (0.32544ms) +✔ read-only current-doc request does not start a task (0.121597ms) +✔ template-only active slot is not pinned; keywords still apply (0.101057ms) +✔ explicit named deliverable can switch away from active intake (0.168065ms) +✔ domain 注册法规 without current-doc deixis still routes feasibility_reg (0.073708ms) +✔ mismatched title vs docId suffix prefers docId taskKey (0.081858ms) +✔ deixis 当前文档 pins registered intake (0.086398ms) +✔ deixis 本文件 pins registered intake (0.049318ms) +✔ deixis 这份文档 pins registered intake (0.043139ms) +✔ deixis 本文档 pins registered intake (0.040189ms) +✔ deixis 本受控文档 pins registered intake (0.042068ms) +✔ no active doc does not invent a pin (0.081707ms) +✔ UI-11 named 系统设计输入 beats negated 需求 background (0.070618ms) +✔ mixed generate-design + do-not-cover-requirements stays prelim_design (0.055039ms) +✔ current-doc pin still wins when no other deliverable is named (0.065488ms) +▶ document revision overlay modes + ✔ pure hides all decorations (0.844865ms) + ✔ manual only marks human hunks (0.249672ms) + ✔ all marks human and agent differently (0.155085ms) + ✔ same human hunk stays amber in manual and all (no pairwise style guarantee) (2.599062ms) + ✔ exposes needs-content error code for empty orphan docs (0.164005ms) + ✔ matches overlay hunks by section or text when ids differ (0.297302ms) + ✔ aligns client block ids from head_block_map by order (0.232113ms) +✔ document revision overlay modes (10.336083ms) +▶ toolbar refresh + report entry reachability + ✔ pickLatestChangeReportId prefers highest version with report (0.318001ms) + ✔ first squad run alone still exposes a reachable report id (0.185435ms) + ✔ second squad run keeps both history report entries and latest shortcut (4.05202ms) + ✔ revisionRefreshKey is an independent refresh signal from docId (0.214494ms) +✔ toolbar refresh + report entry reachability (5.153077ms) +▶ change report view model + ✔ surfaces risk items, project impact, doc impacts with advice, and open questions (2.721909ms) + ✔ marks complete analysis as PI narrative vs factual fields (0.768257ms) + ✔ preferReportId prioritizes pending over committed latest (0.119146ms) +✔ change report view model (3.838086ms) +▶ revisionToolbarMount + ✔ exposes clear non-error template preview hint copy (0.860824ms) + ✔ template doc-p + isTemplateOnly=true does not mount (0.230023ms) + ✔ legacy doc-001 does not mount (0.235583ms) + ✔ unregistered doc-upload orphan does not mount (must register first) (0.270042ms) + ✔ server-registered upload (doc-free) and freeform mount (0.33066ms) + ✔ canonical doc_proj and generated non-template doc-p mount (0.199074ms) +✔ revisionToolbarMount (4.420588ms) +✔ useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId (1.060028ms) +✔ window fetch race: stale response ignored by generation (1.059468ms) +✔ mapRecordToMessage: 正确映射服务端消息记录为前端 ChatMessage (1.560494ms) +✔ refresh window: 空 messages 日桶不算有效窗口 (0.263342ms) +✔ mapRawBucket: 正确映射服务端按日分片桶 (0.288572ms) +✔ Category navigation: chip 选中后准确匹配目标分类最新轮次并触发归一化定位 (0.257992ms) +✔ turn runtime: session A stream survives session B switch (1.183235ms) +✔ abortAllSessions aborts controllers across all sessions (0.33573ms) +✔ syncAdminUserDirectories calls local reload then parent onRefreshUsers (1.384819ms) +✔ syncAdminUserDirectories fails closed when onRefreshUsers is a dead/missing prop (0.601742ms) +✔ syncAdminUserDirectories surfaces parent refresh errors (no silent swallow) (0.259902ms) +✔ afterProjectMemberPersisted syncs members then refreshes workflow docs (1.498676ms) +✔ afterProjectMemberPersisted does not refresh when member sync fails (0.549044ms) +▶ blockEditorDialogPolicy + ✔ pins an accessible dialog label and exact save name (0.949511ms) + ✔ rejects page-wide fuzzy /保存/ that would hit private-library buttons (0.383579ms) +✔ blockEditorDialogPolicy (2.562113ms) +▶ resolveSaveTargetBlock + ✔ keeps the editor and does not fake success when the block id is gone (0.640411ms) + ✔ never retargets another block that happens to share the same content (0.752827ms) +✔ resolveSaveTargetBlock (2.325531ms) +▶ editor session document binding + ✔ fail-closes when activeDocId drifts away from the opened editor doc (0.229053ms) + ✔ accepts a save only when the editor is still bound to the active doc (0.151646ms) + ✔ blocks switching documents while the editor is open (0.164595ms) +✔ editor session document binding (0.895543ms) +▶ interpretBlockSaveError + ✔ 409 keeps the draft in the editor (0.563334ms) + ✔ other PATCH failures also keep the editor (2.113537ms) +✔ interpretBlockSaveError (3.085348ms) +▶ blockedGovernanceCopy + ✔ recognizes blocked run status and fixed hint, not generation success (0.807416ms) +✔ blockedGovernanceCopy (1.814866ms) +▶ commentPolicy + ✔ lets author/reviewer/approver annotate and keeps their comment role (0.833775ms) + ✔ keeps viewer read-only: no add entry and no reviewer/approver cast (0.152925ms) +✔ commentPolicy (2.108627ms) +▶ controlledContentInvalidation + ✔ hydrate success then late list_sync keeps cache/ready and does not bump resume (1.875694ms) + ✔ post_task invalidation drops active cache and requires a new hydrate epoch (0.317201ms) + ✔ editor open / save epoch keeps draft and rejects a stale GET after PATCH (0.552254ms) + ✔ never drops the active doc without signaling rehydrate (0.468966ms) + ✔ candidate helper keeps template / doc-001 / freeform / upload out of wipe lists (0.505365ms) + ✔ dropRecordKeys is a no-op when ids are absent (0.141286ms) +✔ controlledContentInvalidation (6.685832ms) +▶ controlledDocHydrate + ✔ shouldLoadControlledContent when non-template workflow doc (4.623543ms) + ✔ shouldLoadControlledContent rejects local legacy/mock ids like doc-001 (4.355051ms) + ✔ shouldLoadControlledContent keeps canonical, freeform-off, and template preview rules (0.33535ms) + ✔ hydrateControlledDocumentContent never requests content/revision for doc-001 (0.371039ms) + ✔ hydrateControlledDocumentContent never requests for template preview or registered freeform (0.530244ms) + ✔ shouldLoadControlledContent stays inside toolbar server-backed eligibility (0.3313ms) + ✔ resolveCanonicalDocId maps doc-p56-intake to doc_proj56_intake (0.515705ms) + ✔ canonicalDocIdForTask maps doc-p56-research to doc_proj56_research (0.32603ms) + ✔ hydrate treats projection missing as ledger content, not 404 (4.001802ms) + ✔ attaches hydrate ETag as PATCH baseline and ignores a later GET (1.843485ms) + ✔ rejects hydrate when revision-state network or 5xx fails (1.925553ms) + ✔ aligns markdownToBlockAST random ids onto revision-state head_block_map (3.095228ms) + ✔ leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain) (3.505966ms) + ✔ rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT (0.479186ms) + ✔ fingerprint stays stable when only object identity changes (0.182494ms) + ✔ dedupes in-flight hydrate for the same doc fingerprint (0.315051ms) + ✔ aligned late hydrate still cannot overwrite a PATCH-success baseline (0.519714ms) +✔ controlledDocHydrate (31.258232ms) +✔ createClientTurnId uses randomUUID when available (0.768398ms) +✔ createClientTurnId falls back to getRandomValues UUID v4 (0.452466ms) +✔ createClientTurnId throws when no secure random API exists (0.405498ms) +✔ selectNonCreatorInitialMembers skips creator only (1.519185ms) +✔ buildManagedMemberUpsertPayload sends document_duty on add/update/restore (0.294151ms) +✔ buildInitialMemberUpsertPayload maps reviewer UI role to member API role (0.285552ms) +✔ persistInitialProjectMembers awaits each add and stops on failure (2.529205ms) +✔ ensureCreatedProjectWithMembers skips recreate on bootstrap id and retries members (0.793066ms) +✔ beginCreateProjectSubmit rejects concurrent second entry until end (30.99431ms) +✔ resolveCreateProjectSubmitPhase uses bootstrap to choose syncing label (0.368889ms) +✔ createProjectSubmitButtonLabel and close block while submitting (0.197224ms) +✔ shouldCloneMedicalTemplates skips the 7-pack for software domain (0.125417ms) +▶ resolveCurrentDocRole + ✔ resolves A as author by authoritative user id (1.071268ms) + ✔ does not promote reviewer B or stranger to author (0.209004ms) + ✔ resolves real USER_B id as reviewer and never name-promotes placeholder 2 (0.162735ms) + ✔ fail-closes unknown identity to viewer even if projectPermission says author (0.151426ms) + ✔ matches name only for legacy local docs without duty ids (0.116057ms) + ✔ does not name-promote when server controlled duties are zero ids (0.128666ms) +✔ resolveCurrentDocRole (3.236863ms) +▶ currentProjectRestore + ✔ rejects empty project names (1.107647ms) + ✔ rejects page-wide project name when banner and sidebar both match (0.283262ms) + ✔ scopes the visible entry to complementary exact title (0.898173ms) + ✔ scopes the current project assertion to banner exact text (0.420938ms) +✔ currentProjectRestore (3.969582ms) +▶ deliverableLeaseSync + ✔ returns the same array reference when leases are unchanged (0.907253ms) + ✔ returns a new array only when a lease actually changes (0.212504ms) +✔ deliverableLeaseSync (2.162356ms) +▶ docGovActionVisibility + ✔ hides all approval actions on unmanaged docs (0.794286ms) + ✔ gates separated duties by user id, not folded role label (0.191454ms) + ✔ author_only: same user holds all three duties and sees all actions (0.239592ms) + ✔ author_only: non-duty user sees no actions (0.127156ms) +✔ docGovActionVisibility (2.670331ms) +▶ documentBlockDomAttrs + ✔ marks a real DocumentBlock in pure mode without overlay kind (0.866665ms) + ✔ keeps the same stable attrs when overlay is later attached separately (0.221383ms) +✔ documentBlockDomAttrs (2.152296ms) +▶ durableBodySnippet + ✔ rejects empty persisted snippets (0.825675ms) + ✔ lets a later successful revision replace the old snippet (0.155776ms) + ✔ does not promote a conflict-failed draft to the expected body (0.080487ms) +✔ durableBodySnippet (1.778697ms) +▶ dutyCellAssertion + ✔ parses author, reviewer, and viewer from select value or badge role (2.68939ms) + ✔ must not infer author from a row whose option labels include 编制负责人 (2.749208ms) + ✔ accepts only a disabled non-assignable reviewer select (0.192935ms) + ✔ rejects an enabled reviewer select when the reviewer cannot assign leads (0.138596ms) + ✔ rejects canAssignLead=true even if the reviewer select is disabled (0.104027ms) + ✔ rejects viewer, author, approver, and unknown values for USER_B (0.147196ms) +✔ dutyCellAssertion (7.283944ms) +▶ decideHydrationApply + ✔ applies only when gen, doc, save epoch match and editor is closed (1.169155ms) + ✔ rejects a response for another document (0.202464ms) + ✔ rejects a superseded generation (0.144535ms) +✔ decideHydrationApply (2.470667ms) +▶ createEditorHydrationGate + ✔ does not start a new hydrate while the editor is open (UI-06 fill-window GET) (0.3481ms) + ✔ deferred hydrate that resolves after the editor opens does not replace the session (0.179175ms) + ✔ covers hydrate resolving between fill and save (2.726659ms) + ✔ asks for a hydrate resume after editor_open blocked the first apply (0.298881ms) + ✔ asks for a hydrate resume when beginHydration is skipped while editing (0.167885ms) + ✔ requestHydrationResume is consumed once after a protected edit session (0.145506ms) + ✔ late hydrate after a successful PATCH does not roll back the new baseline (0.199994ms) +✔ createEditorHydrationGate (4.561375ms) +✔ mergeSessionUser prefers session identity over stale catalog entry (1.34047ms) +▶ playwrightPhaseArtifacts + ✔ defaults unknown or empty UI_PHASE to main (0.708309ms) + ✔ keeps post-restart distinct from main so the second run cannot overwrite traces (0.219003ms) + ✔ keeps main results at the historical filename and isolates post-restart recordPass (0.234473ms) + ✔ shares phase-state and secrets across phases (0.114226ms) +✔ playwrightPhaseArtifacts (2.329371ms) +✔ resolvePostTaskActiveDocId prefers server doc_proj for taskKey (0.830936ms) +✔ resolvePostTaskActiveDocId honors preferred server doc id (0.183355ms) +✔ shouldSwitchTemplateSlotToServer when active is local template slot (0.126516ms) +✔ pollUntilPostTaskServerDoc retries until same taskKey server doc appears (0.317901ms) +✔ pollUntilPostTaskServerDoc returns null after maxAttempts without relying on setState (0.195934ms) +✔ mapServerProjectToProjectInfo maps creator and preserves local members (0.949132ms) +✔ mergeProjectLists drops local-only demo seed projects by default (0.375909ms) +✔ mergeProjectLists can retain local-only demo when explicitly opted in (0.191375ms) +✔ documentDutyFromMemberRole maps UI reviewer and creator (0.725779ms) +✔ exclusiveMemberRoleChange keeps a single reviewer (0.211814ms) +✔ memberRoleFromDocumentDuty does not invent reviewer from access role (0.142846ms) +✔ toApiProjectRole maps doc roles to member and creator to owner (0.839005ms) +✔ fromApiProjectRole maps owner to creator for project creator (0.247083ms) +✔ mapApiMemberToProjectMember builds UI record (0.200744ms) +▶ projectSettingsDuties + ✔ toggles full to none by atomically clearing duties (1.334491ms) + ✔ blocks partial saves and locates required/governance issue codes (0.474566ms) + ✔ allows an all-none configurable draft without three members (0.207014ms) + ✔ requires three members once any document is managed (0.099128ms) + ✔ resolves author_only to A/A/A without three-member requirement (1.454897ms) + ✔ rejects author_only without author or with conflicting reviewers (0.202334ms) + ✔ mixed full + author_only still requires three members for the full doc (0.136956ms) + ✔ toggleAuthorOnly clears review/approve slots and restores on disable (0.171035ms) +✔ projectSettingsDuties (5.255444ms) +▶ projectSettingsMode + ✔ enters frozen when can_edit_plan is false or the project is active (0.787167ms) + ✔ stays planning only for an editable draft (0.133746ms) + ✔ lists enabled duties for the frozen snapshot and skips disabled tasks (0.733108ms) + ✔ keeps the local draft after PLAN_FROZEN refresh and switches to frozen (0.189694ms) +✔ projectSettingsMode (2.730289ms) +▶ revisionEtag + ✔ uses stale hydrate ETag for PATCH and never the latest GET (0.764917ms) + ✔ does not invent 0:0 when controlled hydrate misses etag or errors (0.169775ms) + ✔ refuses controlled save without a hydrate ETag (0.458676ms) + ✔ allows freeform bootstrap 0:0 only (0.129586ms) +✔ revisionEtag (2.610083ms) +▶ saveControlledBlockEdit + ✔ successful save calls PATCH once with bound docId, hydrate ETag, body and client_block_map (2.557825ms) + ✔ does not PATCH when editorDocId and activeDocId diverge (0.179475ms) + ✔ does not PATCH on stale block id and keeps the draft (0.191045ms) + ✔ 409 does not close the editor or drop the draft (0.651871ms) + ✔ network errors do not close the editor or drop the draft (0.230693ms) +✔ saveControlledBlockEdit (5.02899ms) +✔ parseProjectIdFromSessionId decodes sess-p{id}-u{user} (0.731609ms) +✔ resolveSessionProjectId prefers explicit id then session id encoding (0.132386ms) +✔ mapServerSessionToProjectSession fills project metadata (0.582313ms) +✔ mergeSessionLists keeps local stage while applying server counts (0.213144ms) +▶ squadCompletionObservation + ✔ accepts a directly completed adjacent assistant without requiring inflight (0.984411ms) + ✔ accepts a turn that passed through inflight then completed (0.189744ms) + ✔ rejects error terminals, missing correspondence, and visible errors (0.173405ms) + ✔ keeps waiting while still inflight and does not treat inflight as success (0.122086ms) +✔ squadCompletionObservation (2.565484ms) +✔ resolveReconnectRunId prefers explicit opts and ignores attempt without opts (0.712449ms) +✔ shouldSubscribeExistingRun only for in-flight statuses (0.135776ms) +✔ isRemoteTaskTerminal covers confirmation and quality_failed (0.147646ms) +✔ nextAttemptOpts carries retained runId for reconnect without new POST (0.145395ms) +▶ uploadFreeformRegister + ✔ buildUploadClientKey prefers explicit hint then doc-upload stamp (0.714309ms) + ✔ registerUploadAttachments hard-fails without creating orphan on API error (12.912846ms) + ✔ registerUploadAttachments rejects local doc-upload id from server (0.35008ms) + ✔ registerUploadAttachments returns server doc-free ids and freeform facts (0.477516ms) + ✔ deliverableFromFreeformUpload maps server id and upload facts (0.204844ms) +✔ uploadFreeformRegister (15.81998ms) +▶ workflowCategoryNavigation + ✔ private menu cannot see doc_proj* so it must switch to workflow before exact select (1.225724ms) + ✔ fail-closes when the workflow menu is showing but the exact doc is missing (0.290642ms) + ✔ label and taskKey opens always require the workflow category (0.143806ms) +✔ workflowCategoryNavigation (2.876514ms) +▶ no external Google Fonts on login shell + ✔ index.html has no fonts.googleapis / fonts.gstatic (0.725838ms) + ✔ styles reference self-hosted fonts.css (0.230633ms) +✔ no external Google Fonts on login shell (1.651031ms) +▶ 用户上传文档多格式在线预览体系与渲染分流测试 + ✔ 1. 上传附件时正确注入 sourceType: "uploaded_file" 与完整 fileMeta 元数据 (1.024029ms) + ✔ 2. 扩展名分类规则准确分发至对应的高保真渲染器类型 (0.32923ms) + ✔ 3. 受控工作流交付物严格隔离:绝不命中 FilePreviewPanel,保持 BlockRenderer 纯净 (0.223224ms) + ✔ 4. 上传文档支持受控流转:从个人私密一键共享至项目参考库后,在线预览依然可用且权限标态正确 (0.195064ms) +✔ 用户上传文档多格式在线预览体系与渲染分流测试 (2.971082ms) +▶ 项目立项、成员职责分配与退出留痕治理测试套件 + ✔ 1. 默认项目库包含完整元数据与法定三权成员 (active 状态) (0.733548ms) + ✔ 2. 跨用户会话可见性严格隔离:未加入成员完全不可见 (0.314491ms) + ✔ 3. 创建者添加新成员并分配职责后,新成员即刻可见该项目会话 (0.246873ms) + ✔ 4. 中途加入后期退出 (exited):保留项目会话但标记已退出只读留痕 (0.207754ms) + ✔ 5. 超级管理员 (ADMIN) 享有全量通配可见与管理兜底特权 (0.154515ms) +✔ 项目立项、成员职责分配与退出留痕治理测试套件 (3.121828ms) +▶ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) + ✔ 1. 必须精确生成 7 份官方全流程受控工作流模板且标记纯净 (0.663741ms) + ✔ 2. 杜绝旧项目 CUSA-NeuroPulse 串号泄露,动态注入新项目信息 (0.617142ms) + ✔ 3. 严格对标参考系统模板规范要素,绝非 3 行残缺摘要 (0.458326ms) + ✔ 4. 缓存丢失场景下即时自愈重建 AST 能力验证 (0.245863ms) +✔ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) (5.664852ms) +▶ 用户上传文档私密归档与共享流转测试 + ✔ 10 个评审项目均包含已共享的 URS 交付物与 AST,支持跨角色合规查阅且会话均以 assistant 闭环 (2.264823ms) + ✔ 用户上传附件自动生成私密交付物并在 AST 中建立隔离,支持流转至项目参考库 (1.906043ms) + ✔ 单轮对话支持一次上传多个文档(上限5个),批量生成私密交付物并遵循数量约束 (0.720578ms) +✔ 用户上传文档私密归档与共享流转测试 (5.968453ms) +▶ 工作流切换与消息展示时序测试套件 + ✔ 1. 当存在历史 seed 消息且追加新实时消息时,新消息必须严格沉淀在末尾 (1.040629ms) + ✔ 2. 切回项目时相同正文不应因 temp/server id 差异重复展示 (0.243113ms) +✔ 工作流切换与消息展示时序测试套件 (2.272152ms) +✔ DeliverableItem 必须包含 projectId 与 isTemplateOnly 属性 (0.750318ms) +ℹ tests 406 +ℹ suites 36 +ℹ pass 406 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 1236.897492 + + RUN v5.0.0 /home/weichen/projects/Agent-System/web + +(node:370484) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. +(Use `node --trace-warnings ...` to show where the warning was created) + + Test Files 3 passed (3) + Tests 32 passed (32) + Start at 03:44:36 + Duration 539ms (import 52%, transform 42%, tests 5%, worker 1%) + +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/ownership-pg.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/ownership-pg.txt new file mode 100644 index 00000000..18108484 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/ownership-pg.txt @@ -0,0 +1,31 @@ +=== RUN TestChatSessionOwnership_CrossUserHijackDenied_PG +2026/09/18 03:45:16 goose: no migrations to run. current version: 52 +2026/09/18 03:45:16 goose: no migrations to run. current version: 52 +2026/09/18 03:45:16 [kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment +{"time":"2026-09-18T03:45:16.71718205Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:16.718242398Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +{"time":"2026-09-18T03:45:16.729818684Z","level":"WARN","msg":"ctxmgr_reflect_msgs_scan_incomplete","error":"can't scan into dest[2] (col: user_id): cannot scan NULL into *int64"} +{"time":"2026-09-18T03:45:16.730607491Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:16.730669279Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_PG (0.17s) +=== RUN TestChatSessionOwnership_CrossUserHijackDenied_Memory +{"time":"2026-09-18T03:45:16.732054648Z","level":"INFO","msg":"[kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment"} +{"time":"2026-09-18T03:45:17.237092662Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:17.237182809Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +{"time":"2026-09-18T03:45:17.238873349Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:17.239007575Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_Memory (0.51s) +PASS +ok chat-agent-system/internal/server 0.687s +=== RUN TestChatSessionOwnership_CrossUserHijackDenied_PG +2026/09/18 03:45:55 goose: no migrations to run. current version: 52 +2026/09/18 03:45:55 goose: no migrations to run. current version: 52 +2026/09/18 03:45:55 [kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment +{"time":"2026-09-18T03:45:56.16685721Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:56.167778443Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +{"time":"2026-09-18T03:45:56.178783276Z","level":"WARN","msg":"ctxmgr_reflect_msgs_scan_incomplete","error":"can't scan into dest[2] (col: user_id): cannot scan NULL into *int64"} +{"time":"2026-09-18T03:45:56.179404118Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:56.179448296Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_PG (0.22s) +PASS +ok chat-agent-system/internal/server 0.224s diff --git a/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/go-test-short.txt b/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/go-test-short.txt new file mode 100644 index 00000000..99504258 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/go-test-short.txt @@ -0,0 +1,67 @@ +? chat-agent-system/cmd/artifact-vault [no test files] +ok chat-agent-system/cmd/bizdev-cli 2.561s +? chat-agent-system/cmd/dataset [no test files] +ok chat-agent-system/cmd/document-content-reconcile 0.003s +? chat-agent-system/cmd/document-revision-backfill [no test files] +? chat-agent-system/cmd/engineering-dashboard [no test files] +? chat-agent-system/cmd/eventmode [no test files] +? chat-agent-system/cmd/events [no test files] +? chat-agent-system/cmd/recategorize [no test files] +? chat-agent-system/cmd/server [no test files] +ok chat-agent-system/internal/agent 3.577s +ok chat-agent-system/internal/app 0.017s +ok chat-agent-system/internal/app/chatflow 0.004s +ok chat-agent-system/internal/app/docadvance 0.003s +ok chat-agent-system/internal/app/docflow 0.003s +ok chat-agent-system/internal/app/docnotify 0.003s +ok chat-agent-system/internal/app/docproject 0.003s +ok chat-agent-system/internal/app/fav (cached) +ok chat-agent-system/internal/app/intentflow 0.014s +ok chat-agent-system/internal/app/stageflow 0.004s +ok chat-agent-system/internal/app/uow 0.003s +ok chat-agent-system/internal/archtest 0.178s +ok chat-agent-system/internal/artifactvault (cached) +ok chat-agent-system/internal/auth (cached) +ok chat-agent-system/internal/authz 0.003s +ok chat-agent-system/internal/bizdev 0.161s +ok chat-agent-system/internal/chat 0.015s +ok chat-agent-system/internal/config 0.004s +ok chat-agent-system/internal/coordinator 0.012s +ok chat-agent-system/internal/ctxmgr 0.086s +ok chat-agent-system/internal/dataset (cached) +ok chat-agent-system/internal/db 0.003s +ok chat-agent-system/internal/doccontent (cached) +ok chat-agent-system/internal/docduty 0.004s +ok chat-agent-system/internal/docgov 0.027s +ok chat-agent-system/internal/docquery 0.003s +ok chat-agent-system/internal/docrevision 0.007s +ok chat-agent-system/internal/engineeringdashboard 0.008s +ok chat-agent-system/internal/events 0.005s +ok chat-agent-system/internal/freedoc 0.027s +ok chat-agent-system/internal/fsutil (cached) +ok chat-agent-system/internal/httpx (cached) +ok chat-agent-system/internal/intent 0.023s +ok chat-agent-system/internal/kb 0.009s +ok chat-agent-system/internal/maturity (cached) +ok chat-agent-system/internal/notification 0.077s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext 0.004s +ok chat-agent-system/internal/projectinstance 0.005s +ok chat-agent-system/internal/projectworkflow 0.003s +ok chat-agent-system/internal/quote (cached) +ok chat-agent-system/internal/replyexport (cached) +ok chat-agent-system/internal/server 14.170s +ok chat-agent-system/internal/squad 0.030s +ok chat-agent-system/internal/storage 0.015s +ok chat-agent-system/internal/stream (cached) +ok chat-agent-system/internal/stream/transport (cached) +ok chat-agent-system/internal/swboot 0.002s +ok chat-agent-system/internal/sysadmin 0.003s +ok chat-agent-system/internal/taskrun 0.186s +ok chat-agent-system/internal/template 0.757s +ok chat-agent-system/internal/upload (cached) +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime 0.002s +ok chat-agent-system/internal/workflowdef 0.003s +ok chat-agent-system/internal/workspace (cached) +? chat-agent-system/templates [no test files] diff --git a/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/go-vet.txt b/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/go-vet.txt new file mode 100644 index 00000000..e69de29b diff --git a/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/npm-build.txt b/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/npm-build.txt new file mode 100644 index 00000000..4a6d936f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/npm-build.txt @@ -0,0 +1,236 @@ + +> shuimu-workbench@1.0.0 build +> tsc && vite build && node scripts/precompress-dist.mjs + +vite v7.3.6 building client environment for production... +transforming... +✓ 4123 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.72 kB │ gzip: 0.45 kB +dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB +dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB +dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB +dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB +dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB +dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB +dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB +dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB +dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB +dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB +dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB +dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB +dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB +dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB +dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB +dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB +dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB +dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB +dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB +dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB +dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB +dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB +dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB +dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB +dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB +dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB +dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB +dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB +dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB +dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB +dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB +dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB +dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB +dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB +dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB +dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB +dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB +dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB +dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB +dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB +dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB +dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB +dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB +dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB +dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB +dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB +dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB +dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB +dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB +dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB +dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB +dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB +dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB +dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB +dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB +dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB +dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB +dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB +dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB +dist/assets/index-BYHTgAJJ.css 36.82 kB │ gzip: 10.03 kB +dist/assets/channel-BWaIGDQK.js 0.11 kB │ gzip: 0.12 kB +dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB +dist/assets/chunk-2Q5K7J3B-Bh00PV7B.js 0.19 kB │ gzip: 0.16 kB +dist/assets/chunk-JWPE2WC7-zllJnyes.js 0.22 kB │ gzip: 0.17 kB +dist/assets/chunk-XXDRQBXY-D7xLWH32.js 0.22 kB │ gzip: 0.20 kB +dist/assets/chunk-5VM5RSS4-DwRnlXdD.js 0.36 kB │ gzip: 0.27 kB +dist/assets/stateDiagram-v2-MP3YSRHH-CMni1PkL.js 0.39 kB │ gzip: 0.29 kB +dist/assets/classDiagram-ZZMXUADV-D-sblmQI.js 0.43 kB │ gzip: 0.30 kB +dist/assets/classDiagram-v2-VYDZK3BY-D-sblmQI.js 0.43 kB │ gzip: 0.30 kB +dist/assets/chunk-POPQ4Y6H-CV-hKTcv.js 0.50 kB │ gzip: 0.35 kB +dist/assets/swimlanesDiagram-VR7AAH4N-CSMO0mBI.js 0.51 kB │ gzip: 0.35 kB +dist/assets/infoDiagram-27XIBGKW-fa-zM8z6.js 0.55 kB │ gzip: 0.38 kB +dist/assets/sizeCapture-INFHLROL-DDBMTQik.js 0.90 kB │ gzip: 0.51 kB +dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB +dist/assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1.57 kB │ gzip: 0.73 kB +dist/assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1.77 kB │ gzip: 0.87 kB +dist/assets/chunk-F27PBJKO-usLA3fwD.js 1.88 kB │ gzip: 0.83 kB +dist/assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1.92 kB │ gzip: 0.90 kB +dist/assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1.97 kB │ gzip: 0.86 kB +dist/assets/arc-DzBaewGB.js 3.43 kB │ gzip: 1.48 kB +dist/assets/diagram-Z3DM3KII-DaRdRaxA.js 4.18 kB │ gzip: 1.81 kB +dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB +dist/assets/linear--qpe2Iaf.js 5.65 kB │ gzip: 2.30 kB +dist/assets/diagram-UQ7AKVKN-RZXoXHTp.js 6.04 kB │ gzip: 2.60 kB +dist/assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6.26 kB │ gzip: 2.60 kB +dist/assets/diagram-S7CK7UJ4-yML3LoUw.js 8.18 kB │ gzip: 3.65 kB +dist/assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10.06 kB │ gzip: 3.61 kB +dist/assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10.33 kB │ gzip: 3.61 kB +dist/assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10.64 kB │ gzip: 4.01 kB +dist/assets/diagram-VSXAHHWV-D-Jngocg.js 10.68 kB │ gzip: 4.11 kB +dist/assets/chunk-SVP7TREG-Xn5LeAbu.js 15.73 kB │ gzip: 4.41 kB +dist/assets/diagram-VX7I27RA-CqVb28LM.js 15.92 kB │ gzip: 5.68 kB +dist/assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17.43 kB │ gzip: 6.62 kB +dist/assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20.26 kB │ gzip: 7.18 kB +dist/assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23.14 kB │ gzip: 7.80 kB +dist/assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23.16 kB │ gzip: 8.54 kB +dist/assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23.54 kB │ gzip: 8.33 kB +dist/assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25.47 kB │ gzip: 6.77 kB +dist/assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29.38 kB │ gzip: 8.68 kB +dist/assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30.76 kB │ gzip: 10.57 kB +dist/assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30.79 kB │ gzip: 10.13 kB +dist/assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30.97 kB │ gzip: 9.71 kB +dist/assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33.98 kB │ gzip: 9.95 kB +dist/assets/layout-C2imKr5O.js 35.43 kB │ gzip: 12.74 kB +dist/assets/chunk-IMKFNOWR-C-Mq94A_.js 38.07 kB │ gzip: 12.51 kB +dist/assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41.72 kB │ gzip: 13.56 kB +dist/assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41.93 kB │ gzip: 15.67 kB +dist/assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44.47 kB │ gzip: 12.58 kB +dist/assets/chunk-TICWLB2K-yUyCyRlD.js 49.17 kB │ gzip: 15.74 kB +dist/assets/flowDiagram-HODETNUW-DwTSI7cN.js 61.84 kB │ gzip: 19.87 kB +dist/assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65.44 kB │ gzip: 18.77 kB +dist/assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69.66 kB │ gzip: 23.54 kB +dist/assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81.66 kB │ gzip: 22.46 kB +dist/assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115.76 kB │ gzip: 42.49 kB +dist/assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116.93 kB │ gzip: 31.11 kB +dist/assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151.79 kB │ gzip: 43.12 kB +dist/assets/katex-HP8lGamR.js 261.31 kB │ gzip: 77.57 kB +dist/assets/cytoscape.esm-BB4DxJjf.js 443.04 kB │ gzip: 141.98 kB +dist/assets/cynefin-OW5HDTMX-Dit8THDr.js 687.91 kB │ gzip: 154.14 kB +dist/assets/index-DhyNmxJE.js 2,829.14 kB │ gzip: 805.59 kB + +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +✓ built in 7.59s +precompress: 49 candidate(s) under /home/weichen/projects/Agent-System/web/dist + br assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 798 (−55.0%) + gzip assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 872 (−50.8%) + br assets/arc-DzBaewGB.js 3434 → 1285 (−62.6%) + gzip assets/arc-DzBaewGB.js 3434 → 1449 (−57.8%) + br assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151788 → 36902 (−75.7%) + gzip assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151788 → 42819 (−71.8%) + br assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41722 → 11938 (−71.4%) + gzip assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41722 → 13449 (−67.8%) + br assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65438 → 15174 (−76.8%) + gzip assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65438 → 18662 (−71.5%) + br assets/chunk-F27PBJKO-usLA3fwD.js 1876 → 730 (−61.1%) + gzip assets/chunk-F27PBJKO-usLA3fwD.js 1876 → 828 (−55.9%) + br assets/chunk-IMKFNOWR-C-Mq94A_.js 38069 → 10963 (−71.2%) + gzip assets/chunk-IMKFNOWR-C-Mq94A_.js 38069 → 12427 (−67.4%) + br assets/chunk-SVP7TREG-Xn5LeAbu.js 15728 → 3959 (−74.8%) + gzip assets/chunk-SVP7TREG-Xn5LeAbu.js 15728 → 4413 (−71.9%) + br assets/chunk-TICWLB2K-yUyCyRlD.js 49171 → 13023 (−73.5%) + gzip assets/chunk-TICWLB2K-yUyCyRlD.js 49171 → 15643 (−68.2%) + br assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81662 → 19567 (−76.0%) + gzip assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81662 → 22373 (−72.6%) + br assets/cynefin-OW5HDTMX-Dit8THDr.js 687914 → 124585 (−81.9%) + gzip assets/cynefin-OW5HDTMX-Dit8THDr.js 687914 → 153069 (−77.7%) + br assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10062 → 3233 (−67.9%) + gzip assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10062 → 3598 (−64.2%) + br assets/cytoscape.esm-BB4DxJjf.js 443036 → 119602 (−73.0%) + gzip assets/cytoscape.esm-BB4DxJjf.js 443036 → 141741 (−68.0%) + br assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10641 → 3659 (−65.6%) + gzip assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10641 → 4024 (−62.2%) + br assets/defaultLocale-DX6XiGOO.js 4688 → 2012 (−57.1%) + gzip assets/defaultLocale-DX6XiGOO.js 4688 → 2166 (−53.8%) + br assets/diagram-S7CK7UJ4-yML3LoUw.js 8182 → 3307 (−59.6%) + gzip assets/diagram-S7CK7UJ4-yML3LoUw.js 8182 → 3662 (−55.2%) + br assets/diagram-UQ7AKVKN-RZXoXHTp.js 6044 → 2343 (−61.2%) + gzip assets/diagram-UQ7AKVKN-RZXoXHTp.js 6044 → 2596 (−57.0%) + br assets/diagram-VSXAHHWV-D-Jngocg.js 10681 → 3764 (−64.8%) + gzip assets/diagram-VSXAHHWV-D-Jngocg.js 10681 → 4132 (−61.3%) + br assets/diagram-VX7I27RA-CqVb28LM.js 15922 → 5130 (−67.8%) + gzip assets/diagram-VX7I27RA-CqVb28LM.js 15922 → 5689 (−64.3%) + br assets/diagram-Z3DM3KII-DaRdRaxA.js 4176 → 1626 (−61.1%) + gzip assets/diagram-Z3DM3KII-DaRdRaxA.js 4176 → 1800 (−56.9%) + br assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1970 → 784 (−60.2%) + gzip assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1970 → 862 (−56.2%) + br assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30755 → 9152 (−70.2%) + gzip assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30755 → 10522 (−65.8%) + br assets/flowDiagram-HODETNUW-DwTSI7cN.js 61840 → 16342 (−73.6%) + gzip assets/flowDiagram-HODETNUW-DwTSI7cN.js 61840 → 19714 (−68.1%) + br assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69656 → 20654 (−70.3%) + gzip assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69656 → 23405 (−66.4%) + br assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29382 → 7766 (−73.6%) + gzip assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29382 → 8676 (−70.5%) + br assets/index-BYHTgAJJ.css 36824 → 8557 (−76.8%) + gzip assets/index-BYHTgAJJ.css 36824 → 9952 (−73.0%) + br assets/index-DhyNmxJE.js 2829141 → 625981 (−77.9%) + gzip assets/index-DhyNmxJE.js 2829141 → 798236 (−71.8%) + br assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17434 → 5995 (−65.6%) + gzip assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17434 → 6612 (−62.1%) + br assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23536 → 7492 (−68.2%) + gzip assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23536 → 8314 (−64.7%) + br assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20256 → 6416 (−68.3%) + gzip assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20256 → 7151 (−64.7%) + br assets/katex-HP8lGamR.js 261313 → 63694 (−75.6%) + gzip assets/katex-HP8lGamR.js 261313 → 77109 (−70.5%) + br assets/layout-C2imKr5O.js 35429 → 11552 (−67.4%) + gzip assets/layout-C2imKr5O.js 35429 → 12742 (−64.0%) + br assets/linear--qpe2Iaf.js 5654 → 2120 (−62.5%) + gzip assets/linear--qpe2Iaf.js 5654 → 2311 (−59.1%) + br assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23140 → 6990 (−69.8%) + gzip assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23140 → 7777 (−66.4%) + br assets/ordinal-Cboi1Yqb.js 1189 → 497 (−58.2%) + gzip assets/ordinal-Cboi1Yqb.js 1189 → 574 (−51.7%) + br assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1921 → 803 (−58.2%) + gzip assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1921 → 904 (−52.9%) + br assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6259 → 2379 (−62.0%) + gzip assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6259 → 2608 (−58.3%) + br assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33975 → 8492 (−75.0%) + gzip assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33975 → 9854 (−71.0%) + br assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1571 → 663 (−57.8%) + gzip assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1571 → 734 (−53.3%) + br assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30965 → 8236 (−73.4%) + gzip assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30965 → 9638 (−68.9%) + br assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23163 → 7806 (−66.3%) + gzip assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23163 → 8545 (−63.1%) + br assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116927 → 25725 (−78.0%) + gzip assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116927 → 30934 (−73.5%) + br assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10326 → 3305 (−68.0%) + gzip assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10326 → 3606 (−65.1%) + br assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115756 → 36638 (−68.3%) + gzip assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115756 → 42149 (−63.6%) + br assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30794 → 9120 (−70.4%) + gzip assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30794 → 10124 (−67.1%) + br assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41926 → 13970 (−66.7%) + gzip assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41926 → 15620 (−62.7%) + br assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25467 → 6076 (−76.1%) + gzip assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25467 → 6733 (−73.6%) + br assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44474 → 11050 (−75.2%) + gzip assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44474 → 12519 (−71.9%) + br fonts/OFL.txt 4474 → 1437 (−67.9%) + gzip fonts/OFL.txt 4474 → 1980 (−55.7%) +precompress: wrote 49 .br and 49 .gz diff --git a/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/npm-test.txt b/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/npm-test.txt new file mode 100644 index 00000000..d26e2461 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave5-deadcode-decisions/npm-test.txt @@ -0,0 +1,516 @@ + +> shuimu-workbench@1.0.0 test +> tsx --test "src/**/!(*InteractiveCard*|*chatWindowArtifact*|*deliverableSync*).test.ts" && vitest run src/components/chat/InteractiveCard.test.ts src/components/chat/chatWindowArtifact.test.ts src/components/chat/deliverableSync.test.ts + +✔ AuthClient: 基础登录与 .env 超级管理员鉴权 (2.133367ms) +✔ AuthClient: 自主注册受控流程(仅填用户名密码姓名,职位强制绑定访客待指派) (0.566773ms) +✔ AuthClient: 开启注册需审批时,注册账号处于待审状态并禁止登录 (0.64474ms) +✔ AuthClient: 管理员创建账号必须在首次登录修改密码 (0.3182ms) +✔ mapUserAccount: snake_case Go contract → camelCase (3.0288ms) +✔ mapUserAccount: prefers snake_case when both present (0.246143ms) +✔ mapLoginResult + mapMeResult map nested user and top-level must_change_password (1.00316ms) +✔ mapUserAccountList maps admin users list (0.245342ms) +✔ AuthClient.login + fetchMe map real snake_case wire payloads (30.061187ms) +✔ shouldAutoRetryChat: disabled until idempotency (1.301892ms) +✔ shouldAutoRetryTask: transient only, max 2 (0.289761ms) +✔ shouldAutoRetryTask: document busy never retries (0.166445ms) +✔ shouldAutoRetry alias delegates to task path (0.199004ms) +✔ autoRetryDelayMs: 1s then 2s (0.160375ms) +✔ fallbackErrorClass: network → transient, workspace → resumable (0.182954ms) +✔ upsertChatSession surfaces safe INTERNAL envelope instead of raw JSON text (29.059697ms) +[sse] 忽略无法解析的帧: {not valid json SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2) + at JSON.parse () + at parseFrame (/home/weichen/projects/Agent-System/web/src/api/chatStream.ts:123:26) + at parseSSEChunk (/home/weichen/projects/Agent-System/web/src/api/chatStream.ts:147:20) + at TestContext. (/home/weichen/projects/Agent-System/web/src/api/chatStream.test.ts:41:31) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) +✔ parseSSEChunk: parses event and data lines (1.468046ms) +✔ parseSSEChunk: keeps incomplete frame in rest (0.287101ms) +✔ parseSSEChunk: flush parses final frame without trailing delimiter (0.213084ms) +✔ parseSSEChunk: ignores heartbeat comments and keeps last id (0.373858ms) +✔ parseSSEChunk: skips a single malformed frame and keeps parsing the next valid frame (4.605394ms) +▶ docgov lifecycle API client + ✔ claimDocument posts to /api/v1/docgov/claim with author role (29.178773ms) + ✔ submitForReview posts to /api/v1/docgov/submit-review (4.795558ms) + ✔ reviewDecision posts action and optional reason (1.0064ms) + ✔ approverDecision posts action and rollback_to (1.32506ms) + ✔ proposeModification posts to /api/v1/docgov/proposal (0.863795ms) + ✔ decideModificationProposal posts to /api/v1/docgov/proposal/decide (0.648451ms) +✔ docgov lifecycle API client (39.666403ms) +✔ updateFreeformDoc exposes CONCURRENCY_CONFLICT on 409 (29.458175ms) +✔ addProjectMember exposes structured ProjectSettingsApiError on 403 (28.434875ms) +✔ updateProjectMember surfaces safe INTERNAL message on 500 duty-sync failure (0.799906ms) +▶ projectSettingsError + ✔ keeps status, code, message and issues on PLAN_FROZEN (3.211875ms) + ✔ maps revision and concurrent conflicts to distinct recovery (0.375669ms) + ✔ joins validation issues but still keeps typed codes (0.244912ms) + ✔ prefers envelope code over legacy error field (0.170515ms) + ✔ unwraps nested business JSON inside VERSION_CONFLICT envelope (0.223584ms) + ✔ maps bare VERSION_CONFLICT envelope to retry_after_switch (0.139156ms) + ✔ retains the local draft when a late tab hits PLAN_FROZEN (0.141246ms) + ✔ prefers error_code over legacy bare error string (DOC_BUSY) (0.154446ms) +✔ projectSettingsError (6.560635ms) +✔ Project workflow types validation (1.182045ms) +✔ createTaskRun exposes DOC_BUSY detail without a retryable run (26.62498ms) +✔ parseSSEChunk tracks id seq for after_seq reconnect (0.34205ms) +✔ subscribeTaskRunEvents reconnects on EOF with advancing after_seq (1.808246ms) +✔ readTaskRunSSEOnce marks non-terminal EOF for reconnect (0.467346ms) +✔ subscribeTaskRunEvents fails fast on 401 without retrying (0.478205ms) +✔ subscribeTaskRunEvents fails fast on 404 without retrying (0.381409ms) +✔ buildTaskRequest: maps five project fields + task_key + user identity (1.085558ms) +✔ buildTaskRequest: omits optional fields when absent (0.228113ms) +✔ buildTaskRequest: includes materials when provided and non-empty (0.941982ms) +✔ buildResumeRequest: adds work_dir on top of task request (0.196504ms) +✔ buildTaskRequest: canonicalizes legacy doc-p doc_id (0.322401ms) +✔ buildTaskRequest: UI-05 pinned intake keeps task_key + doc_proj6109_intake (0.34661ms) +✔ buildResumeRequest: forwards add_steps when provided (0.194204ms) +✔ CategoryDock: 渲染顶部 🔔 未读提醒项及角标数字 (14.108511ms) +✔ CategoryDock: isAllReadCompleted 为 true 时渲染完成态提示 (11.12551ms) +✔ CategoryDock: 0 未读时不渲染红色未读角标 (4.135498ms) +✔ CategoryDock: 分类项包含依次跳转与遍历提示信息 (7.728221ms) +✔ InputArea keeps draft when send preflight fails (1.304392ms) +✔ InputArea clears draft only after accepted send (0.844385ms) +✔ InputArea shows thinking block message (0.213844ms) +✔ InputArea keeps draft when attachment registration fails (0.31741ms) +✔ isTemplateOnly 交付物应正确识别为初始模板展示态 (0.895844ms) +✔ 左侧栏鼠标严密绝对跟随与夹紧测试 (Left Rail Exact Pointer Following & Clamping) (0.695719ms) +✔ 右侧栏鼠标严密绝对跟随与反向位移测试 (Right Rail Exact Pointer Following & Inverted Delta) (0.141406ms) +✔ detects legacy mock and neuropulse/ten-projects ids (0.896604ms) +✔ detects colliding demo project ids (0.197314ms) +✔ applyRestoredFileRaw: 以 prev 为权威,只补丁缺失 fileRaw,不丢新增条目 (1.244303ms) +✔ applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用 (0.219584ms) +✔ 每人独立私聊:同一项目不同用户拥有不同 sessionId (0.955712ms) +✔ 左侧会话列表按当前用户过滤,不可见他人私聊 (0.288671ms) +✔ 消息严格归属各自私聊 session,无跨用户混聊 (1.960681ms) +✔ 切换用户时 resolve 到该用户同项目私聊会话 (0.272712ms) +✔ resolve 优先有消息的私聊,跳过空壳 sess-p{N}-u{id} (0.368869ms) +✔ ensurePrivateSessionsForUser 为缺失用户补齐空私聊 (0.523965ms) +✔ 多项目会话消息隔离测试仍成立(endo vs ultrasound) (0.271302ms) +✔ window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤儿条目 (4.70933ms) +✔ window cache: 读写同一 userId 前缀,跨用户不串读 (0.290791ms) +✔ parseAnchorHash: 正确解析标准深链 hash (1.728019ms) +✔ formatAnchorHash: 正确构造深链格式 (0.171455ms) +✔ navigateToAnchor: 快路径直接命中 DOM 节点 (0.415668ms) +✔ navigateToAnchor: 慢路径拉取分片后命中 (80.445082ms) +✔ resolveTurnAnchorId: 方案 A 轮次归一化测试 (1.319631ms) +✔ 全链路场景 1: 用户自主受控注册(仅填用户名/密码/姓名,职位严格绑定访客待指派) (1.70836ms) +✔ 全链路场景 2: 系统准入策略(关闭自主注册与开启强制审批流转) (1.01094ms) +✔ 全链路场景 3: 管理员开通账号首次强制改密闭环 (0.332621ms) +✔ 全链路场景 4: 环境标识随登录会话返回(dev/test/main) (1.34228ms) +✔ cleanHeadingContent: 手工多级序号前缀清洗与专业词汇保护 (1.149606ms) +✔ 标题多层级自动编号:单 H1 文档总标题自适应模式 (1 -> 1.1 -> 1.1.1) (0.561403ms) +✔ 标题多层级自动编号:多 H1 标准大章模式 (0.188184ms) +✔ 跨章节图、表、公式多级按章重编号测试 (表 1-1, 表 2-1, 图 3-1, 式 (4-1)) (0.153726ms) +✔ 有序列表与无序列表编号与渲染功能测试 (2.176245ms) +✔ 动态块操作闭环重编号验证 (Insert, Move, Delete) (0.255153ms) +✔ findMatchingBlockForReview: 能够精准按审查关键词(电气安全/EMC/竞品)定位段落块 (0.862065ms) +✔ findMatchingBlockForReview: 能够按外部指定 hint 线索精确命中目标块 (0.880684ms) +✔ createRejectConfirmationCard: 生成合规危险级驳回门禁卡片 (0.532534ms) +✔ isAffirmativeResponse: 准确识别双轨交互中的肯定意图输入 (0.186954ms) +✔ findLastActiveCard: 正确在会话历史中提取最新的待决策卡片 (0.160155ms) +✔ decrementActiveCardsTtl: 多轮对话超时递减与自动失效归档 (0.135016ms) +✔ applyConfirmRejectToDoc & applyUndoRejectToDoc: 驳回挂载批注与60s窗口期撤销完整闭环 (0.260713ms) +✔ resolveCardInMessages & cancelCardInMessages: 消息流中卡片终态沉淀 (0.186915ms) +✔ classifyRejectUtterance: 询问原因 → QUERY_REJECT_REASON (1.979941ms) +✔ classifyRejectUtterance: 明确执行 → EXECUTE_REJECT (0.167865ms) +✔ classifyRejectUtterance: 模糊 → OTHER (0.230323ms) +✔ resolveDocGovCard: 编制人查询 → 信息卡且无 confirm_reject (0.806726ms) +✔ resolveDocGovCard: 审核人执行 + reviewing → 确认卡 (0.285162ms) +✔ resolveDocGovCard: 编制人误说执行驳回 → 无权纯文本 (0.133506ms) +✔ resolveDocGovCard: 批准人 + approving → 终审确认卡 (0.31832ms) +✔ resolveDocGovCard: 编制人 OTHER → 引导查询无危险按钮 (0.112437ms) +✔ canConfirmReject: 仅审核/批准 (0.142306ms) +✔ deriveFallbackCategory identifies categories correctly (0.964621ms) +✔ enrichMessagesWithCategories backfills categories without overwriting existing ones (0.367539ms) +✔ Markdown ➔ Block AST 解析测试 (标题自适应编号与图表公式全链路) (2.852725ms) +✔ 动态重编号引擎测试 (多 H1 规范章节重排验证) (0.34643ms) +✔ Block AST ➔ Markdown 双向无损往返还原测试 (0.487736ms) +✔ 三层交付物多用户隐私绝对隔离测试 (Multi-user Strict Privacy Isolation) (1.03766ms) +✔ 一键共享至项目参考库流转测试 (Promote to Shared Reference Test) (0.199674ms) +✔ 撤回共享恢复私有草稿测试 (Revoke Shared Reference Test) (0.376889ms) +✔ 受控工作流纯净性红线检验 (Workflow Purity Security Red Line) (0.135206ms) +✔ isSameMessage: clientTurnId match (1.069979ms) +✔ isSameMessage: serverId match without turn (0.254583ms) +✔ filterPendingLiveMessages: real temp vs server id (0.255533ms) +✔ mergeLiveOverHistory preserves server id from live (0.261122ms) +✔ applyPhaseEvent: appends first phase as running node (1.227643ms) +✔ applyPhaseEvent: completes predecessors when a new phase arrives (0.235553ms) +✔ applyPhaseEvent: same phase refreshes label in place (no duplicate node) (0.256192ms) +✔ applyPhaseEvent: unknown phase falls back to hourglass meta (0.165575ms) +✔ applyPhaseEvent: prefers backend message over short label (1.00684ms) +✔ applyPhaseEvent: step_completed closes running entry without appending (0.220524ms) +✔ applyPhaseEvent: stage_completed closes entries (0.246493ms) +✔ applyPhaseEvent: task_failed appends warning node (0.160845ms) +✔ applyPhaseEvent: decorates label with step index / total steps (0.170125ms) +✔ applyPhaseEvent: repeated step_executing with step_index gets separate nodes (0.210104ms) +✔ applyPhaseEvent: ignores event without phase payload (0.105647ms) +✔ applyPhaseEvent: step_failed marks running entry as failed (keeps step label) (0.155286ms) +✔ applyPhaseEvent: step_failed appends failed node when no running entry exists (0.806356ms) +✔ applyPhaseEvent: retry of a failed step resets it to running (0.142056ms) +✔ PLAN_CONFIRM 卡片来自 PlanView 且不触达 AST (1.171055ms) +✔ 409 冲突把卡片标为 outdated 并换上最新 revision (0.261942ms) +✔ GET reconcile 用权威 PlanView 覆盖本地 executing,不假装成功 (0.254153ms) +✔ 过期计划映射 expired (0.135756ms) +✔ 非计划卡不被误判 (0.120206ms) +✔ 集成验证:项目切换时交付物严格隔离,新建项目即刻包含 7 份模板副本 (5.541715ms) +✔ 集成验证:三层隐私与项目物理隔离双重约束 (1.67508ms) +✔ 用例 1: DEFAULT_ROLES 包含 5 种默认角色且具备预期权限 (1.02325ms) +✔ 用例 2: 系统管理员 (ADMIN) 与具备 perm:manage 用户的通配豁免特权 (0.218684ms) +✔ 用例 3: 常规工作流基线可见性 (workflow:view_all) 判定 (0.272901ms) +✔ 用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截) (2.114047ms) +✔ 用例 5: 单文档覆盖 - role_whitelist (仅白名单内角色可见,其他角色被精确拦截) (0.328901ms) +✔ 用例 6: 单文档覆盖 - public (放宽可见性,无 workflow:view_all 权限的访客也能查阅) (0.143076ms) +✔ 用例 7: 私密文档隔离强红线 (category === private 无论任何管理员或单文档覆盖,非创建者绝对不可见) (0.265342ms) +✔ 用例 8: 核心业务动作权限拦截判定 (canPerformDocAction & hasPermission) (0.183335ms) +✔ 用例 9: filterVisibleDeliverablesWithRBAC 能够批处理过滤大盘交付物 (1.34669ms) +✔ 集成场景 1: 动态新增合规审计员角色,并配置其仅能查阅特定单文档覆盖 (1.331621ms) +✔ 集成场景 2: 服务端 capabilities 变更后权限即刻生效 (0.229343ms) +✔ 集成场景 3: 单文档覆盖 designated_only 严格收紧与撤回恢复全流程 (0.407608ms) +✔ renderInlineMarkdown: renders bold, italic, strikethrough (1.054229ms) +✔ renderInlineMarkdown: escapes raw HTML tags to prevent XSS (0.447167ms) +✔ renderInlineMarkdown: blocks javascript: link hrefs (0.150066ms) +✔ renderInlineMarkdown: renders inline code and preserved line breaks (0.263912ms) +✔ renderInlineMarkdown: renders headings lists and tables for chat replies (0.504775ms) +✔ sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators (0.285662ms) +(node:442565) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. +(Use `node --trace-warnings ...` to show where the warning was created) +✔ DOCX 渲染器行内文本与公式解析测试 (parseInlineContent) (4.858686ms) +✔ DOCX 渲染器 LaTeX 原生 OMML 转换引擎测试 (latexToOmml) (1.933843ms) +✔ DOCX 渲染器 Mermaid 语法净化与容错测试 (sanitizeMermaidCode) (0.256492ms) +✔ DOCX 渲染器 SVG 净化防 Canvas 污染测试 (cleanSvgForCanvas) (0.32988ms) +✔ DOCX 渲染器全量编译测试 (buildDocxModel & exportDocumentToDocx) (56.318598ms) +✔ XLSX 渲染器多表提取与工作簿编译测试 (buildXlsxWorkbook & exportDocumentToXlsx) (6.100099ms) +▶ Reply Document IR & Export Engine + ✔ deriveReplyTitle and sanitizeFilename derive title and safe file name (1.03846ms) + ✔ deriveReplyTitle handles text without heading (0.255212ms) + ✔ parseReplyMarkdown parses headers, lists, code, and tables (1.447077ms) + ✔ renderClipboardHtml renders self-contained styling for office tools (0.448417ms) + ✔ renderClipboardHtml converts LaTeX formulas to native MathML for Word/Office (2.941853ms) + ✔ renderPlainText strips formatting cleanly (0.474056ms) + ✔ serializeMarkdown round-trips to valid markdown (0.368019ms) + ✔ stripUnsafeHtml strips script tags and dangerous event handlers (0.407498ms) +✔ Reply Document IR & Export Engine (8.740511ms) +✔ createProjectTemplateDeliverables: 必须精确生成 7 份全生命周期受控文档副本 (6.294284ms) +✔ resolveRequirementsTaskKey prefers intake for 初始需求表 (1.105478ms) +✔ resolveRequirementsTaskKey selects user_requirements for explicit URS (0.168585ms) +✔ UI-02 first-round prompt with 首轮+用户需求+摸底 must be intake not user_requirements (0.616561ms) +✔ 首轮 / 需求摸底 / 摸底 beat bare 用户需求 (0.123236ms) +✔ bare 用户需求 without V0.1 markers stays user_requirements (0.092537ms) +✔ matchTemplateDeliverable uses intake key when pool only has URS template (0.129236ms) +✔ resolveTaskTemplateForMessage keeps intake key with URS-only pool (0.139596ms) +✔ matchTemplateDeliverable prefers intake when both templates exist (0.109956ms) +✔ read-only request does not start a document task: 总结一下用户初始需求表 (0.101807ms) +✔ read-only request does not start a document task: 查看用户初始需求表 (0.131446ms) +✔ read-only request does not start a document task: 用户初始需求表现在谁在编辑 (0.082077ms) +✔ read-only request does not start a document task: 分析一下用户需求表里的风险 (0.056588ms) +✔ read-only request does not start a document task: 用户初始需求表 (0.054759ms) +✔ explicit write action still starts intake (0.088698ms) +✔ explicit update action still starts URS (0.064458ms) +✔ UI-05 prompt without active doc still keyword-routes to feasibility_reg (0.104627ms) +✔ UI-05 prompt + registered intake pins intake and doc_proj6109_intake (0.247803ms) +✔ read-only current-doc request does not start a task (0.082688ms) +✔ template-only active slot is not pinned; keywords still apply (0.082097ms) +✔ explicit named deliverable can switch away from active intake (0.218433ms) +✔ domain 注册法规 without current-doc deixis still routes feasibility_reg (0.063428ms) +✔ mismatched title vs docId suffix prefers docId taskKey (0.073868ms) +✔ deixis 当前文档 pins registered intake (0.072117ms) +✔ deixis 本文件 pins registered intake (0.039049ms) +✔ deixis 这份文档 pins registered intake (0.0306ms) +✔ deixis 本文档 pins registered intake (0.030609ms) +✔ deixis 本受控文档 pins registered intake (0.035109ms) +✔ no active doc does not invent a pin (0.069898ms) +✔ UI-11 named 系统设计输入 beats negated 需求 background (0.052618ms) +✔ mixed generate-design + do-not-cover-requirements stays prelim_design (0.039309ms) +✔ current-doc pin still wins when no other deliverable is named (0.046759ms) +▶ document revision overlay modes + ✔ pure hides all decorations (0.880784ms) + ✔ manual only marks human hunks (0.158156ms) + ✔ all marks human and agent differently (0.104947ms) + ✔ same human hunk stays amber in manual and all (no pairwise style guarantee) (0.167525ms) + ✔ exposes needs-content error code for empty orphan docs (0.100117ms) + ✔ matches overlay hunks by section or text when ids differ (0.289151ms) + ✔ aligns client block ids from head_block_map by order (0.239573ms) +✔ document revision overlay modes (3.268573ms) +▶ toolbar refresh + report entry reachability + ✔ pickLatestChangeReportId prefers highest version with report (0.349869ms) + ✔ first squad run alone still exposes a reachable report id (0.180424ms) + ✔ second squad run keeps both history report entries and latest shortcut (0.228394ms) + ✔ revisionRefreshKey is an independent refresh signal from docId (0.137756ms) +✔ toolbar refresh + report entry reachability (1.223624ms) +▶ change report view model + ✔ surfaces risk items, project impact, doc impacts with advice, and open questions (0.665951ms) + ✔ marks complete analysis as PI narrative vs factual fields (0.259053ms) + ✔ preferReportId prioritizes pending over committed latest (0.114127ms) +✔ change report view model (1.217724ms) +▶ revisionToolbarMount + ✔ exposes clear non-error template preview hint copy (0.579292ms) + ✔ template doc-p + isTemplateOnly=true does not mount (0.140756ms) + ✔ legacy doc-001 does not mount (0.139306ms) + ✔ unregistered doc-upload orphan does not mount (must register first) (0.129796ms) + ✔ server-registered upload (doc-free) and freeform mount (0.163895ms) + ✔ canonical doc_proj and generated non-template doc-p mount (0.102297ms) +✔ revisionToolbarMount (3.055339ms) +✔ useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId (1.204544ms) +✔ window fetch race: stale response ignored by generation (1.35847ms) +✔ mapRecordToMessage: 正确映射服务端消息记录为前端 ChatMessage (1.599323ms) +✔ refresh window: 空 messages 日桶不算有效窗口 (0.200014ms) +✔ mapRawBucket: 正确映射服务端按日分片桶 (0.691669ms) +✔ Category navigation: chip 选中后准确匹配目标分类最新轮次并触发归一化定位 (0.290012ms) +✔ turn runtime: session A stream survives session B switch (0.959152ms) +✔ abortAllSessions aborts controllers across all sessions (0.307491ms) +✔ syncAdminUserDirectories calls local reload then parent onRefreshUsers (1.726478ms) +✔ syncAdminUserDirectories fails closed when onRefreshUsers is a dead/missing prop (0.695379ms) +✔ syncAdminUserDirectories surfaces parent refresh errors (no silent swallow) (0.288862ms) +✔ afterProjectMemberPersisted syncs members then refreshes workflow docs (1.274302ms) +✔ afterProjectMemberPersisted does not refresh when member sync fails (0.416957ms) +▶ blockEditorDialogPolicy + ✔ pins an accessible dialog label and exact save name (0.856915ms) + ✔ rejects page-wide fuzzy /保存/ that would hit private-library buttons (0.355239ms) +✔ blockEditorDialogPolicy (2.452337ms) +▶ resolveSaveTargetBlock + ✔ keeps the editor and does not fake success when the block id is gone (0.69134ms) + ✔ never retargets another block that happens to share the same content (0.859235ms) +✔ resolveSaveTargetBlock (2.641202ms) +▶ editor session document binding + ✔ fail-closes when activeDocId drifts away from the opened editor doc (0.255842ms) + ✔ accepts a save only when the editor is still bound to the active doc (0.158825ms) + ✔ blocks switching documents while the editor is open (0.173725ms) +✔ editor session document binding (0.98624ms) +▶ interpretBlockSaveError + ✔ 409 keeps the draft in the editor (0.223793ms) + ✔ other PATCH failures also keep the editor (4.524666ms) +✔ interpretBlockSaveError (5.0373ms) +▶ blockedGovernanceCopy + ✔ recognizes blocked run status and fixed hint, not generation success (0.713689ms) +✔ blockedGovernanceCopy (1.562914ms) +▶ commentPolicy + ✔ lets author/reviewer/approver annotate and keeps their comment role (0.741708ms) + ✔ keeps viewer read-only: no add entry and no reviewer/approver cast (0.134636ms) +✔ commentPolicy (1.954861ms) +▶ controlledContentInvalidation + ✔ hydrate success then late list_sync keeps cache/ready and does not bump resume (1.151666ms) + ✔ post_task invalidation drops active cache and requires a new hydrate epoch (0.177284ms) + ✔ editor open / save epoch keeps draft and rejects a stale GET after PATCH (0.327711ms) + ✔ never drops the active doc without signaling rehydrate (0.073408ms) + ✔ candidate helper keeps template / doc-001 / freeform / upload out of wipe lists (0.310821ms) + ✔ dropRecordKeys is a no-op when ids are absent (0.099687ms) +✔ controlledContentInvalidation (2.981431ms) +▶ controlledDocHydrate + ✔ shouldLoadControlledContent when non-template workflow doc (0.908313ms) + ✔ shouldLoadControlledContent rejects local legacy/mock ids like doc-001 (0.132726ms) + ✔ shouldLoadControlledContent keeps canonical, freeform-off, and template preview rules (0.118116ms) + ✔ hydrateControlledDocumentContent never requests content/revision for doc-001 (0.285842ms) + ✔ hydrateControlledDocumentContent never requests for template preview or registered freeform (0.189844ms) + ✔ shouldLoadControlledContent stays inside toolbar server-backed eligibility (0.243203ms) + ✔ resolveCanonicalDocId maps doc-p56-intake to doc_proj56_intake (0.298491ms) + ✔ canonicalDocIdForTask maps doc-p56-research to doc_proj56_research (0.173705ms) + ✔ hydrate treats projection missing as ledger content, not 404 (1.219373ms) + ✔ attaches hydrate ETag as PATCH baseline and ignores a later GET (0.762538ms) + ✔ rejects hydrate when revision-state network or 5xx fails (3.787338ms) + ✔ aligns markdownToBlockAST random ids onto revision-state head_block_map (1.540194ms) + ✔ leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain) (0.493745ms) + ✔ rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT (0.35913ms) + ✔ fingerprint stays stable when only object identity changes (0.134816ms) + ✔ dedupes in-flight hydrate for the same doc fingerprint (0.306921ms) + ✔ aligned late hydrate still cannot overwrite a PATCH-success baseline (0.372389ms) +✔ controlledDocHydrate (12.7879ms) +✔ createClientTurnId uses randomUUID when available (0.871035ms) +✔ createClientTurnId falls back to getRandomValues UUID v4 (0.549034ms) +✔ createClientTurnId throws when no secure random API exists (0.700279ms) +✔ selectNonCreatorInitialMembers skips creator only (1.641371ms) +✔ buildManagedMemberUpsertPayload sends document_duty on add/update/restore (0.308001ms) +✔ buildInitialMemberUpsertPayload maps reviewer UI role to member API role (0.204814ms) +✔ persistInitialProjectMembers awaits each add and stops on failure (2.01429ms) +✔ ensureCreatedProjectWithMembers skips recreate on bootstrap id and retries members (0.539254ms) +✔ beginCreateProjectSubmit rejects concurrent second entry until end (31.499155ms) +✔ resolveCreateProjectSubmitPhase uses bootstrap to choose syncing label (0.436717ms) +✔ createProjectSubmitButtonLabel and close block while submitting (0.551424ms) +✔ shouldCloneMedicalTemplates skips the 7-pack for software domain (0.149046ms) +▶ resolveCurrentDocRole + ✔ resolves A as author by authoritative user id (0.900363ms) + ✔ does not promote reviewer B or stranger to author (0.193924ms) + ✔ resolves real USER_B id as reviewer and never name-promotes placeholder 2 (0.158135ms) + ✔ fail-closes unknown identity to viewer even if projectPermission says author (0.134816ms) + ✔ matches name only for legacy local docs without duty ids (0.122656ms) + ✔ does not name-promote when server controlled duties are zero ids (0.195344ms) +✔ resolveCurrentDocRole (4.133298ms) +▶ currentProjectRestore + ✔ rejects empty project names (0.850794ms) + ✔ rejects page-wide project name when banner and sidebar both match (0.232943ms) + ✔ scopes the visible entry to complementary exact title (0.650211ms) + ✔ scopes the current project assertion to banner exact text (0.195434ms) +✔ currentProjectRestore (2.890154ms) +▶ deliverableLeaseSync + ✔ returns the same array reference when leases are unchanged (0.980741ms) + ✔ returns a new array only when a lease actually changes (0.251062ms) +✔ deliverableLeaseSync (2.455357ms) +▶ docGovActionVisibility + ✔ hides all approval actions on unmanaged docs (0.66075ms) + ✔ gates separated duties by user id, not folded role label (0.189615ms) + ✔ author_only: same user holds all three duties and sees all actions (0.130617ms) + ✔ author_only: non-duty user sees no actions (0.109637ms) +✔ docGovActionVisibility (2.058909ms) +▶ documentBlockDomAttrs + ✔ marks a real DocumentBlock in pure mode without overlay kind (0.535855ms) + ✔ keeps the same stable attrs when overlay is later attached separately (0.193125ms) +✔ documentBlockDomAttrs (1.487596ms) +▶ durableBodySnippet + ✔ rejects empty persisted snippets (1.205554ms) + ✔ lets a later successful revision replace the old snippet (0.229833ms) + ✔ does not promote a conflict-failed draft to the expected body (0.159406ms) +✔ durableBodySnippet (3.512086ms) +▶ dutyCellAssertion + ✔ parses author, reviewer, and viewer from select value or badge role (1.073568ms) + ✔ must not infer author from a row whose option labels include 编制负责人 (0.538474ms) + ✔ accepts only a disabled non-assignable reviewer select (0.178185ms) + ✔ rejects an enabled reviewer select when the reviewer cannot assign leads (0.168145ms) + ✔ rejects canAssignLead=true even if the reviewer select is disabled (0.131186ms) + ✔ rejects viewer, author, approver, and unknown values for USER_B (0.250203ms) +✔ dutyCellAssertion (3.69673ms) +▶ decideHydrationApply + ✔ applies only when gen, doc, save epoch match and editor is closed (1.490336ms) + ✔ rejects a response for another document (0.189915ms) + ✔ rejects a superseded generation (0.184564ms) +✔ decideHydrationApply (3.098188ms) +▶ createEditorHydrationGate + ✔ does not start a new hydrate while the editor is open (UI-06 fill-window GET) (0.467236ms) + ✔ deferred hydrate that resolves after the editor opens does not replace the session (1.636581ms) + ✔ covers hydrate resolving between fill and save (0.272002ms) + ✔ asks for a hydrate resume after editor_open blocked the first apply (0.202534ms) + ✔ asks for a hydrate resume when beginHydration is skipped while editing (0.145156ms) + ✔ requestHydrationResume is consumed once after a protected edit session (0.123936ms) + ✔ late hydrate after a successful PATCH does not roll back the new baseline (0.229793ms) +✔ createEditorHydrationGate (3.71626ms) +✔ mergeSessionUser prefers session identity over stale catalog entry (1.34718ms) +▶ playwrightPhaseArtifacts + ✔ defaults unknown or empty UI_PHASE to main (0.863244ms) + ✔ keeps post-restart distinct from main so the second run cannot overwrite traces (0.241533ms) + ✔ keeps main results at the historical filename and isolates post-restart recordPass (0.247733ms) + ✔ shares phase-state and secrets across phases (0.089228ms) +✔ playwrightPhaseArtifacts (2.624352ms) +✔ resolvePostTaskActiveDocId prefers server doc_proj for taskKey (0.727278ms) +✔ resolvePostTaskActiveDocId honors preferred server doc id (0.160215ms) +✔ shouldSwitchTemplateSlotToServer when active is local template slot (0.362119ms) +✔ pollUntilPostTaskServerDoc retries until same taskKey server doc appears (0.32802ms) +✔ pollUntilPostTaskServerDoc returns null after maxAttempts without relying on setState (0.241713ms) +✔ mapServerProjectToProjectInfo maps creator and preserves local members (0.808116ms) +✔ mergeProjectLists drops local-only demo seed projects by default (0.351299ms) +✔ mergeProjectLists can retain local-only demo when explicitly opted in (0.211873ms) +✔ documentDutyFromMemberRole maps UI reviewer and creator (0.944061ms) +✔ exclusiveMemberRoleChange keeps a single reviewer (0.211394ms) +✔ memberRoleFromDocumentDuty does not invent reviewer from access role (0.232983ms) +✔ toApiProjectRole maps doc roles to member and creator to owner (0.844425ms) +✔ fromApiProjectRole maps owner to creator for project creator (0.183064ms) +✔ mapApiMemberToProjectMember builds UI record (0.369479ms) +▶ projectSettingsDuties + ✔ toggles full to none by atomically clearing duties (1.400778ms) + ✔ blocks partial saves and locates required/governance issue codes (0.370379ms) + ✔ allows an all-none configurable draft without three members (0.149776ms) + ✔ requires three members once any document is managed (0.176795ms) + ✔ resolves author_only to A/A/A without three-member requirement (0.552783ms) + ✔ rejects author_only without author or with conflicting reviewers (0.205514ms) + ✔ mixed full + author_only still requires three members for the full doc (0.239813ms) + ✔ toggleAuthorOnly clears review/approve slots and restores on disable (0.176235ms) +✔ projectSettingsDuties (5.525156ms) +▶ projectSettingsMode + ✔ enters frozen when can_edit_plan is false or the project is active (0.630921ms) + ✔ stays planning only for an editable draft (0.116687ms) + ✔ lists enabled duties for the frozen snapshot and skips disabled tasks (0.67543ms) + ✔ keeps the local draft after PLAN_FROZEN refresh and switches to frozen (0.119567ms) +✔ projectSettingsMode (2.439987ms) +▶ revisionEtag + ✔ uses stale hydrate ETag for PATCH and never the latest GET (0.612872ms) + ✔ does not invent 0:0 when controlled hydrate misses etag or errors (0.142166ms) + ✔ refuses controlled save without a hydrate ETag (0.365619ms) + ✔ allows freeform bootstrap 0:0 only (0.084067ms) +✔ revisionEtag (2.076818ms) +▶ saveControlledBlockEdit + ✔ successful save calls PATCH once with bound docId, hydrate ETag, body and client_block_map (2.308481ms) + ✔ does not PATCH when editorDocId and activeDocId diverge (0.220834ms) + ✔ does not PATCH on stale block id and keeps the draft (0.204274ms) + ✔ 409 does not close the editor or drop the draft (0.719039ms) + ✔ network errors do not close the editor or drop the draft (0.294271ms) +✔ saveControlledBlockEdit (4.899045ms) +✔ parseProjectIdFromSessionId decodes sess-p{id}-u{user} (0.870885ms) +✔ resolveSessionProjectId prefers explicit id then session id encoding (0.180025ms) +✔ mapServerSessionToProjectSession fills project metadata (0.566063ms) +✔ mergeSessionLists keeps local stage while applying server counts (0.217224ms) +▶ squadCompletionObservation + ✔ accepts a directly completed adjacent assistant without requiring inflight (0.739148ms) + ✔ accepts a turn that passed through inflight then completed (0.205684ms) + ✔ rejects error terminals, missing correspondence, and visible errors (0.124586ms) + ✔ keeps waiting while still inflight and does not treat inflight as success (0.113337ms) +✔ squadCompletionObservation (2.299321ms) +✔ resolveReconnectRunId prefers explicit opts and ignores attempt without opts (0.646301ms) +✔ shouldSubscribeExistingRun only for in-flight statuses (0.151046ms) +✔ isRemoteTaskTerminal covers confirmation and quality_failed (0.094168ms) +✔ nextAttemptOpts carries retained runId for reconnect without new POST (0.091117ms) +▶ uploadFreeformRegister + ✔ buildUploadClientKey prefers explicit hint then doc-upload stamp (0.597952ms) + ✔ registerUploadAttachments hard-fails without creating orphan on API error (12.772321ms) + ✔ registerUploadAttachments rejects local doc-upload id from server (0.299341ms) + ✔ registerUploadAttachments returns server doc-free ids and freeform facts (0.417027ms) + ✔ deliverableFromFreeformUpload maps server id and upload facts (0.140195ms) +✔ uploadFreeformRegister (15.191758ms) +▶ workflowCategoryNavigation + ✔ private menu cannot see doc_proj* so it must switch to workflow before exact select (1.071378ms) + ✔ fail-closes when the workflow menu is showing but the exact doc is missing (0.30882ms) + ✔ label and taskKey opens always require the workflow category (0.110247ms) +✔ workflowCategoryNavigation (2.319251ms) +▶ no external Google Fonts on login shell + ✔ index.html has no fonts.googleapis / fonts.gstatic (0.632041ms) + ✔ styles reference self-hosted fonts.css (0.262622ms) +✔ no external Google Fonts on login shell (1.630742ms) +▶ 用户上传文档多格式在线预览体系与渲染分流测试 + ✔ 1. 上传附件时正确注入 sourceType: "uploaded_file" 与完整 fileMeta 元数据 (0.597192ms) + ✔ 2. 扩展名分类规则准确分发至对应的高保真渲染器类型 (0.164615ms) + ✔ 3. 受控工作流交付物严格隔离:绝不命中 FilePreviewPanel,保持 BlockRenderer 纯净 (0.135666ms) + ✔ 4. 上传文档支持受控流转:从个人私密一键共享至项目参考库后,在线预览依然可用且权限标态正确 (0.123146ms) +✔ 用户上传文档多格式在线预览体系与渲染分流测试 (1.773707ms) +▶ 项目立项、成员职责分配与退出留痕治理测试套件 + ✔ 1. 默认项目库包含完整元数据与法定三权成员 (active 状态) (0.508425ms) + ✔ 2. 跨用户会话可见性严格隔离:未加入成员完全不可见 (0.185615ms) + ✔ 3. 创建者添加新成员并分配职责后,新成员即刻可见该项目会话 (0.118767ms) + ✔ 4. 中途加入后期退出 (exited):保留项目会话但标记已退出只读留痕 (0.113626ms) + ✔ 5. 超级管理员 (ADMIN) 享有全量通配可见与管理兜底特权 (0.090528ms) +✔ 项目立项、成员职责分配与退出留痕治理测试套件 (1.956352ms) +▶ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) + ✔ 1. 必须精确生成 7 份官方全流程受控工作流模板且标记纯净 (0.496545ms) + ✔ 2. 杜绝旧项目 CUSA-NeuroPulse 串号泄露,动态注入新项目信息 (0.484096ms) + ✔ 3. 严格对标参考系统模板规范要素,绝非 3 行残缺摘要 (0.232263ms) + ✔ 4. 缓存丢失场景下即时自愈重建 AST 能力验证 (0.191584ms) +✔ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) (4.371831ms) +▶ 用户上传文档私密归档与共享流转测试 + ✔ 10 个评审项目均包含已共享的 URS 交付物与 AST,支持跨角色合规查阅且会话均以 assistant 闭环 (1.189995ms) + ✔ 用户上传附件自动生成私密交付物并在 AST 中建立隔离,支持流转至项目参考库 (1.207574ms) + ✔ 单轮对话支持一次上传多个文档(上限5个),批量生成私密交付物并遵循数量约束 (0.414727ms) +✔ 用户上传文档私密归档与共享流转测试 (3.488406ms) +▶ 工作流切换与消息展示时序测试套件 + ✔ 1. 当存在历史 seed 消息且追加新实时消息时,新消息必须严格沉淀在末尾 (0.633981ms) + ✔ 2. 切回项目时相同正文不应因 temp/server id 差异重复展示 (0.134076ms) +✔ 工作流切换与消息展示时序测试套件 (1.415858ms) +✔ DeliverableItem 必须包含 projectId 与 isTemplateOnly 属性 (0.598822ms) +ℹ tests 406 +ℹ suites 36 +ℹ pass 406 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 903.526321 + + RUN v5.0.0 /home/weichen/projects/Agent-System/web + +(node:443082) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. +(Use `node --trace-warnings ...` to show where the warning was created) + + Test Files 3 passed (3) + Tests 32 passed (32) + Start at 04:01:01 + Duration 533ms (import 50%, transform 44%, tests 5%, worker 1%) + diff --git a/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/go-build.txt b/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/go-build.txt new file mode 100644 index 00000000..0eb8f54f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/go-build.txt @@ -0,0 +1,2 @@ +GO_BUILD_EXIT=0 +build_exit=0 diff --git a/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/go-vet.txt b/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/go-vet.txt new file mode 100644 index 00000000..2d0c469a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/go-vet.txt @@ -0,0 +1,2 @@ +GO_VET_EXIT=0 +vet_exit=0 diff --git a/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/index-last-lines.txt b/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/index-last-lines.txt new file mode 100644 index 00000000..a4acff64 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/index-last-lines.txt @@ -0,0 +1,6 @@ +=== audit/INDEX.md last line === +- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-audit.md)(三入口重启首访;U2 已验收,主 JS ~624 KB;U1/U3–U7 待实施)。 +=== remediation/INDEX.md last line === +- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-plan.md)(三入口重启首访;U2 已验收,主 JS ~624 KB;U1/U3–U7 待实施)。 +=== date === +2026-09-18T04:11:26Z diff --git a/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/npm-build.txt b/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/npm-build.txt new file mode 100644 index 00000000..71171ebc --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave6-doc-drift/npm-build.txt @@ -0,0 +1,238 @@ + +> shuimu-workbench@1.0.0 build +> tsc && vite build && node scripts/precompress-dist.mjs + +vite v7.3.6 building client environment for production... +transforming... +✓ 4123 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.72 kB │ gzip: 0.45 kB +dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB +dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB +dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB +dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB +dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB +dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB +dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB +dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB +dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB +dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB +dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB +dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB +dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB +dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB +dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB +dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB +dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB +dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB +dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB +dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB +dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB +dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB +dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB +dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB +dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB +dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB +dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB +dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB +dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB +dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB +dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB +dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB +dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB +dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB +dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB +dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB +dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB +dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB +dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB +dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB +dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB +dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB +dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB +dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB +dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB +dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB +dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB +dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB +dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB +dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB +dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB +dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB +dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB +dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB +dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB +dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB +dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB +dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB +dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB +dist/assets/index-BYHTgAJJ.css 36.82 kB │ gzip: 10.03 kB +dist/assets/channel-BWaIGDQK.js 0.11 kB │ gzip: 0.12 kB +dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB +dist/assets/chunk-2Q5K7J3B-Bh00PV7B.js 0.19 kB │ gzip: 0.16 kB +dist/assets/chunk-JWPE2WC7-zllJnyes.js 0.22 kB │ gzip: 0.17 kB +dist/assets/chunk-XXDRQBXY-D7xLWH32.js 0.22 kB │ gzip: 0.20 kB +dist/assets/chunk-5VM5RSS4-DwRnlXdD.js 0.36 kB │ gzip: 0.27 kB +dist/assets/stateDiagram-v2-MP3YSRHH-CMni1PkL.js 0.39 kB │ gzip: 0.29 kB +dist/assets/classDiagram-ZZMXUADV-D-sblmQI.js 0.43 kB │ gzip: 0.30 kB +dist/assets/classDiagram-v2-VYDZK3BY-D-sblmQI.js 0.43 kB │ gzip: 0.30 kB +dist/assets/chunk-POPQ4Y6H-CV-hKTcv.js 0.50 kB │ gzip: 0.35 kB +dist/assets/swimlanesDiagram-VR7AAH4N-CSMO0mBI.js 0.51 kB │ gzip: 0.35 kB +dist/assets/infoDiagram-27XIBGKW-fa-zM8z6.js 0.55 kB │ gzip: 0.38 kB +dist/assets/sizeCapture-INFHLROL-DDBMTQik.js 0.90 kB │ gzip: 0.51 kB +dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB +dist/assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1.57 kB │ gzip: 0.73 kB +dist/assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1.77 kB │ gzip: 0.87 kB +dist/assets/chunk-F27PBJKO-usLA3fwD.js 1.88 kB │ gzip: 0.83 kB +dist/assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1.92 kB │ gzip: 0.90 kB +dist/assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1.97 kB │ gzip: 0.86 kB +dist/assets/arc-DzBaewGB.js 3.43 kB │ gzip: 1.48 kB +dist/assets/diagram-Z3DM3KII-DaRdRaxA.js 4.18 kB │ gzip: 1.81 kB +dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB +dist/assets/linear--qpe2Iaf.js 5.65 kB │ gzip: 2.30 kB +dist/assets/diagram-UQ7AKVKN-RZXoXHTp.js 6.04 kB │ gzip: 2.60 kB +dist/assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6.26 kB │ gzip: 2.60 kB +dist/assets/diagram-S7CK7UJ4-yML3LoUw.js 8.18 kB │ gzip: 3.65 kB +dist/assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10.06 kB │ gzip: 3.61 kB +dist/assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10.33 kB │ gzip: 3.61 kB +dist/assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10.64 kB │ gzip: 4.01 kB +dist/assets/diagram-VSXAHHWV-D-Jngocg.js 10.68 kB │ gzip: 4.11 kB +dist/assets/chunk-SVP7TREG-Xn5LeAbu.js 15.73 kB │ gzip: 4.41 kB +dist/assets/diagram-VX7I27RA-CqVb28LM.js 15.92 kB │ gzip: 5.68 kB +dist/assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17.43 kB │ gzip: 6.62 kB +dist/assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20.26 kB │ gzip: 7.18 kB +dist/assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23.14 kB │ gzip: 7.80 kB +dist/assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23.16 kB │ gzip: 8.54 kB +dist/assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23.54 kB │ gzip: 8.33 kB +dist/assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25.47 kB │ gzip: 6.77 kB +dist/assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29.38 kB │ gzip: 8.68 kB +dist/assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30.76 kB │ gzip: 10.57 kB +dist/assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30.79 kB │ gzip: 10.13 kB +dist/assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30.97 kB │ gzip: 9.71 kB +dist/assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33.98 kB │ gzip: 9.95 kB +dist/assets/layout-C2imKr5O.js 35.43 kB │ gzip: 12.74 kB +dist/assets/chunk-IMKFNOWR-C-Mq94A_.js 38.07 kB │ gzip: 12.51 kB +dist/assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41.72 kB │ gzip: 13.56 kB +dist/assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41.93 kB │ gzip: 15.67 kB +dist/assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44.47 kB │ gzip: 12.58 kB +dist/assets/chunk-TICWLB2K-yUyCyRlD.js 49.17 kB │ gzip: 15.74 kB +dist/assets/flowDiagram-HODETNUW-DwTSI7cN.js 61.84 kB │ gzip: 19.87 kB +dist/assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65.44 kB │ gzip: 18.77 kB +dist/assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69.66 kB │ gzip: 23.54 kB +dist/assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81.66 kB │ gzip: 22.46 kB +dist/assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115.76 kB │ gzip: 42.49 kB +dist/assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116.93 kB │ gzip: 31.11 kB +dist/assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151.79 kB │ gzip: 43.12 kB +dist/assets/katex-HP8lGamR.js 261.31 kB │ gzip: 77.57 kB +dist/assets/cytoscape.esm-BB4DxJjf.js 443.04 kB │ gzip: 141.98 kB +dist/assets/cynefin-OW5HDTMX-Dit8THDr.js 687.91 kB │ gzip: 154.14 kB +dist/assets/index-DhyNmxJE.js 2,829.14 kB │ gzip: 805.59 kB + +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +✓ built in 7.53s +precompress: 49 candidate(s) under /home/weichen/projects/Agent-System/web/dist + br assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 798 (−55.0%) + gzip assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 872 (−50.8%) + br assets/arc-DzBaewGB.js 3434 → 1285 (−62.6%) + gzip assets/arc-DzBaewGB.js 3434 → 1449 (−57.8%) + br assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151788 → 36902 (−75.7%) + gzip assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151788 → 42819 (−71.8%) + br assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41722 → 11938 (−71.4%) + gzip assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41722 → 13449 (−67.8%) + br assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65438 → 15174 (−76.8%) + gzip assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65438 → 18662 (−71.5%) + br assets/chunk-F27PBJKO-usLA3fwD.js 1876 → 730 (−61.1%) + gzip assets/chunk-F27PBJKO-usLA3fwD.js 1876 → 828 (−55.9%) + br assets/chunk-IMKFNOWR-C-Mq94A_.js 38069 → 10963 (−71.2%) + gzip assets/chunk-IMKFNOWR-C-Mq94A_.js 38069 → 12427 (−67.4%) + br assets/chunk-SVP7TREG-Xn5LeAbu.js 15728 → 3959 (−74.8%) + gzip assets/chunk-SVP7TREG-Xn5LeAbu.js 15728 → 4413 (−71.9%) + br assets/chunk-TICWLB2K-yUyCyRlD.js 49171 → 13023 (−73.5%) + gzip assets/chunk-TICWLB2K-yUyCyRlD.js 49171 → 15643 (−68.2%) + br assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81662 → 19567 (−76.0%) + gzip assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81662 → 22373 (−72.6%) + br assets/cynefin-OW5HDTMX-Dit8THDr.js 687914 → 124585 (−81.9%) + gzip assets/cynefin-OW5HDTMX-Dit8THDr.js 687914 → 153069 (−77.7%) + br assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10062 → 3233 (−67.9%) + gzip assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10062 → 3598 (−64.2%) + br assets/cytoscape.esm-BB4DxJjf.js 443036 → 119602 (−73.0%) + gzip assets/cytoscape.esm-BB4DxJjf.js 443036 → 141741 (−68.0%) + br assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10641 → 3659 (−65.6%) + gzip assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10641 → 4024 (−62.2%) + br assets/defaultLocale-DX6XiGOO.js 4688 → 2012 (−57.1%) + gzip assets/defaultLocale-DX6XiGOO.js 4688 → 2166 (−53.8%) + br assets/diagram-S7CK7UJ4-yML3LoUw.js 8182 → 3307 (−59.6%) + gzip assets/diagram-S7CK7UJ4-yML3LoUw.js 8182 → 3662 (−55.2%) + br assets/diagram-UQ7AKVKN-RZXoXHTp.js 6044 → 2343 (−61.2%) + gzip assets/diagram-UQ7AKVKN-RZXoXHTp.js 6044 → 2596 (−57.0%) + br assets/diagram-VSXAHHWV-D-Jngocg.js 10681 → 3764 (−64.8%) + gzip assets/diagram-VSXAHHWV-D-Jngocg.js 10681 → 4132 (−61.3%) + br assets/diagram-VX7I27RA-CqVb28LM.js 15922 → 5130 (−67.8%) + gzip assets/diagram-VX7I27RA-CqVb28LM.js 15922 → 5689 (−64.3%) + br assets/diagram-Z3DM3KII-DaRdRaxA.js 4176 → 1626 (−61.1%) + gzip assets/diagram-Z3DM3KII-DaRdRaxA.js 4176 → 1800 (−56.9%) + br assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1970 → 784 (−60.2%) + gzip assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1970 → 862 (−56.2%) + br assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30755 → 9152 (−70.2%) + gzip assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30755 → 10522 (−65.8%) + br assets/flowDiagram-HODETNUW-DwTSI7cN.js 61840 → 16342 (−73.6%) + gzip assets/flowDiagram-HODETNUW-DwTSI7cN.js 61840 → 19714 (−68.1%) + br assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69656 → 20654 (−70.3%) + gzip assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69656 → 23405 (−66.4%) + br assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29382 → 7766 (−73.6%) + gzip assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29382 → 8676 (−70.5%) + br assets/index-BYHTgAJJ.css 36824 → 8557 (−76.8%) + gzip assets/index-BYHTgAJJ.css 36824 → 9952 (−73.0%) + br assets/index-DhyNmxJE.js 2829141 → 625981 (−77.9%) + gzip assets/index-DhyNmxJE.js 2829141 → 798236 (−71.8%) + br assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17434 → 5995 (−65.6%) + gzip assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17434 → 6612 (−62.1%) + br assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23536 → 7492 (−68.2%) + gzip assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23536 → 8314 (−64.7%) + br assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20256 → 6416 (−68.3%) + gzip assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20256 → 7151 (−64.7%) + br assets/katex-HP8lGamR.js 261313 → 63694 (−75.6%) + gzip assets/katex-HP8lGamR.js 261313 → 77109 (−70.5%) + br assets/layout-C2imKr5O.js 35429 → 11552 (−67.4%) + gzip assets/layout-C2imKr5O.js 35429 → 12742 (−64.0%) + br assets/linear--qpe2Iaf.js 5654 → 2120 (−62.5%) + gzip assets/linear--qpe2Iaf.js 5654 → 2311 (−59.1%) + br assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23140 → 6990 (−69.8%) + gzip assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23140 → 7777 (−66.4%) + br assets/ordinal-Cboi1Yqb.js 1189 → 497 (−58.2%) + gzip assets/ordinal-Cboi1Yqb.js 1189 → 574 (−51.7%) + br assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1921 → 803 (−58.2%) + gzip assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1921 → 904 (−52.9%) + br assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6259 → 2379 (−62.0%) + gzip assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6259 → 2608 (−58.3%) + br assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33975 → 8492 (−75.0%) + gzip assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33975 → 9854 (−71.0%) + br assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1571 → 663 (−57.8%) + gzip assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1571 → 734 (−53.3%) + br assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30965 → 8236 (−73.4%) + gzip assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30965 → 9638 (−68.9%) + br assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23163 → 7806 (−66.3%) + gzip assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23163 → 8545 (−63.1%) + br assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116927 → 25725 (−78.0%) + gzip assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116927 → 30934 (−73.5%) + br assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10326 → 3305 (−68.0%) + gzip assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10326 → 3606 (−65.1%) + br assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115756 → 36638 (−68.3%) + gzip assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115756 → 42149 (−63.6%) + br assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30794 → 9120 (−70.4%) + gzip assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30794 → 10124 (−67.1%) + br assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41926 → 13970 (−66.7%) + gzip assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41926 → 15620 (−62.7%) + br assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25467 → 6076 (−76.1%) + gzip assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25467 → 6733 (−73.6%) + br assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44474 → 11050 (−75.2%) + gzip assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44474 → 12519 (−71.9%) + br fonts/OFL.txt 4474 → 1437 (−67.9%) + gzip fonts/OFL.txt 4474 → 1980 (−55.7%) +precompress: wrote 49 .br and 49 .gz +NPM_BUILD_EXIT=0 +npm_exit=0 diff --git a/.env.example b/.env.example index 16eb774d..39392f30 100644 --- a/.env.example +++ b/.env.example @@ -24,7 +24,6 @@ CURSOR_API_KEY= CURSOR_MODEL=auto CURSOR_BIN=/usr/local/bin/cursor CURSOR_BRIDGE_BIN=tools/cursor-bridge/index.mjs -CURSOR_RUNNER_PORT=18765 # --- 4. Pi-Agent 容器与 MiniMax 模型(pi-agent 容器 entrypoint 读取)--- MINIMAX_ENDPOINT_URL=https://api.minimaxi.com/v1 @@ -100,7 +99,6 @@ MULTI_INTENT_U9_SEGMENT=false MULTI_INTENT_U10_MUTATE=false MULTI_INTENT_U11_DOCGOV=false MULTI_INTENT_U12_RUN_CONTROL=false -SEMANTIC_FALLBACK_ENABLED=false # W2: shadow only. Do not set SEMANTIC_SUMMARY_REPLACE=true; REPLACE is unwired. SEMANTIC_SUMMARY_SHADOW=false SEMANTIC_SUMMARY_REPLACE=false diff --git a/PROJECT.md b/PROJECT.md index 0e7d1996..33594798 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -127,8 +127,8 @@ cd web && npm test && npm run build - 发布缺口 SSOT:[gap-matrix](./.doc/remediation/gap-matrix.md) - 模块完成度:[FEATURE-MATRIX](./.doc/status/FEATURE-MATRIX.md) -- 最新测试报告:[2026-09-13-codex-audit-p0-remediation-test-report.md](./.doc/test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md) -- **剩余 blocker**: 对话刷新恢复与历史读取授权一致性 + CI 真实 PI E2E + 前端人工抽检(main 本地冒烟已通过) +- 历史里程碑测试报告:[2026-09-13-codex-audit-p0-remediation-test-report.md](./.doc/test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md)(P0 收口快照;持续更新的测试报告见 `.doc/test-reports/` 与 CURRENT) +- **发布门禁**:`production_ready=yes`(gap-matrix B1–B4 已签,[发布门禁区段](./.doc/remediation/gap-matrix.md))。**无剩余发布 blocker**;U9–U12 扩面 / 纯软件项目自举 SWBOOT-10 / FAV live 放量 / D7 合规签字均为已知 deferred/需独立授权事项,不阻塞首发,详见 gap-matrix 与 FEATURE-MATRIX 各自行 --- diff --git a/cmd/document-content-reconcile/main.go b/cmd/document-content-reconcile/main.go index a0af1fb0..7fa1f4e6 100644 --- a/cmd/document-content-reconcile/main.go +++ b/cmd/document-content-reconcile/main.go @@ -111,6 +111,10 @@ func main() { os.Exit(1) } } + if err := rows.Err(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } enc, _ := json.MarshalIndent(report, "", " ") if *outPath != "" { _ = os.WriteFile(*outPath, enc, 0o644) diff --git a/cmd/document-revision-backfill/main.go b/cmd/document-revision-backfill/main.go index 04117787..6d4c8814 100644 --- a/cmd/document-revision-backfill/main.go +++ b/cmd/document-revision-backfill/main.go @@ -116,6 +116,10 @@ func main() { report.Success++ report.Items = append(report.Items, it) } + if err := rows.Err(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } enc, _ := json.MarshalIndent(report, "", " ") if *outPath != "" { _ = os.WriteFile(*outPath, enc, 0o644) diff --git a/cmd/server/main.go b/cmd/server/main.go index 71b260ab..c581b3d0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -70,6 +70,7 @@ func main() { fmt.Println(" • [GET] /api/v1/context/cases/recommend - 往期受控标杆案例与历史报价语义自推荐") fmt.Println(" • [GET] /api/v1/docgov/document - 查询受控文档治理状态看板") fmt.Println(" • [POST] /api/v1/docgov/claim - 查阅并认领受控文档 (正式稿方可认领)") + fmt.Println(" • [POST] /api/v1/docgov/proposal - 对已认领文档发起变更提案") fmt.Println(" • [POST] /api/v1/docgov/proposal/decide - 编制负责人拍板同意/拒绝变更提案") fmt.Println(" • [POST] /api/v1/docgov/submit-review - 编制完成提交审核 (文档正文锁定)") fmt.Println(" • [POST] /api/v1/docgov/review-decision - 审核通过或驳回 (驳回必填原因且解锁)") diff --git a/configs/.env.dev.example b/configs/.env.dev.example index 1ac7fffa..d1a5f103 100644 --- a/configs/.env.dev.example +++ b/configs/.env.dev.example @@ -29,7 +29,6 @@ KB_DB_HOST=shuimu-kb-shared KB_DB_PORT=5432 # 5. Agent 后端与 Runner 端口 -CURSOR_RUNNER_PORT=18765 PI_AGENT_RUNNER_PORT=18865 AGENT_BACKEND=pi-agent AGENT_TIMEOUT=60m diff --git a/configs/.env.main.example b/configs/.env.main.example index 335b4755..0e6fac84 100644 --- a/configs/.env.main.example +++ b/configs/.env.main.example @@ -4,7 +4,7 @@ # 根 .env 提供公共模型与检索配置;本文件覆盖生产端口、数据库和生产密钥。 # ============================================================================= -ENV=prod +ENV=main # 1. 业务 HTTP 服务 HTTP_PORT=8000 @@ -30,7 +30,6 @@ KB_DB_PORT=5432 KB_DB_PASSWORD= # 5. Cursor / LLM Agent Runner 服务 -CURSOR_RUNNER_PORT=18767 PI_AGENT_RUNNER_PORT=18867 AGENT_BACKEND=pi-agent AGENT_TIMEOUT=60m diff --git a/configs/.env.test.example b/configs/.env.test.example index a4e712cb..1e732ce0 100644 --- a/configs/.env.test.example +++ b/configs/.env.test.example @@ -27,7 +27,6 @@ KB_DB_HOST=shuimu-kb-shared KB_DB_PORT=5432 # 5. Cursor / LLM Agent Runner 服务 -CURSOR_RUNNER_PORT=18766 PI_AGENT_RUNNER_PORT=18866 AGENT_BACKEND=pi-agent AGENT_TIMEOUT=60m diff --git a/configs/agents/rdev_leader.md b/configs/agents/rdev_leader.md new file mode 100644 index 00000000..b55a5fc3 --- /dev/null +++ b/configs/agents/rdev_leader.md @@ -0,0 +1,30 @@ +## 角色 + +你是研发/设计控制小队的负责人(花名册名称:研发设计控制负责人)。按 ISO 13485 把本阶段做成可追溯的 DHF 记录。你是用户与小队之间的唯一接口:专员向你汇报,只有你对用户说话、提交阶段文档。 + +你的价值不在于自己写长文,而在于把本阶段设计控制要求变成可验收的任务,审核专员产出,整合成一份可提交的 Markdown 终稿。结论短、论证完整:卡片可以短,DHF 文档不能短。 + +**明确边界**:不做报价核算与商务承诺,不写商务对接口径。 + +## 汇报纪律 + +- 只有你可以把成果交给用户(通过系统落文档)。不要把未审核的专员原文直接当终稿。 +- 需要用户决策的事项一律上升,不要代用户拍板。 +- 专员产出不合格时退回说明差距;系统按剧本执行,你负责审核与整合。 + +## 本阶段你要做的(DHF 终审) + +1. 用一句话回述本阶段交付物及其 DHF 归属。 +2. 阅读快照、材料文本、前序文档和各专员 `output.md`。 +3. 按本阶段 RD 模板整合终稿,结论先行;与前序已批准文件保持一致。 +4. **追溯表必填核查**:本文件 ID、上游输入、下游验证/确认;未知项写入「待确认」而非留空。 +5. **不适用理由完整性**:声明不适用时须写完整理由,禁止空壳「不适用」。 +6. 缺信息标「待确认」,**禁止编造**试验数据、标准号、URL、签字、附件正文。 +7. 按 TASK.md 指定的交付文件落盘:终稿步写 `output.md`;若本步是「审核撰写计划」(review_plan),只写 `review_notes.md`,**禁止**把审核意见写入或覆盖 `output.md`。 +8. 专员已标注的出处必须保留;你整合时把能溯源的句子标上〔…〕,无依据的分析不要删,也不要补造引用。 + +## 汇报格式(终稿 output.md 开头;计划审核则写在 review_notes.md) + +**注意**:你整合生成的终稿文档结构必须严格遵循 `template.md` 的所有章节结构,不得自行替换为审阅报告结构!你的审阅和把关成果体现为:在保持 `template.md` 章节骨架(如 ## 结论、## 适用性、专业正文各节、## 追溯、## 待确认清单、## 签署 等)的基础上,将专员的草稿进行逻辑润色、出处溯源和终审定稿输出。 + +**待确认清单**:在模板对应章节中集中列出文中所有「待确认」。 diff --git a/configs/shared/writing.md b/configs/shared/writing.md index b291899c..4ff87e98 100644 --- a/configs/shared/writing.md +++ b/configs/shared/writing.md @@ -31,3 +31,16 @@ 3. 文中所有「待确认」是否已在文末清单汇总。 4. 是否有未标注的推测、疗效宣称或绝对化用语。 5. 专员已标注的出处是否保留;无依据的分析是否被误删。 + +## DHF 设计控制纪律(研发设计控制流程 rd_* 任务) + +> **分工红线**:模板管结构与占位;overlay/运行时管写作纪律与粒度下限。详见 [08-rdev模板写作规范](../../.doc/system-design/project-workflow/08-rdev模板写作规范.md)。 + +适用于 ISO 13485 设计历史文件(DHF)类受控文档: + +- 模板各章节必须写满;禁止残留括号提示或空表壳。 +- 结论先行:文首给出适用性/通过/不适用结论,后文论证不得与结论矛盾。 +- 追溯表必填:本文件 ID、上游输入、下游验证/确认;未知项写入「待确认」而非留空。 +- 缺项统一标「待确认」,并在文末待确认清单汇总。 +- 严禁编造试验数据、标准号、URL、签字、附件正文;附件清单类只建索引。 +- 声明不适用时须写完整理由(首期若任务在规划期关闭则不生成正文)。 diff --git a/deploy/postgres/goose/00050_2026-09-17-workflow-author-only-policy.sql b/deploy/postgres/goose/00050_2026-09-17-workflow-author-only-policy.sql new file mode 100644 index 00000000..8d5da424 --- /dev/null +++ b/deploy/postgres/goose/00050_2026-09-17-workflow-author-only-policy.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- 编制人兼任总授权:system_settings 增加 monotonic revision;种子目标 key;策略变更审计(仅本策略必需)。 + +ALTER TABLE system_settings + ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS system_setting_policy_audits ( + id BIGSERIAL PRIMARY KEY, + setting_key TEXT NOT NULL, + before_value JSONB, + after_value JSONB NOT NULL, + before_revision BIGINT, + after_revision BIGINT NOT NULL, + actor_user_id BIGINT NOT NULL, + request_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_system_setting_policy_audits_key_created + ON system_setting_policy_audits (setting_key, created_at DESC); + +-- 初始化目标行,避免激活事务对缺失行拿不到行锁。 +INSERT INTO system_settings (key, value, revision, updated_at) +VALUES ('workflow.allow_author_only_approval', 'false'::jsonb, 0, now()) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DELETE FROM system_settings WHERE key = 'workflow.allow_author_only_approval'; +DROP TABLE IF EXISTS system_setting_policy_audits; +ALTER TABLE system_settings DROP COLUMN IF EXISTS revision; diff --git a/deploy/postgres/goose/00051_2026-09-17-activation-token-author-only-revision.sql b/deploy/postgres/goose/00051_2026-09-17-activation-token-author-only-revision.sql new file mode 100644 index 00000000..c1c01111 --- /dev/null +++ b/deploy/postgres/goose/00051_2026-09-17-activation-token-author-only-revision.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- 激活确认 token 绑定编制人兼任总授权 revision,防止预览后关闭开关仍可激活。 + +ALTER TABLE workflow_activation_tokens + ADD COLUMN IF NOT EXISTS author_only_policy_revision BIGINT; + +-- +goose Down +ALTER TABLE workflow_activation_tokens + DROP COLUMN IF EXISTS author_only_policy_revision; diff --git a/deploy/postgres/goose/00052_2026-09-17-document-duty-locked.sql b/deploy/postgres/goose/00052_2026-09-17-document-duty-locked.sql new file mode 100644 index 00000000..61eb84f6 --- /dev/null +++ b/deploy/postgres/goose/00052_2026-09-17-document-duty-locked.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- 实例治理职责锁定:受工作流实例规划约束的文档标记 duty_locked,heal/revoke 跳过。 + +ALTER TABLE document_records + ADD COLUMN IF NOT EXISTS duty_locked BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS duty_policy TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE document_records + DROP COLUMN IF EXISTS duty_policy, + DROP COLUMN IF EXISTS duty_locked; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d823ab83..1d753d58 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -30,21 +30,6 @@ services: networks: - dev-net - cursor-agent: - image: alpine:latest - container_name: shuimu-cursor-agent-dev - command: ["sh", "-c", "echo 'Dev Agent Runner is standby' && sleep infinity"] - environment: - CURSOR_RUNNER_ADDR: ":18765" - DATA_DIR: /app/data/workspaces - ports: - - "127.0.0.1:${CURSOR_RUNNER_PORT:-18765}:18765" - volumes: - - ./data/dev/workspaces:/app/data/workspaces - restart: unless-stopped - networks: - - dev-net - pi-agent: build: context: . @@ -101,7 +86,6 @@ services: APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-dev_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_dev}?sslmode=disable # 共享知识库 POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:?KB_DB_PASSWORD is required}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable - CURSOR_RUNNER_URL: http://cursor-agent:18765 PI_AGENT_RUNNER_URL: http://pi-agent:18865 PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-} PI_AGENT_PROVIDER: ${PI_AGENT_PROVIDER:-minimax} @@ -125,11 +109,11 @@ services: INTENT_V2_DECISION_TIER: ${INTENT_V2_DECISION_TIER:-none} WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true} MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true} + MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy} MULTI_INTENT_U9_SEGMENT: ${MULTI_INTENT_U9_SEGMENT:-false} MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} - SEMANTIC_FALLBACK_ENABLED: ${SEMANTIC_FALLBACK_ENABLED:-false} # W2 PU6: shadow semantic summary; REPLACE stays unwired even if env is true SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} SEMANTIC_SUMMARY_REPLACE: ${SEMANTIC_SUMMARY_REPLACE:-false} diff --git a/docker-compose.main.yml b/docker-compose.main.yml index 7a9dd74d..d1ebb18a 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -30,21 +30,6 @@ services: networks: - main-net - cursor-agent: - image: alpine:latest - container_name: shuimu-cursor-agent-main - command: ["sh", "-c", "echo 'Main/Prod Agent Runner is running' && sleep infinity"] - environment: - CURSOR_RUNNER_ADDR: ":18767" - DATA_DIR: /app/data/workspaces - ports: - - "127.0.0.1:${CURSOR_RUNNER_PORT:-18767}:18767" - volumes: - - ./data/main/workspaces:/app/data/workspaces - restart: always - networks: - - main-net - pi-agent: image: ${PI_AGENT_IMAGE:-shuimu-pi-agent:main} build: @@ -86,8 +71,11 @@ services: depends_on: system-db: condition: service_healthy + events: + condition: service_healthy environment: - ENV: prod + ENV: main + SEED_DEMO_USERS: "false" HTTP_ADDR: ":${HTTP_PORT:-8000}" HTTP_PORT: "${HTTP_PORT:-8000}" HTTP_HOST: "0.0.0.0" @@ -99,7 +87,6 @@ services: APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?SYSTEM_DB_PASSWORD is required for main/production}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_main}?sslmode=disable # 共享知识库 POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable - CURSOR_RUNNER_URL: http://cursor-agent:18767 PI_AGENT_RUNNER_URL: http://pi-agent:18867 PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:?RUNNER_AUTH_TOKEN is required for main/production} PI_AGENT_PROVIDER: ${PI_AGENT_PROVIDER:-minimax} @@ -124,7 +111,13 @@ services: INTENT_V2_DECISION_TIER: ${INTENT_V2_DECISION_TIER:-none} WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true} MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true} - SEMANTIC_FALLBACK_ENABLED: ${SEMANTIC_FALLBACK_ENABLED:-false} + MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy} + MULTI_INTENT_U9_SEGMENT: ${MULTI_INTENT_U9_SEGMENT:-false} + MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} + MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} + MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} + SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} + PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} FAV_DEGRADED_READ: "false" FAV_PUBLISHER_ENABLED: "false" ports: @@ -162,7 +155,11 @@ services: HTTP_PORT: "${EVENTS_HTTP_PORT:-8090}" EVENTS_HTTP_PORT: "${EVENTS_HTTP_PORT:-8090}" DATA_DIR: /app/data + ALLOW_IN_MEMORY: "false" + # validateProductionSecrets(ENV=main) 需要口令;events 本身只连系统库,KB DSN 仅满足启动校验 APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?SYSTEM_DB_PASSWORD is required for main/production}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_main}?sslmode=disable + POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required for main/production} ports: - "127.0.0.1:${EVENTS_HTTP_PORT:-8090}:${EVENTS_HTTP_PORT:-8090}" healthcheck: @@ -174,6 +171,7 @@ services: restart: always networks: - main-net + - shared-net networks: main-net: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 7ab5d8c9..af9112c7 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -30,21 +30,6 @@ services: networks: - test-net - cursor-agent: - image: alpine:latest - container_name: shuimu-cursor-agent-test - command: ["sh", "-c", "echo 'Test Agent Runner is standby' && sleep infinity"] - environment: - CURSOR_RUNNER_ADDR: ":18766" - DATA_DIR: /app/data/workspaces - ports: - - "127.0.0.1:${CURSOR_RUNNER_PORT:-18766}:18766" - volumes: - - ./data/test/workspaces:/app/data/workspaces - restart: unless-stopped - networks: - - test-net - pi-agent: image: ${PI_AGENT_IMAGE:-shuimu-pi-agent:test} build: @@ -100,7 +85,6 @@ services: APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:-test_secret_db_pass}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable # 共享知识库 POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:?KB_DB_PASSWORD is required}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable - CURSOR_RUNNER_URL: http://cursor-agent:18766 PI_AGENT_RUNNER_URL: http://pi-agent:18866 PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-} PI_AGENT_PROVIDER: ${PI_AGENT_PROVIDER:-minimax} @@ -124,7 +108,13 @@ services: INTENT_V2_DECISION_TIER: ${INTENT_V2_DECISION_TIER:-none} WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true} MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true} - SEMANTIC_FALLBACK_ENABLED: ${SEMANTIC_FALLBACK_ENABLED:-false} + MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy} + MULTI_INTENT_U9_SEGMENT: ${MULTI_INTENT_U9_SEGMENT:-false} + MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} + MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} + MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} + SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} + PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} FAV_DEGRADED_READ: ${FAV_DEGRADED_READ:-false} FAV_PUBLISHER_ENABLED: ${FAV_PUBLISHER_ENABLED:-false} ports: diff --git a/docs/user-manual/README.md b/docs/user-manual/README.md new file mode 100644 index 00000000..b7f4a907 --- /dev/null +++ b/docs/user-manual/README.md @@ -0,0 +1,29 @@ +# 系统使用说明书(水木东方 · 医疗器械 AI 协同工作台) + +面向终端用户与系统管理员的完整使用说明书,覆盖:从 0 进入系统、登录/注册、管理员配置角色与权限、项目使用、AI 对话、文档工作台、审批全流程、FAQ。全部截图为 dev 环境真实操作采集(含真实 PI Agent 对话)。 + +## 文件说明 + +| 路径 | 说明 | +|------|------| +| [manual.md](./manual.md) | 说明书主文档(Markdown 源,SSOT) | +| [images/](./images/) | 全部 UI 截图(PNG,编号对应章节) | +| [dist/系统使用说明书.html](./dist/系统使用说明书.html) | HTML 版(单文件,图片已嵌入,浏览器直接打开) | +| [dist/系统使用说明书.pdf](./dist/系统使用说明书.pdf) | PDF 版(A4) | +| [dist/系统使用说明书.docx](./dist/系统使用说明书.docx) | Word 版 | +| [scripts/capture-screenshots.mjs](./scripts/capture-screenshots.mjs) | 截图采集脚本(Playwright,需 dev 环境运行在 8080) | +| [scripts/build-formats.mjs](./scripts/build-formats.mjs) | 格式生成脚本(pandoc + playwright) | + +## 重新生成 + +```bash +# 重新采集截图(dev 环境需在运行,会创建「说明书演示」前缀的演示数据) +node docs/user-manual/scripts/capture-screenshots.mjs + +# 重新生成 HTML / PDF / DOCX +node docs/user-manual/scripts/build-formats.mjs +``` + +注意:`dist/` 在根 `.gitignore` 中被忽略,本仓库通过 `git add -f` 强制纳入版本管理;本地重新生成后需同样 `git add -f docs/user-manual/dist`。 + +依赖:pandoc(`brew install pandoc`)、playwright docker 镜像 `mcr.microsoft.com/playwright:v1.63.0-noble`(PDF 打印在容器内执行,主机缺 chromium 系统依赖)。 diff --git a/docs/user-manual/dist/系统使用说明书.docx b/docs/user-manual/dist/系统使用说明书.docx new file mode 100644 index 00000000..915344eb Binary files /dev/null and b/docs/user-manual/dist/系统使用说明书.docx differ diff --git a/docs/user-manual/dist/系统使用说明书.html b/docs/user-manual/dist/系统使用说明书.html new file mode 100644 index 00000000..0a309425 --- /dev/null +++ b/docs/user-manual/dist/系统使用说明书.html @@ -0,0 +1,791 @@ + + + + + + + 水木东方 · 医疗器械 AI 协同工作台 — 系统使用说明书 + + + + +
+

水木东方 · 医疗器械 AI 协同工作台 — +系统使用说明书

+
+ +

水木东方 · +医疗器械 AI 协同工作台 — 系统使用说明书

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目说明
文档版本v1.0
编写日期2026-09-18
适用环境dev / test / main 三环境通用(截图取自 dev 环境)
适用读者系统管理员、研发总监、技术主管、方案工程师、外部审计/访客
配套格式Markdown(本文)/ HTML / PDF / Word
+
+

目录

+
    +
  1. 系统简介
  2. +
  3. 快速开始:从 0 进入系统
  4. +
  5. 界面导览:三栏工作台
  6. +
  7. 管理员指南:用户、角色与权限
  8. +
  9. 项目使用:立项与成员配置
  10. +
  11. 对话与 AI 项目助手
  12. +
  13. 文档工作台
  14. +
  15. 审批全流程
  16. +
  17. 角色权限速查与典型动线
  18. +
  19. 常见问题 FAQ
  20. +
+
+

1. 系统简介

+

1.1 系统定位

+

水木东方 · 医疗器械 AI 协同工作台 +是一个面向企业与团队的多用户工作流与对话智能体平台,定位于医疗器械受控研发场景(NMPA +/ ISO 13485 语境)。它将「AI +对话助手」与「受控文档治理」结合:项目成员通过对话推进项目,AI +助手协助沉淀研发文档,文档经过编制 → 审核 → +批准三责分离的审批链后归档生效,阶段全部必需文档批准后项目方可推进到下一阶段。

+

1.2 三大核心能力

+
    +
  1. 受控 RBAC 权限体系:系统级角色权限(RBAC)+ +文档级职责角色(编制/审核/批准)+ 项目级成员职责,三层隔离。
  2. +
  3. 项目助手(AI +Agent):每个项目会话配备项目助手,支持 SSE +流式对话、意图识别、计划确认卡片、知识库检索推荐。
  4. +
  5. 三层文档隔离:交付物分为「工作流受控」「项目共享」「个人私密」三类,受控文档全生命周期留痕,支持版本历史、修订、导出 +DOCX/XLSX。
  6. +
+

1.3 主要功能一览

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块功能
登录与准入账号密码登录、用户自主注册、注册审批、首次登录强制改密
工作台三栏布局:项目会话列表 / 对话区 / 交付物文档区
项目管理立项会话、成员与职责分配、工作流切换、阶段门禁推进
对话流式对话、附件上传(每轮最多 5 +个)、意图计划卡片、历史搜索与导出
文档块编辑、编辑租约锁、版本历史与对比、评论、可见性覆盖、导出
审批文档三责审批(编制/审核/批准)、阶段门禁、注册审批、修改建议审批
系统管理台运行总览、知识库连接、特性开关、设置白名单、工作流定义、RBAC +角色权限、用户与准入治理、文档模板库
知识库跨环境共享的向量知识库(RAG),检索结果在对话中以推荐卡片呈现
+

1.4 术语表

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
术语含义
项目实例(ProjectInstance)一个业务项目激活工作流后生成的运行实例,包含阶段序列与当前活动阶段
工作流(Workflow)预定义的「阶段 → 任务 → +产出文档」模板。系统内置三套正式工作流:main(V0.1 +需求摸底 → V0.5 方案与可行性 → V1.0 +商务报价)、product_manager(系统需求基线)、test_manager(系统验证策划)
阶段门禁(Stage Gate)进入下一阶段前,本阶段所有「必需且受审批管理」的文档必须到达「已批准」终态,由项目创建人显式推进
文档审批(docgov)受控文档 编制中 → 审核中 → 批准中 → 已批准 +的生命周期,三责分离,含编辑租约锁
职责三角色编制人(author,唯一可改正文)、审核人(reviewer)、批准人(approver)
RAG 知识库跨环境共享的向量检索库,为对话提供引用与案例推荐
SSE 流式对话服务端推送(Server-Sent Events)实时输出 AI 回复,逐字呈现
任务持久化AI 任务运行落库,支持断点恢复、取消与事件回放
PI Agent Runner产品容器内的真实 AI 执行器,驱动项目助手对话与文档生成
+
+

2. 快速开始:从 0 进入系统

+

2.1 访问地址与浏览器要求

+ + + + + + + + + + + + + + + + + + + + + + + + + +
环境访问地址用途
开发 devhttp://127.0.0.1:8080日常开发验证
测试 testhttp://127.0.0.1:8081发布前验收
生产 mainhttp://127.0.0.1:8000正式使用
+

推荐使用 Chrome / Edge 最新版本,窗口宽度建议 1440px +以上(三栏布局)。

+

2.2 账号从哪里来

+

系统不提供匿名访问,账号有三条来源:

+
    +
  1. 管理员直接开通(最常见):管理员在管理台「用户与准入治理」中创建受控员工账号,初始密码默认为 +InitPass123,首次登录强制改密(见 2.5)。
  2. +
  3. 用户自主注册:在登录页「用户自主注册」Tab +自行注册。注册后职位为「访客/待指派」,必须经管理员在注册审批中心核准后才能登录。是否开放注册、注册是否需要审核,由管理员在「准入策略」中控制。
  4. +
  5. 环境引导管理员:系统首次部署时自动创建管理员账号(用户名密码由部署配置决定)。
  6. +
+

2.3 登录

+

打开访问地址,进入登录页。左侧为品牌与能力介绍,右侧为登录表单;左上角胶囊显示当前环境(DEV/MAIN)。

+

登录页 - 账号密码

+

操作步骤:

+
    +
  1. 在「账号密码登录」Tab 输入用户名与密码;
  2. +
  3. 点击登录;
  4. +
  5. 登录成功后进入三栏工作台。
  6. +
+
+

注意:连续登录失败请确认账号状态。新注册未审核的账号登录会提示「账号正在等待管理员审核中」;被停用的账号无法登录,需联系管理员。

+
+

2.4 自主注册

+

登录页 - 用户自主注册

+
    +
  1. 切换到「用户自主注册」Tab;
  2. +
  3. 填写必填项:用户名、真实姓名、密码(两次输入一致);选填:部门、工号;
  4. +
  5. 提交后等待管理员审核;审核通过后使用注册的用户名密码登录;
  6. +
  7. 审核通过后,管理员还会为你指派职位与角色(见第 4 章)。
  8. +
+

2.5 首次登录强制改密

+

管理员开通的账号使用初始密码首次登录时,系统强制要求修改密码:

+

首次登录强制改密

+
    +
  1. 输入新密码并确认;
  2. +
  3. 点击「重置并加密」;
  4. +
  5. 确认后自动进入系统,之后使用新密码登录。
  6. +
+
+

3. 界面导览:三栏工作台

+

登录成功后进入工作台。工作台为三栏布局:

+

三栏工作台总览

+ + + + + + + + + + + + + + + + + + + + + +
区域内容
左栏项目会话列表(一项目一会话)、「+ +新建立项会话」按钮、项目成员入口
中栏对话区:消息流、阶段事件条、分类 Dock、输入区(附件/引用/发送)
右栏交付物文档区:分类 +Tab(受控/共享/私密)、文档渲染与块编辑、治理操作条
+

3.1 顶栏

+

顶栏

+

顶栏显示:当前项目名与当前阶段、工作流名称与运行状态、项目助手在线状态(绿点)、系统设置与个人信息入口。

+

3.2 左栏:项目会话列表

+

左栏项目会话列表

+
    +
  • 每个项目对应一个会话卡片,显示项目名、当前阶段徽标、消息数;
  • +
  • 点击卡片切换当前项目;
  • +
  • 「+ 新建立项会话」发起立项(见 5.1);
  • +
  • 「项目成员」打开成员与职责弹窗(见 5.2)。
  • +
+

3.3 个人设置

+

点击左下角个人区域打开个人设置弹窗,可修改个人资料(姓名、部门、工号等):

+

个人设置

+

3.4 我的项目权限

+

「我的项目权限」弹窗展示当前账号在各项目中的职责:待我编制 / 待我审核 +/ 待我批准的文档清单,以及法定职责说明:

+

我的项目权限

+
+

4. 管理员指南:用户、角色与权限

+
+

本章仅适用于**系统管理员(ADMIN)**角色。管理员登录后,可通过顶栏入口切换到「系统管理台」全屏视图;非管理员访问会被二次鉴权拦截(403 +退回工作台)。

+
+

系统管理台左侧为 8 个分组导航:

+

管理台 - 系统运行总览

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
分组用途
系统运行总览服务健康、版本、数据库与知识库就绪状态
知识库连接知识库在线状态、服务地址、数据库、连接账号
特性功能开关环境变量级 / 数据库级 feature flag 管理
系统设置白名单允许前端读取的系统设置项
工作流定义内置工作流定义列表(阶段/任务/产出文档结构)
RBAC 角色权限角色-权限矩阵,支持自定义角色
用户与准入治理账号开通、注册审批、准入策略
文档与模板库文档模板名称、版本、资产标识
+

4.1 开通受控员工账号

+

在「用户与准入治理 → 账号列表」中:

+

账号列表与开通账号

+
    +
  1. 点击「开通受控员工账号」;
  2. +
  3. 填写用户名、姓名、角色、部门等信息;
  4. +
  5. 系统生成初始密码(默认 +InitPass123),将其线下告知员工;
  6. +
  7. 员工首次登录会被强制改密(见 2.5)。
  8. +
+

账号列表还支持:变更职位与角色、启用/停用账号、重置初始密码(重置后该账号下次登录再次强制改密)。

+

4.2 注册审批中心

+

当准入策略开启「自主注册」且「注册需审核」时,新注册用户进入待审核队列:

+

注册审批中心

+
    +
  • 通过并指派:先在下拉框选择授予的业务职位(如「方案工程师」),再点击通过;账号激活,用户可登录并立即拥有该职位对应角色权限;
  • +
  • 驳回:注册被拒绝,账号不可用。
  • +
+

4.3 准入策略

+

准入策略

+

两个开关:

+ + + + + + + + + + + + + + + + + +
开关说明
开放自主注册关闭后登录页不再显示「用户自主注册」Tab
注册需要审核开启后新注册账号处于待审核状态,须管理员核准
+

4.4 RBAC 角色权限矩阵

+

「RBAC 角色权限」分组展示角色 × 权限点矩阵:

+

RBAC 角色权限矩阵

+

系统内置 5 个角色(ADMIN 为系统角色,不可删除/改名):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
角色Code定位
系统管理员ADMIN全量权限,含系统管理台
研发总监DIRECTOR立项、指派负责人、可见性配置与覆盖、导出、查看全部工作流
研发技术主管TECH_LEAD立项、指派负责人、导出、查看全部工作流
方案工程师ENGINEER导出、查看全部工作流
外部审计/访客GUEST仅导出(只读审计场景)
+

权限点目录:project:create(立项)、project:assign_lead(变更责任人)、system:manage(系统管理)、workflow:config_visibility(可见性配置)、doc:override_visibility(可见性覆盖)、doc:export(导出)、workflow:view_all(查阅全部)、workflow_defs.publish(发布工作流定义)、templates.publish(发布模板)。

+

管理员还可新建自定义角色并勾选权限点组合,或修改/删除非系统角色。

+

4.5 其余管理分组

+ + + + + + + + + + + + + + + + + + + + + + + + + +
截图说明
特性功能开关特性功能开关:控制实验性能力的启用/关闭(数据库级开关即时生效;环境变量级需重启服务)。注意:未理解的开关不要随意改动,部分开关影响数据处理链路
知识库连接知识库连接:查看共享知识库(RAG)在线状态、服务地址、数据库与连接账号;离线时对话中的案例推荐不可用
工作流定义工作流定义:查看内置工作流的阶段、任务与产出文档模板结构
文档与模板库文档与模板库:模板名称、版本列表与资产标识(asset_key),受控文档基于模板生成
+
+

5. 项目使用:立项与成员配置

+

5.1 新建立项会话

+

拥有 project:create +权限的角色(研发总监、技术主管)可立项。点击左栏「+ 新建立项会话」:

+

新建立项会话

+
    +
  1. 填写项目名称、项目类型、背景说明等;
  2. +
  3. 选择工作流(见 5.3);
  4. +
  5. 确认后系统创建项目实例,自动进入第一阶段(如 main 工作流的「V0.1 +需求摸底」),并生成本阶段要求的受控文档骨架。
  6. +
+

5.2 项目成员与职责分配

+

立项后,打开「项目成员」弹窗,把成员加入项目并分配职责:

+

项目成员与职责

+

职责与文档审批链直接挂钩(见第 8 章):

+
    +
  • 编制人:可编辑受控文档正文、提交送审;
  • +
  • 审核人:对送审文档做审核决议(通过/驳回);
  • +
  • 批准人:终审批准,批准后文档归档生效。
  • +
+
+

职责模式:项目工作流计划可配置 +full(编制/审核/批准三人齐备)、partial(部分职责)、none(免审批链)以及试点环境的「编制人兼任」(同人三签)模式。

+
+

5.3 工作流切换

+

一个项目可挂接多套工作流(如同时推进商务线 main 与产品线 +product_manager)。工作流在项目设置的「选择工作流」步骤中选择(列出全部已发布工作流,单选):

+

选择工作流

+ + + + + + + + + + + + + + + + + + + + + +
工作流阶段序列
mainV0.1 需求摸底 → V0.5 方案与可行性 → V1.0 商务报价
product_manager系统需求基线
test_manager系统验证策划
+

5.4 项目设置与职责配置

+

项目设置的「配置职责」步骤中,可将阶段产出文档(如 intake +用户初始需求收集表)纳入人工审批,并通过下拉框指派编制/审核/批准三权责任人:

+

配置职责

+
+

6. 对话与 AI 项目助手

+

6.1 发起对话

+

中栏底部为输入区:输入消息,Enter 发送,Shift+Enter 换行。项目助手以 +SSE 流式逐字输出回复。

+

下图为与项目助手的真实对话(dev 环境真实 PI Agent 执行):

+

真实对话

+

项目助手可以帮你:查询项目进展与风险、沉淀客户需求与技术指标、起草沟通话术与方案要点、推进方案论证与报价核算、编制或修订交付物文档。

+

6.2 附件上传

+

每轮对话最多可上传 5 +个附件(图片、文档等),点击输入区附件图标选择文件:

+

附件上传

+

6.3 意图计划确认卡片

+

当助手识别到需要多步骤执行的任务时,会在对话中给出执行计划(步骤清单与选项),你可以直接回复确认(如"好"或"1")让助手按计划执行,或补充说明调整方向。多意图计划的确认/驳回卡片由特性开关控制(当前 +dev 环境默认关闭,计划以消息内选项形式呈现,见 6.1 +的真实对话截图)。

+

6.4 历史记录

+

对话支持历史搜索、日历定位、导出与 Digest 时间线回顾:

+

历史记录

+
+

7. 文档工作台

+

右栏为交付物文档区,顶部分类 Tab 将文档分为三层:

+ + + + + + + + + + + + + + + + + + + + + +
分类说明
工作流受控工作流要求的正式交付物,走三责审批链,全生命周期留痕
项目共享项目成员共享的非受控资料
个人私密仅自己可见的草稿与笔记
+

7.1 块编辑

+

受控文档由「块」组成。双击任意段落进入就地编辑;鼠标右键可唤出快捷菜单(编辑、批注、复制、插块):

+

文档块编辑

+
+

编辑租约锁:同一文档同一时间仅一人持有编辑租约,避免冲突;他人打开时显示锁定状态。审核中/批准中的文档正文锁定,不可编辑。

+
+

7.2 版本历史与对比

+

文档工具栏可打开版本历史抽屉,查看每次修订版本,支持两版本对比(人工修改以琥珀底+下划线标出,相对最新基线):

+

版本历史

+

7.3 导出

+

交付物支持导出 DOCX / XLSX / Markdown(需要 doc:export +权限):

+

导出

+

7.4 文档可见性

+

默认可见性由工作流配置;有 doc:override_visibility +权限的角色(如研发总监)可对单个文档做可见性覆盖:

+

文档可见性

+
+

8. 审批全流程

+

系统中有四套审批机制,本章逐一说明。

+

8.1 文档三责审批(核心)

+

受控文档生命周期:

+
编制中(drafting) → 审核中(reviewing) → 批准中(approving) → 已批准(approved)
+        ↑                ↓ 驳回
+        └──── 退回编制 ────┘
+

第 1 步:编制人认领文档。 +编制人打开受控文档并首次保存正文后,系统自动完成认领(治理操作条显示「编制中 +· 已被编制人认领」),获得该文档的编制权:

+

编制中 · 已被编制人认领

+

第 2 步:编制并提交送审。 +编制完成后点击「提交送审(流转至审核人)」,文档进入「审核中」,正文锁定:

+

提交送审

+

第 3 步:审核人审核。 +审核人在自己的队列中看到待审核文档,可「审核通过(送呈批准人)」或「驳回退回编制」(驳回需说明理由):

+

审核决议

+

第 4 步:批准人终审。 +批准人执行「终审批准生效」;批准后文档到达 approved +终态并自动入标杆库。批准人驳回时必须填写理由,可指定回退到编制或审核环节:

+

终审批准

+
+

三责分离守卫:只有编制人可改正文;审核人/批准人不可编辑;送审只能由编制人发起。试点环境支持「编制人兼任」同人三签模式(同一人员依次完成三签)。

+
+

8.2 阶段门禁推进

+

当前阶段所有「必需且受审批管理」的文档到达已批准后,阶段门禁就绪,顶部出现推进 +Banner:

+

阶段门禁就绪

+

只有项目创建人可以点击「确认推进」进入下一阶段;门禁未就绪时按钮不可用并列出阻塞项(哪些必需文档未批准):

+

确认推进阶段

+

8.3 修改建议审批

+

非编制人员对受控文档可发起「修改建议」(propose),由责任人确认(decide)后合入,全程留痕,不破坏三责分离。

+

8.4 注册审批

+

面向管理员,见 +4.2。普通用户视角:注册后等待审核,审核通过前登录会收到「等待管理员审核」提示。

+
+

9. 角色权限速查与典型动线

+

9.1 权限速查表

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
权限点ADMINDIRECTORTECH_LEADENGINEERGUEST
立项 project:create✓✓✓——
变更责任人 project:assign_lead✓✓✓——
系统管理 system:manage✓————
可见性配置 workflow:config_visibility✓✓———
可见性覆盖 doc:override_visibility✓✓✓——
导出 doc:export✓✓✓✓✓
查阅全部 workflow:view_all✓✓✓✓—
+
+

以管理台「RBAC +角色权限」矩阵实时数据为准;自定义角色权限由管理员配置。

+
+

9.2 典型角色的一天

+

系统管理员:登录 → 管理台总览看运行状态 → +注册审批中心处理新注册 → 为新员工开通账号并指派角色 → +按需调整特性开关。

+

研发总监 / 技术主管(项目创建人):立项 → +配置成员职责 → 与项目助手对话推进需求 → 关注待批准队列 → 终审批准文档 → +阶段门禁就绪后确认推进。

+

编制人:在「我的项目权限」看待我编制清单 → 认领文档 +→ 块编辑编制内容 → 提交送审 → 如被驳回按意见修改再送审。

+

审核人:看待我审核队列 → +审阅送审文档(可批注/提修改建议)→ 通过送呈批准人,或驳回退回编制。

+

外部审计/访客:只读查阅被授权的交付物 → +导出留存。

+
+

10. 常见问题 FAQ

+

Q1:登录提示「账号正在等待管理员审核中」? +自主注册的账号需要管理员在「注册审批中心」核准。请联系系统管理员。

+

Q2:忘记密码怎么办? +联系管理员在「账号列表」中「重置初始密码」,之后用初始密码登录并按提示强制改密。

+

Q3:登录后看不到管理台入口? +管理台仅系统管理员可见。其他角色访问会被拦截并退回工作台。

+

Q4:为什么不能编辑某份受控文档? +可能原因:你不是该文档编制人;文档处于审核中/批准中(正文锁定);他人持有编辑租约;文档已批准归档(终态只读)。

+

Q5:「确认推进」按钮不可用? +阶段门禁未就绪:本阶段还有必需文档未批准。Banner +上会列出阻塞文档清单,先完成其审批链。另外只有项目创建人能推进阶段。

+

Q6:对话中助手没有推荐案例? +知识库可能离线。请管理员到「知识库连接」分组检查共享知识库状态。

+

Q7:附件传不上去? 每轮对话最多 5 +个附件,超出需分轮发送;另请确认文件类型受支持。

+

Q8:注册 Tab 不见了? +管理员在「准入策略」中关闭了自主注册。请联系管理员直接开通账号。

+
+

本说明书基于 dev 环境(schema +v52)真实操作截图编写。界面与流程以实际环境为准。

+ + diff --git a/docs/user-manual/dist/系统使用说明书.pdf b/docs/user-manual/dist/系统使用说明书.pdf new file mode 100644 index 00000000..51262463 Binary files /dev/null and b/docs/user-manual/dist/系统使用说明书.pdf differ diff --git a/docs/user-manual/images/01-login.png b/docs/user-manual/images/01-login.png new file mode 100644 index 00000000..72b5d9a3 Binary files /dev/null and b/docs/user-manual/images/01-login.png differ diff --git a/docs/user-manual/images/02-login-register.png b/docs/user-manual/images/02-login-register.png new file mode 100644 index 00000000..b7256c40 Binary files /dev/null and b/docs/user-manual/images/02-login-register.png differ diff --git a/docs/user-manual/images/03-first-login-password.png b/docs/user-manual/images/03-first-login-password.png new file mode 100644 index 00000000..443c9ebc Binary files /dev/null and b/docs/user-manual/images/03-first-login-password.png differ diff --git a/docs/user-manual/images/10-workbench-overview.png b/docs/user-manual/images/10-workbench-overview.png new file mode 100644 index 00000000..40cec24f Binary files /dev/null and b/docs/user-manual/images/10-workbench-overview.png differ diff --git a/docs/user-manual/images/11-topbar.png b/docs/user-manual/images/11-topbar.png new file mode 100644 index 00000000..e218704b Binary files /dev/null and b/docs/user-manual/images/11-topbar.png differ diff --git a/docs/user-manual/images/12-left-rail.png b/docs/user-manual/images/12-left-rail.png new file mode 100644 index 00000000..ae9a51a3 Binary files /dev/null and b/docs/user-manual/images/12-left-rail.png differ diff --git a/docs/user-manual/images/13-user-settings.png b/docs/user-manual/images/13-user-settings.png new file mode 100644 index 00000000..319d3e71 Binary files /dev/null and b/docs/user-manual/images/13-user-settings.png differ diff --git a/docs/user-manual/images/14-my-rights.png b/docs/user-manual/images/14-my-rights.png new file mode 100644 index 00000000..3ae0312e Binary files /dev/null and b/docs/user-manual/images/14-my-rights.png differ diff --git a/docs/user-manual/images/20-create-project.png b/docs/user-manual/images/20-create-project.png new file mode 100644 index 00000000..9d6a9d5a Binary files /dev/null and b/docs/user-manual/images/20-create-project.png differ diff --git a/docs/user-manual/images/21-project-members.png b/docs/user-manual/images/21-project-members.png new file mode 100644 index 00000000..24a07c00 Binary files /dev/null and b/docs/user-manual/images/21-project-members.png differ diff --git a/docs/user-manual/images/22-workflow-switcher.png b/docs/user-manual/images/22-workflow-switcher.png new file mode 100644 index 00000000..daaf614d Binary files /dev/null and b/docs/user-manual/images/22-workflow-switcher.png differ diff --git a/docs/user-manual/images/23-project-settings.png b/docs/user-manual/images/23-project-settings.png new file mode 100644 index 00000000..9377ea2f Binary files /dev/null and b/docs/user-manual/images/23-project-settings.png differ diff --git a/docs/user-manual/images/30-chat-real.png b/docs/user-manual/images/30-chat-real.png new file mode 100644 index 00000000..58e0e6a8 Binary files /dev/null and b/docs/user-manual/images/30-chat-real.png differ diff --git a/docs/user-manual/images/31-chat-attachment.png b/docs/user-manual/images/31-chat-attachment.png new file mode 100644 index 00000000..acc11e17 Binary files /dev/null and b/docs/user-manual/images/31-chat-attachment.png differ diff --git a/docs/user-manual/images/33-history.png b/docs/user-manual/images/33-history.png new file mode 100644 index 00000000..ad4c6ce4 Binary files /dev/null and b/docs/user-manual/images/33-history.png differ diff --git a/docs/user-manual/images/40-doc-editor.png b/docs/user-manual/images/40-doc-editor.png new file mode 100644 index 00000000..f2bb2801 Binary files /dev/null and b/docs/user-manual/images/40-doc-editor.png differ diff --git a/docs/user-manual/images/41-version-history.png b/docs/user-manual/images/41-version-history.png new file mode 100644 index 00000000..92d09e7d Binary files /dev/null and b/docs/user-manual/images/41-version-history.png differ diff --git a/docs/user-manual/images/42-export.png b/docs/user-manual/images/42-export.png new file mode 100644 index 00000000..8f4a692c Binary files /dev/null and b/docs/user-manual/images/42-export.png differ diff --git a/docs/user-manual/images/43-doc-visibility.png b/docs/user-manual/images/43-doc-visibility.png new file mode 100644 index 00000000..25ff5680 Binary files /dev/null and b/docs/user-manual/images/43-doc-visibility.png differ diff --git a/docs/user-manual/images/50-doc-claim.png b/docs/user-manual/images/50-doc-claim.png new file mode 100644 index 00000000..83259c03 Binary files /dev/null and b/docs/user-manual/images/50-doc-claim.png differ diff --git a/docs/user-manual/images/51-doc-submit-review.png b/docs/user-manual/images/51-doc-submit-review.png new file mode 100644 index 00000000..a3575a3c Binary files /dev/null and b/docs/user-manual/images/51-doc-submit-review.png differ diff --git a/docs/user-manual/images/52-doc-review.png b/docs/user-manual/images/52-doc-review.png new file mode 100644 index 00000000..e0920083 Binary files /dev/null and b/docs/user-manual/images/52-doc-review.png differ diff --git a/docs/user-manual/images/53-doc-approve.png b/docs/user-manual/images/53-doc-approve.png new file mode 100644 index 00000000..ef4cbb34 Binary files /dev/null and b/docs/user-manual/images/53-doc-approve.png differ diff --git a/docs/user-manual/images/54-stage-gate-ready.png b/docs/user-manual/images/54-stage-gate-ready.png new file mode 100644 index 00000000..5c259e35 Binary files /dev/null and b/docs/user-manual/images/54-stage-gate-ready.png differ diff --git a/docs/user-manual/images/55-stage-advance.png b/docs/user-manual/images/55-stage-advance.png new file mode 100644 index 00000000..bf5d37e0 Binary files /dev/null and b/docs/user-manual/images/55-stage-advance.png differ diff --git a/docs/user-manual/images/60-admin-overview.png b/docs/user-manual/images/60-admin-overview.png new file mode 100644 index 00000000..d6686bd0 Binary files /dev/null and b/docs/user-manual/images/60-admin-overview.png differ diff --git a/docs/user-manual/images/61-admin-users.png b/docs/user-manual/images/61-admin-users.png new file mode 100644 index 00000000..b2735111 Binary files /dev/null and b/docs/user-manual/images/61-admin-users.png differ diff --git a/docs/user-manual/images/62-admin-approvals.png b/docs/user-manual/images/62-admin-approvals.png new file mode 100644 index 00000000..1d50c803 Binary files /dev/null and b/docs/user-manual/images/62-admin-approvals.png differ diff --git a/docs/user-manual/images/63-admin-access-policy.png b/docs/user-manual/images/63-admin-access-policy.png new file mode 100644 index 00000000..94c533b1 Binary files /dev/null and b/docs/user-manual/images/63-admin-access-policy.png differ diff --git a/docs/user-manual/images/64-admin-roles.png b/docs/user-manual/images/64-admin-roles.png new file mode 100644 index 00000000..4b0963d4 Binary files /dev/null and b/docs/user-manual/images/64-admin-roles.png differ diff --git a/docs/user-manual/images/65-admin-features.png b/docs/user-manual/images/65-admin-features.png new file mode 100644 index 00000000..e96c1f5a Binary files /dev/null and b/docs/user-manual/images/65-admin-features.png differ diff --git a/docs/user-manual/images/66-admin-kb.png b/docs/user-manual/images/66-admin-kb.png new file mode 100644 index 00000000..1969f1ed Binary files /dev/null and b/docs/user-manual/images/66-admin-kb.png differ diff --git a/docs/user-manual/images/67-admin-workflows.png b/docs/user-manual/images/67-admin-workflows.png new file mode 100644 index 00000000..920b747e Binary files /dev/null and b/docs/user-manual/images/67-admin-workflows.png differ diff --git a/docs/user-manual/images/68-admin-templates.png b/docs/user-manual/images/68-admin-templates.png new file mode 100644 index 00000000..85d18f7d Binary files /dev/null and b/docs/user-manual/images/68-admin-templates.png differ diff --git a/docs/user-manual/images/_capture-report.json b/docs/user-manual/images/_capture-report.json new file mode 100644 index 00000000..de3dacb3 --- /dev/null +++ b/docs/user-manual/images/_capture-report.json @@ -0,0 +1,75 @@ +{ + "capturedAt": "2026-09-18T04:02:39.919Z", + "results": [ + { + "file": "10-workbench-overview.png", + "ok": true, + "note": "三栏工作台总览(张工)" + }, + { + "file": "11-topbar.png", + "ok": true, + "note": "顶栏特写(当前项目 + 项目助手在线)" + }, + { + "file": "12-left-rail.png", + "ok": true, + "note": "左栏项目会话列表特写" + }, + { + "file": "13-user-settings.png", + "ok": true, + "note": "个人设置弹窗" + }, + { + "file": "14-my-rights.png", + "ok": true, + "note": "我的项目权限弹窗" + }, + { + "file": "60-admin-overview.png", + "ok": true, + "note": "系统运行总览" + }, + { + "file": "61-admin-users.png", + "ok": true, + "note": "账号列表 + 开通受控员工账号弹窗(示例已填,未提交)" + }, + { + "file": "62-admin-approvals.png", + "ok": true, + "note": "注册审批中心(含 demo02 待审核记录)" + }, + { + "file": "63-admin-access-policy.png", + "ok": true, + "note": "准入策略开关" + }, + { + "file": "64-admin-roles.png", + "ok": true, + "note": "RBAC 角色权限矩阵" + }, + { + "file": "65-admin-features.png", + "ok": true, + "note": "特性功能开关" + }, + { + "file": "66-admin-kb.png", + "ok": true, + "note": "知识库连接" + }, + { + "file": "67-admin-workflows.png", + "ok": true, + "note": "工作流定义" + }, + { + "file": "68-admin-templates.png", + "ok": true, + "note": "文档与模板库" + } + ] +} \ No newline at end of file diff --git a/docs/user-manual/manual.md b/docs/user-manual/manual.md new file mode 100644 index 00000000..86c182f8 --- /dev/null +++ b/docs/user-manual/manual.md @@ -0,0 +1,472 @@ +# 水木东方 · 医疗器械 AI 协同工作台 — 系统使用说明书 + +| 项目 | 说明 | +|------|------| +| 文档版本 | v1.0 | +| 编写日期 | 2026-09-18 | +| 适用环境 | dev / test / main 三环境通用(截图取自 dev 环境) | +| 适用读者 | 系统管理员、研发总监、技术主管、方案工程师、外部审计/访客 | +| 配套格式 | Markdown(本文)/ HTML / PDF / Word | + +--- + +## 目录 + +1. [系统简介](#1-系统简介) +2. [快速开始:从 0 进入系统](#2-快速开始从-0-进入系统) +3. [界面导览:三栏工作台](#3-界面导览三栏工作台) +4. [管理员指南:用户、角色与权限](#4-管理员指南用户角色与权限) +5. [项目使用:立项与成员配置](#5-项目使用立项与成员配置) +6. [对话与 AI 项目助手](#6-对话与-ai-项目助手) +7. [文档工作台](#7-文档工作台) +8. [审批全流程](#8-审批全流程) +9. [角色权限速查与典型动线](#9-角色权限速查与典型动线) +10. [常见问题 FAQ](#10-常见问题-faq) + +--- + +## 1. 系统简介 + +### 1.1 系统定位 + +**水木东方 · 医疗器械 AI 协同工作台** 是一个面向企业与团队的多用户工作流与对话智能体平台,定位于医疗器械受控研发场景(NMPA / ISO 13485 语境)。它将「AI 对话助手」与「受控文档治理」结合:项目成员通过对话推进项目,AI 助手协助沉淀研发文档,文档经过**编制 → 审核 → 批准**三责分离的审批链后归档生效,阶段全部必需文档批准后项目方可推进到下一阶段。 + +### 1.2 三大核心能力 + +1. **受控 RBAC 权限体系**:系统级角色权限(RBAC)+ 文档级职责角色(编制/审核/批准)+ 项目级成员职责,三层隔离。 +2. **项目助手(AI Agent)**:每个项目会话配备项目助手,支持 SSE 流式对话、意图识别、计划确认卡片、知识库检索推荐。 +3. **三层文档隔离**:交付物分为「工作流受控」「项目共享」「个人私密」三类,受控文档全生命周期留痕,支持版本历史、修订、导出 DOCX/XLSX。 + +### 1.3 主要功能一览 + +| 模块 | 功能 | +|------|------| +| 登录与准入 | 账号密码登录、用户自主注册、注册审批、首次登录强制改密 | +| 工作台 | 三栏布局:项目会话列表 / 对话区 / 交付物文档区 | +| 项目管理 | 立项会话、成员与职责分配、工作流切换、阶段门禁推进 | +| 对话 | 流式对话、附件上传(每轮最多 5 个)、意图计划卡片、历史搜索与导出 | +| 文档 | 块编辑、编辑租约锁、版本历史与对比、评论、可见性覆盖、导出 | +| 审批 | 文档三责审批(编制/审核/批准)、阶段门禁、注册审批、修改建议审批 | +| 系统管理台 | 运行总览、知识库连接、特性开关、设置白名单、工作流定义、RBAC 角色权限、用户与准入治理、文档模板库 | +| 知识库 | 跨环境共享的向量知识库(RAG),检索结果在对话中以推荐卡片呈现 | + +### 1.4 术语表 + +| 术语 | 含义 | +|------|------| +| 项目实例(ProjectInstance) | 一个业务项目激活工作流后生成的运行实例,包含阶段序列与当前活动阶段 | +| 工作流(Workflow) | 预定义的「阶段 → 任务 → 产出文档」模板。系统内置三套正式工作流:**main**(V0.1 需求摸底 → V0.5 方案与可行性 → V1.0 商务报价)、**product_manager**(系统需求基线)、**test_manager**(系统验证策划) | +| 阶段门禁(Stage Gate) | 进入下一阶段前,本阶段所有「必需且受审批管理」的文档必须到达「已批准」终态,由项目创建人显式推进 | +| 文档审批(docgov) | 受控文档 编制中 → 审核中 → 批准中 → 已批准 的生命周期,三责分离,含编辑租约锁 | +| 职责三角色 | 编制人(author,唯一可改正文)、审核人(reviewer)、批准人(approver) | +| RAG 知识库 | 跨环境共享的向量检索库,为对话提供引用与案例推荐 | +| SSE 流式对话 | 服务端推送(Server-Sent Events)实时输出 AI 回复,逐字呈现 | +| 任务持久化 | AI 任务运行落库,支持断点恢复、取消与事件回放 | +| PI Agent Runner | 产品容器内的真实 AI 执行器,驱动项目助手对话与文档生成 | + +--- + +## 2. 快速开始:从 0 进入系统 + +### 2.1 访问地址与浏览器要求 + +| 环境 | 访问地址 | 用途 | +|------|----------|------| +| 开发 dev | http://127.0.0.1:8080 | 日常开发验证 | +| 测试 test | http://127.0.0.1:8081 | 发布前验收 | +| 生产 main | http://127.0.0.1:8000 | 正式使用 | + +推荐使用 Chrome / Edge 最新版本,窗口宽度建议 1440px 以上(三栏布局)。 + +### 2.2 账号从哪里来 + +系统不提供匿名访问,账号有三条来源: + +1. **管理员直接开通**(最常见):管理员在管理台「用户与准入治理」中创建受控员工账号,初始密码默认为 `InitPass123`,首次登录强制改密(见 2.5)。 +2. **用户自主注册**:在登录页「用户自主注册」Tab 自行注册。注册后职位为「访客/待指派」,**必须经管理员在注册审批中心核准后才能登录**。是否开放注册、注册是否需要审核,由管理员在「准入策略」中控制。 +3. **环境引导管理员**:系统首次部署时自动创建管理员账号(用户名密码由部署配置决定)。 + +### 2.3 登录 + +打开访问地址,进入登录页。左侧为品牌与能力介绍,右侧为登录表单;左上角胶囊显示当前环境(DEV/MAIN)。 + +![登录页 - 账号密码](images/01-login.png) + +操作步骤: + +1. 在「账号密码登录」Tab 输入用户名与密码; +2. 点击登录; +3. 登录成功后进入三栏工作台。 + +> **注意**:连续登录失败请确认账号状态。新注册未审核的账号登录会提示「账号正在等待管理员审核中」;被停用的账号无法登录,需联系管理员。 + +### 2.4 自主注册 + +![登录页 - 用户自主注册](images/02-login-register.png) + +1. 切换到「用户自主注册」Tab; +2. 填写必填项:用户名、真实姓名、密码(两次输入一致);选填:部门、工号; +3. 提交后等待管理员审核;审核通过后使用注册的用户名密码登录; +4. 审核通过后,管理员还会为你指派职位与角色(见第 4 章)。 + +### 2.5 首次登录强制改密 + +管理员开通的账号使用初始密码首次登录时,系统强制要求修改密码: + +![首次登录强制改密](images/03-first-login-password.png) + +1. 输入新密码并确认; +2. 点击「重置并加密」; +3. 确认后自动进入系统,之后使用新密码登录。 + +--- + +## 3. 界面导览:三栏工作台 + +登录成功后进入工作台。工作台为三栏布局: + +![三栏工作台总览](images/10-workbench-overview.png) + +| 区域 | 内容 | +|------|------| +| 左栏 | 项目会话列表(一项目一会话)、「+ 新建立项会话」按钮、项目成员入口 | +| 中栏 | 对话区:消息流、阶段事件条、分类 Dock、输入区(附件/引用/发送) | +| 右栏 | 交付物文档区:分类 Tab(受控/共享/私密)、文档渲染与块编辑、治理操作条 | + +### 3.1 顶栏 + +![顶栏](images/11-topbar.png) + +顶栏显示:当前项目名与当前阶段、工作流名称与运行状态、项目助手在线状态(绿点)、系统设置与个人信息入口。 + +### 3.2 左栏:项目会话列表 + +![左栏项目会话列表](images/12-left-rail.png) + +- 每个项目对应一个会话卡片,显示项目名、当前阶段徽标、消息数; +- 点击卡片切换当前项目; +- 「+ 新建立项会话」发起立项(见 5.1); +- 「项目成员」打开成员与职责弹窗(见 5.2)。 + +### 3.3 个人设置 + +点击左下角个人区域打开个人设置弹窗,可修改个人资料(姓名、部门、工号等): + +![个人设置](images/13-user-settings.png) + +### 3.4 我的项目权限 + +「我的项目权限」弹窗展示当前账号在各项目中的职责:待我编制 / 待我审核 / 待我批准的文档清单,以及法定职责说明: + +![我的项目权限](images/14-my-rights.png) + +--- + +## 4. 管理员指南:用户、角色与权限 + +> 本章仅适用于**系统管理员(ADMIN)**角色。管理员登录后,可通过顶栏入口切换到「系统管理台」全屏视图;非管理员访问会被二次鉴权拦截(403 退回工作台)。 + +系统管理台左侧为 8 个分组导航: + +![管理台 - 系统运行总览](images/60-admin-overview.png) + +| 分组 | 用途 | +|------|------| +| 系统运行总览 | 服务健康、版本、数据库与知识库就绪状态 | +| 知识库连接 | 知识库在线状态、服务地址、数据库、连接账号 | +| 特性功能开关 | 环境变量级 / 数据库级 feature flag 管理 | +| 系统设置白名单 | 允许前端读取的系统设置项 | +| 工作流定义 | 内置工作流定义列表(阶段/任务/产出文档结构) | +| RBAC 角色权限 | 角色-权限矩阵,支持自定义角色 | +| 用户与准入治理 | 账号开通、注册审批、准入策略 | +| 文档与模板库 | 文档模板名称、版本、资产标识 | + +### 4.1 开通受控员工账号 + +在「用户与准入治理 → 账号列表」中: + +![账号列表与开通账号](images/61-admin-users.png) + +1. 点击「开通受控员工账号」; +2. 填写用户名、姓名、角色、部门等信息; +3. 系统生成初始密码(默认 `InitPass123`),将其线下告知员工; +4. 员工首次登录会被强制改密(见 2.5)。 + +账号列表还支持:**变更职位与角色**、**启用/停用账号**、**重置初始密码**(重置后该账号下次登录再次强制改密)。 + +### 4.2 注册审批中心 + +当准入策略开启「自主注册」且「注册需审核」时,新注册用户进入待审核队列: + +![注册审批中心](images/62-admin-approvals.png) + +- **通过并指派**:先在下拉框选择授予的业务职位(如「方案工程师」),再点击通过;账号激活,用户可登录并立即拥有该职位对应角色权限; +- **驳回**:注册被拒绝,账号不可用。 + +### 4.3 准入策略 + +![准入策略](images/63-admin-access-policy.png) + +两个开关: + +| 开关 | 说明 | +|------|------| +| 开放自主注册 | 关闭后登录页不再显示「用户自主注册」Tab | +| 注册需要审核 | 开启后新注册账号处于待审核状态,须管理员核准 | + +### 4.4 RBAC 角色权限矩阵 + +「RBAC 角色权限」分组展示角色 × 权限点矩阵: + +![RBAC 角色权限矩阵](images/64-admin-roles.png) + +系统内置 5 个角色(ADMIN 为系统角色,不可删除/改名): + +| 角色 | Code | 定位 | +|------|------|------| +| 系统管理员 | ADMIN | 全量权限,含系统管理台 | +| 研发总监 | DIRECTOR | 立项、指派负责人、可见性配置与覆盖、导出、查看全部工作流 | +| 研发技术主管 | TECH_LEAD | 立项、指派负责人、导出、查看全部工作流 | +| 方案工程师 | ENGINEER | 导出、查看全部工作流 | +| 外部审计/访客 | GUEST | 仅导出(只读审计场景) | + +权限点目录:`project:create`(立项)、`project:assign_lead`(变更责任人)、`system:manage`(系统管理)、`workflow:config_visibility`(可见性配置)、`doc:override_visibility`(可见性覆盖)、`doc:export`(导出)、`workflow:view_all`(查阅全部)、`workflow_defs.publish`(发布工作流定义)、`templates.publish`(发布模板)。 + +管理员还可**新建自定义角色**并勾选权限点组合,或修改/删除非系统角色。 + +### 4.5 其余管理分组 + +| 截图 | 说明 | +|------|------| +| ![特性功能开关](images/65-admin-features.png) | 特性功能开关:控制实验性能力的启用/关闭(数据库级开关即时生效;环境变量级需重启服务)。**注意**:未理解的开关不要随意改动,部分开关影响数据处理链路 | +| ![知识库连接](images/66-admin-kb.png) | 知识库连接:查看共享知识库(RAG)在线状态、服务地址、数据库与连接账号;离线时对话中的案例推荐不可用 | +| ![工作流定义](images/67-admin-workflows.png) | 工作流定义:查看内置工作流的阶段、任务与产出文档模板结构 | +| ![文档与模板库](images/68-admin-templates.png) | 文档与模板库:模板名称、版本列表与资产标识(asset_key),受控文档基于模板生成 | + +--- + +## 5. 项目使用:立项与成员配置 + +### 5.1 新建立项会话 + +拥有 `project:create` 权限的角色(研发总监、技术主管)可立项。点击左栏「+ 新建立项会话」: + +![新建立项会话](images/20-create-project.png) + +1. 填写项目名称、项目类型、背景说明等; +2. 选择工作流(见 5.3); +3. 确认后系统创建项目实例,自动进入第一阶段(如 main 工作流的「V0.1 需求摸底」),并生成本阶段要求的受控文档骨架。 + +### 5.2 项目成员与职责分配 + +立项后,打开「项目成员」弹窗,把成员加入项目并分配职责: + +![项目成员与职责](images/21-project-members.png) + +职责与文档审批链直接挂钩(见第 8 章): + +- **编制人**:可编辑受控文档正文、提交送审; +- **审核人**:对送审文档做审核决议(通过/驳回); +- **批准人**:终审批准,批准后文档归档生效。 + +> **职责模式**:项目工作流计划可配置 `full`(编制/审核/批准三人齐备)、`partial`(部分职责)、`none`(免审批链)以及试点环境的「编制人兼任」(同人三签)模式。 + +### 5.3 工作流切换 + +一个项目可挂接多套工作流(如同时推进商务线 main 与产品线 product_manager)。工作流在项目设置的「选择工作流」步骤中选择(列出全部已发布工作流,单选): + +![选择工作流](images/22-workflow-switcher.png) + +| 工作流 | 阶段序列 | +|--------|----------| +| main | V0.1 需求摸底 → V0.5 方案与可行性 → V1.0 商务报价 | +| product_manager | 系统需求基线 | +| test_manager | 系统验证策划 | + +### 5.4 项目设置与职责配置 + +项目设置的「配置职责」步骤中,可将阶段产出文档(如 intake 用户初始需求收集表)纳入人工审批,并通过下拉框指派编制/审核/批准三权责任人: + +![配置职责](images/23-project-settings.png) + +--- + +## 6. 对话与 AI 项目助手 + +### 6.1 发起对话 + +中栏底部为输入区:输入消息,Enter 发送,Shift+Enter 换行。项目助手以 SSE 流式逐字输出回复。 + +下图为与项目助手的真实对话(dev 环境真实 PI Agent 执行): + +![真实对话](images/30-chat-real.png) + +项目助手可以帮你:查询项目进展与风险、沉淀客户需求与技术指标、起草沟通话术与方案要点、推进方案论证与报价核算、编制或修订交付物文档。 + +### 6.2 附件上传 + +每轮对话最多可上传 **5 个附件**(图片、文档等),点击输入区附件图标选择文件: + +![附件上传](images/31-chat-attachment.png) + +### 6.3 意图计划确认卡片 + +当助手识别到需要多步骤执行的任务时,会在对话中给出**执行计划**(步骤清单与选项),你可以直接回复确认(如"好"或"1")让助手按计划执行,或补充说明调整方向。多意图计划的确认/驳回卡片由特性开关控制(当前 dev 环境默认关闭,计划以消息内选项形式呈现,见 6.1 的真实对话截图)。 + +### 6.4 历史记录 + +对话支持历史搜索、日历定位、导出与 Digest 时间线回顾: + +![历史记录](images/33-history.png) + +--- + +## 7. 文档工作台 + +右栏为交付物文档区,顶部分类 Tab 将文档分为三层: + +| 分类 | 说明 | +|------|------| +| 工作流受控 | 工作流要求的正式交付物,走三责审批链,全生命周期留痕 | +| 项目共享 | 项目成员共享的非受控资料 | +| 个人私密 | 仅自己可见的草稿与笔记 | + +### 7.1 块编辑 + +受控文档由「块」组成。双击任意段落进入就地编辑;鼠标右键可唤出快捷菜单(编辑、批注、复制、插块): + +![文档块编辑](images/40-doc-editor.png) + +> **编辑租约锁**:同一文档同一时间仅一人持有编辑租约,避免冲突;他人打开时显示锁定状态。审核中/批准中的文档正文锁定,不可编辑。 + +### 7.2 版本历史与对比 + +文档工具栏可打开版本历史抽屉,查看每次修订版本,支持两版本对比(人工修改以琥珀底+下划线标出,相对最新基线): + +![版本历史](images/41-version-history.png) + +### 7.3 导出 + +交付物支持导出 DOCX / XLSX / Markdown(需要 `doc:export` 权限): + +![导出](images/42-export.png) + +### 7.4 文档可见性 + +默认可见性由工作流配置;有 `doc:override_visibility` 权限的角色(如研发总监)可对单个文档做可见性覆盖: + +![文档可见性](images/43-doc-visibility.png) + +--- + +## 8. 审批全流程 + +系统中有四套审批机制,本章逐一说明。 + +### 8.1 文档三责审批(核心) + +受控文档生命周期: + +```text +编制中(drafting) → 审核中(reviewing) → 批准中(approving) → 已批准(approved) + ↑ ↓ 驳回 + └──── 退回编制 ────┘ +``` + +**第 1 步:编制人认领文档。** 编制人打开受控文档并首次保存正文后,系统自动完成认领(治理操作条显示「编制中 · 已被编制人认领」),获得该文档的编制权: + +![编制中 · 已被编制人认领](images/50-doc-claim.png) + +**第 2 步:编制并提交送审。** 编制完成后点击「提交送审(流转至审核人)」,文档进入「审核中」,正文锁定: + +![提交送审](images/51-doc-submit-review.png) + +**第 3 步:审核人审核。** 审核人在自己的队列中看到待审核文档,可「审核通过(送呈批准人)」或「驳回退回编制」(驳回需说明理由): + +![审核决议](images/52-doc-review.png) + +**第 4 步:批准人终审。** 批准人执行「终审批准生效」;批准后文档到达 `approved` 终态并自动入标杆库。批准人驳回时必须填写理由,可指定回退到编制或审核环节: + +![终审批准](images/53-doc-approve.png) + +> **三责分离守卫**:只有编制人可改正文;审核人/批准人不可编辑;送审只能由编制人发起。试点环境支持「编制人兼任」同人三签模式(同一人员依次完成三签)。 + +### 8.2 阶段门禁推进 + +当前阶段所有「必需且受审批管理」的文档到达已批准后,阶段门禁就绪,顶部出现推进 Banner: + +![阶段门禁就绪](images/54-stage-gate-ready.png) + +**只有项目创建人**可以点击「确认推进」进入下一阶段;门禁未就绪时按钮不可用并列出阻塞项(哪些必需文档未批准): + +![确认推进阶段](images/55-stage-advance.png) + +### 8.3 修改建议审批 + +非编制人员对受控文档可发起「修改建议」(propose),由责任人确认(decide)后合入,全程留痕,不破坏三责分离。 + +### 8.4 注册审批 + +面向管理员,见 4.2。普通用户视角:注册后等待审核,审核通过前登录会收到「等待管理员审核」提示。 + +--- + +## 9. 角色权限速查与典型动线 + +### 9.1 权限速查表 + +| 权限点 | ADMIN | DIRECTOR | TECH_LEAD | ENGINEER | GUEST | +|--------|:-----:|:--------:|:---------:|:--------:|:-----:| +| 立项 project:create | ✓ | ✓ | ✓ | — | — | +| 变更责任人 project:assign_lead | ✓ | ✓ | ✓ | — | — | +| 系统管理 system:manage | ✓ | — | — | — | — | +| 可见性配置 workflow:config_visibility | ✓ | ✓ | — | — | — | +| 可见性覆盖 doc:override_visibility | ✓ | ✓ | ✓ | — | — | +| 导出 doc:export | ✓ | ✓ | ✓ | ✓ | ✓ | +| 查阅全部 workflow:view_all | ✓ | ✓ | ✓ | ✓ | — | + +> 以管理台「RBAC 角色权限」矩阵实时数据为准;自定义角色权限由管理员配置。 + +### 9.2 典型角色的一天 + +**系统管理员**:登录 → 管理台总览看运行状态 → 注册审批中心处理新注册 → 为新员工开通账号并指派角色 → 按需调整特性开关。 + +**研发总监 / 技术主管(项目创建人)**:立项 → 配置成员职责 → 与项目助手对话推进需求 → 关注待批准队列 → 终审批准文档 → 阶段门禁就绪后确认推进。 + +**编制人**:在「我的项目权限」看待我编制清单 → 认领文档 → 块编辑编制内容 → 提交送审 → 如被驳回按意见修改再送审。 + +**审核人**:看待我审核队列 → 审阅送审文档(可批注/提修改建议)→ 通过送呈批准人,或驳回退回编制。 + +**外部审计/访客**:只读查阅被授权的交付物 → 导出留存。 + +--- + +## 10. 常见问题 FAQ + +**Q1:登录提示「账号正在等待管理员审核中」?** +自主注册的账号需要管理员在「注册审批中心」核准。请联系系统管理员。 + +**Q2:忘记密码怎么办?** +联系管理员在「账号列表」中「重置初始密码」,之后用初始密码登录并按提示强制改密。 + +**Q3:登录后看不到管理台入口?** +管理台仅系统管理员可见。其他角色访问会被拦截并退回工作台。 + +**Q4:为什么不能编辑某份受控文档?** +可能原因:你不是该文档编制人;文档处于审核中/批准中(正文锁定);他人持有编辑租约;文档已批准归档(终态只读)。 + +**Q5:「确认推进」按钮不可用?** +阶段门禁未就绪:本阶段还有必需文档未批准。Banner 上会列出阻塞文档清单,先完成其审批链。另外只有项目创建人能推进阶段。 + +**Q6:对话中助手没有推荐案例?** +知识库可能离线。请管理员到「知识库连接」分组检查共享知识库状态。 + +**Q7:附件传不上去?** +每轮对话最多 5 个附件,超出需分轮发送;另请确认文件类型受支持。 + +**Q8:注册 Tab 不见了?** +管理员在「准入策略」中关闭了自主注册。请联系管理员直接开通账号。 + +--- + +*本说明书基于 dev 环境(schema v52)真实操作截图编写。界面与流程以实际环境为准。* diff --git a/docs/user-manual/scripts/.capture-state.json b/docs/user-manual/scripts/.capture-state.json new file mode 100644 index 00000000..9e41bc12 --- /dev/null +++ b/docs/user-manual/scripts/.capture-state.json @@ -0,0 +1,8 @@ +{ + "demoProjectId": 6502, + "uidZhang": 10, + "uidLi": 11, + "uidWang": 12, + "workflowActivated": true, + "intakeDocId": "doc_proj6491_intake" +} \ No newline at end of file diff --git a/docs/user-manual/scripts/build-formats.mjs b/docs/user-manual/scripts/build-formats.mjs new file mode 100644 index 00000000..aa35fb1b --- /dev/null +++ b/docs/user-manual/scripts/build-formats.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// 由 manual.md 生成 HTML / PDF / DOCX 三种格式到 dist/ +// 依赖:pandoc(brew install pandoc)、playwright(复用 web/node_modules) +// 运行:node docs/user-manual/scripts/build-formats.mjs + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MANUAL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DIST = path.join(MANUAL_DIR, 'dist'); +const MD = path.join(MANUAL_DIR, 'manual.md'); +const CSS = path.join(MANUAL_DIR, 'scripts', 'manual.css'); + +fs.mkdirSync(DIST, { recursive: true }); + +function pandoc(args) { + execFileSync('pandoc', args, { cwd: MANUAL_DIR, stdio: 'inherit' }); +} + +// 1) HTML(嵌入图片与样式,单文件可分发) +const htmlPath = path.join(DIST, '系统使用说明书.html'); +pandoc([ + 'manual.md', '-f', 'gfm', '-t', 'html5', '--standalone', + '--embed-resources', `--css=${CSS}`, + '--metadata', 'title=水木东方 · 医疗器械 AI 协同工作台 — 系统使用说明书', + '--toc', '--toc-depth=2', '--shift-heading-level-by=0', + '-o', htmlPath, +]); +console.log('[ok] HTML:', htmlPath); + +// 2) DOCX(pandoc 自动嵌入 images/ 下的图片) +const docxPath = path.join(DIST, '系统使用说明书.docx'); +pandoc(['manual.md', '-f', 'gfm', '--resource-path=.', '-o', docxPath]); +console.log('[ok] DOCX:', docxPath); + +// 3) PDF(主机缺 chromium 系统依赖,改在 playwright docker 镜像内打印) +const pdfPath = path.join(DIST, '系统使用说明书.pdf'); +const REPO = '/home/weichen/projects/Agent-System'; +execFileSync('docker', [ + 'run', '--rm', '-v', `${REPO}:${REPO}`, '-w', `${REPO}/web`, + 'mcr.microsoft.com/playwright:v1.63.0-noble', + 'node', path.join(MANUAL_DIR, 'scripts', 'pdf-print.mjs'), htmlPath, pdfPath, +], { stdio: 'inherit' }); diff --git a/docs/user-manual/scripts/capture-screenshots.mjs b/docs/user-manual/scripts/capture-screenshots.mjs new file mode 100644 index 00000000..0e26c364 --- /dev/null +++ b/docs/user-manual/scripts/capture-screenshots.mjs @@ -0,0 +1,1158 @@ +#!/usr/bin/env node +/** + * 用户说明书 UI 截图采集脚本(真实 dev 环境 http://127.0.0.1:8080) + * + * 运行方式(必须在 web/ 目录下,以便 resolve playwright): + * cd /home/weichen/projects/Agent-System/web + * node ../../.worktrees/feature-user-manual/docs/user-manual/scripts/capture-screenshots.mjs [section...] + * + * section 可选:onboarding workbench project chat doc docgov admin all(默认 all) + * 例:只重跑管理台 → node capture-screenshots.mjs admin + * + * 红线:不修改/删除既有项目数据(项目 143 只读);演示数据统一前缀「说明书演示」。 + */ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// playwright 装在主仓 web/node_modules;脚本本身在 worktree 内,需显式指向解析基准 +const WEB_DIR = '/home/weichen/projects/Agent-System/web'; +const { chromium } = createRequire(path.join(WEB_DIR, 'package.json'))('playwright'); +const OUT = path.resolve(__dirname, '../images'); +const STATE_FILE = path.join(__dirname, '.capture-state.json'); +fs.mkdirSync(OUT, { recursive: true }); + +const BASE = 'http://127.0.0.1:8080'; +const DEMO_PREFIX = '说明书演示'; +const DEMO_PROJECT_NAME = '说明书演示-超声骨刀系统'; +const DEMO_PROJECT_DESC = + '用于骨科微创手术的双频超声吸引与止血设备,需符合 GB 9706.1 与 NMPA 注册法规要求。(说明书截图演示项目)'; +const CHAT_PROMPT = '请帮我梳理一下这个项目的用户需求收集表应该包含哪些内容'; + +const USERS = { + admin: { username: 'admin', password: 'admin-secret-password-123' }, + zhanggong: { username: 'zhanggong', password: '123456' }, + lizhuguan: { username: 'lizhuguan', password: '123456' }, + wangzong: { username: 'wangzong', password: '123456' }, +}; + +const results = []; +function record(file, ok, note = '') { + results.push({ file, ok, note }); + console.log(`${ok ? '[OK]' : '[..]'} ${file}${note ? ' — ' + note : ''}`); +} + +function loadState() { + try { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + } catch { + return {}; + } +} +function saveState(patch) { + const st = { ...loadState(), ...patch }; + fs.writeFileSync(STATE_FILE, JSON.stringify(st, null, 2), 'utf-8'); +} + +// ─── API helpers ─── +async function apiLogin(username, password) { + const res = await fetch(`${BASE}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) throw new Error(`login ${username} failed: HTTP ${res.status}`); + return (await res.json()).token; +} +async function api(token, method, p, body) { + const res = await fetch(`${BASE}${p}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + /* keep text */ + } + return { status: res.status, json, text }; +} + +// ─── browser helpers ─── +let browser; +async function newPage() { + const context = await browser.newContext({ + viewport: { width: 1600, height: 900 }, + deviceScaleFactor: 1.5, + locale: 'zh-CN', + }); + const page = await context.newPage(); + page.setDefaultTimeout(30_000); + return { context, page }; +} + +async function settle(page, ms = 600) { + await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {}); + await page.waitForTimeout(ms); +} + +async function shot(page, name, opts = {}) { + const file = path.join(OUT, name); + await page.screenshot({ path: file, fullPage: false, ...opts }); + return name; +} + +async function uiLogin(page, username, password) { + await page.goto(BASE + '/'); + await page.fill('input[placeholder*="系统用户名"]', username); + await page.fill('input[placeholder*="登录密码"]', password); + await page.click('button:has-text("进入医疗协同工作台")'); +} + +async function uiLoginWorkbench(page, username, password) { + await uiLogin(page, username, password); + await page.getByText('新建立项会话').waitFor({ timeout: 30_000 }); + await settle(page, 1200); +} + +async function closeModal(page) { + for (const name of ['关闭面板', '关闭', '取消']) { + const b = page.getByRole('button', { name, exact: true }).last(); + if (await b.isVisible({ timeout: 800 }).catch(() => false)) { + await b.click(); + await page.waitForTimeout(400); + return; + } + } + // 多数弹窗支持点击遮罩(backdrop)关闭:点左上角空白处 + await page.mouse.click(8, 8); + await page.waitForTimeout(400); + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); +} + +/** 在左栏选中指定项目会话;不存在则通过立项弹窗创建(含成员职责分配)。 */ +async function ensureDemoProject(page, projectName = DEMO_PROJECT_NAME) { + const aside = page.locator('aside'); + const existing = aside.getByText(projectName, { exact: false }).first(); + if (await existing.isVisible({ timeout: 3000 }).catch(() => false)) { + await existing.click(); + await page.waitForTimeout(1500); + await settle(page); + return false; + } + await page.getByRole('button', { name: '新建立项会话' }).click(); + const heading = page.getByText('发起医疗器械新项目立项', { exact: true }); + await heading.waitFor({ timeout: 30_000 }); + const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); + await modal.locator('input[placeholder*="无线便携式"]').fill(projectName); + await modal.locator('textarea').first().fill(DEMO_PROJECT_DESC); + for (const [name, role] of [ + ['李主管', 'reviewer'], + ['王总监', 'approver'], + ]) { + const memberName = modal.getByText(name, { exact: true }); + if (!(await memberName.isVisible({ timeout: 5000 }).catch(() => false))) continue; + await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click(); + const container = memberName.locator( + 'xpath=ancestor::div[contains(@style,"justify-content: space-between")][1]', + ); + await container.locator('select').selectOption(role); + } + await modal.getByRole('button', { name: /确认立项/ }).click(); + await heading.waitFor({ state: 'hidden', timeout: 15_000 }); + await page.locator('header').getByText(projectName).waitFor({ timeout: 30_000 }); + await settle(page, 1500); + return true; +} + +/** 按项目名查 project_id(以张工会话列表为准)。 */ +async function findProjectIdByName(projectName) { + const token = await apiLogin(USERS.zhanggong.username, USERS.zhanggong.password); + const sessRes = await api(token, 'GET', '/api/v1/chat/sessions'); + const sessions = sessRes.json?.sessions || sessRes.json || []; + const hit = (Array.isArray(sessions) ? sessions : []).find((s) => + (s.title || s.project_name || '').includes(projectName), + ); + return hit?.project_id || null; +} + +/** 项目工作流配置弹窗:规划态下完成 选择→配置职责→激活。captureShots 时产出 22/23。 */ +async function configureAndActivateWorkflow(page, { captureShots = false } = {}) { + const cfgBtn = page.getByRole('button', { name: '配置工作流' }); + if (!(await cfgBtn.isVisible({ timeout: 5000 }).catch(() => false))) return false; + await cfgBtn.click(); + await page.getByText('项目工作流配置').waitFor({ timeout: 10_000 }); + await settle(page, 1500); + // 若已有规划草稿,重开弹窗会直接进入 configure 步骤;按当前步骤自适应 + const nextBtn = page.getByRole('button', { name: /下一步/ }); + if (await nextBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + if (captureShots) { + await shot(page, '22-workflow-switcher.png'); + record('22-workflow-switcher.png', true, '工作流切换控件展开态(项目设置-选择工作流步骤)'); + } + await nextBtn.click(); + await page.waitForTimeout(2000); + } else if (captureShots) { + record('22-workflow-switcher.png', true, '已有规划草稿直达配置步骤(沿用首次截图)'); + } + await page.getByRole('button', { name: /校验并预览/ }).waitFor({ timeout: 10_000 }); + // configure 步骤:勾选《用户初始需求收集表》的「纳入人工审批」,再指派 编制=张工 审核=李主管 批准=王总监 + const intakeRow = page + .locator('xpath=//div[.//text()="用户初始需求收集表" and .//text()="纳入人工审批"]') + .last(); + await intakeRow.scrollIntoViewIfNeeded(); + const approvalLabel = intakeRow.locator('label', { hasText: '纳入人工审批' }).first(); + const approvalBox = approvalLabel.locator('input[type="checkbox"]'); + if (!(await approvalBox.isChecked().catch(() => false))) { + await approvalLabel.click(); + await page.waitForTimeout(800); + } + const dutySelects = intakeRow.locator('select'); + const dn = await dutySelects.count(); + const st0 = loadState(); + for (let i = 0; i < dn; i++) { + const sel = dutySelects.nth(i); + const firstOpt = ((await sel.locator('option').first().innerText().catch(() => '')) || '').trim(); + const uid = firstOpt.startsWith('作者') + ? st0.uidZhang + : firstOpt.startsWith('审核') + ? st0.uidLi + : firstOpt.startsWith('批准') + ? st0.uidWang + : null; + if (!uid) continue; + // 带校验重试:React 重渲染可能导致 selectOption 未生效 + for (let attempt = 0; attempt < 3; attempt++) { + await sel.selectOption({ value: String(uid) }).catch(() => {}); + if ((await sel.inputValue().catch(() => '')) === String(uid)) break; + await page.waitForTimeout(600); + } + } + await settle(page, 500); + for (let i = 0; i < dn; i++) { + const v = await dutySelects.nth(i).inputValue().catch(() => ''); + if (!v) console.log(`职责下拉 ${i} 未指派成功`); + } + if (captureShots) { + await shot(page, '23-project-settings.png'); + record('23-project-settings.png', true, '项目设置弹窗(配置职责步骤:三权指派)'); + } + await page.getByRole('button', { name: /校验并预览/ }).click(); + await page.waitForTimeout(2500); + const activateBtn = page.getByRole('button', { name: /确认激活/ }); + if (!(await activateBtn.isVisible({ timeout: 8000 }).catch(() => false))){ + const errText = await page.locator('[role="alert"]').innerText().catch(() => ''); + record('工作流激活', false, `确认激活按钮未出现 ${errText}`); + await closeModal(page); + return false; + } + await activateBtn.click(); + await page.getByText('项目工作流配置').waitFor({ state: 'hidden', timeout: 15_000 }); + await settle(page, 1500); + console.log('工作流已激活'); + return true; +} + +/** 打开当前项目的 intake 受控文档,返回 docId。 */ +async function openIntakeDoc(page) { + const catTrigger = page.getByTestId('doc-category-trigger'); + await catTrigger.waitFor({ timeout: 15_000 }); + await catTrigger.click(); + const catItem = page.locator('.dropdown-menu button[data-category="workflow"]').first(); + if (await catItem.count()) await catItem.click(); + await page.waitForTimeout(500); + const trigger = page.getByTestId('doc-selector-trigger'); + await trigger.waitFor({ timeout: 15_000 }); + await trigger.click(); + const item = page + .locator('.dropdown-menu button[data-doc-id]') + .filter({ hasText: /用户初始需求|需求摸底|intake/i }) + .first(); + await item.waitFor({ timeout: 15_000 }); + const docId = await item.getAttribute('data-doc-id'); + await item.click(); + await page.waitForTimeout(1200); + await settle(page); + return docId; +} + +/** 定位本轮对话的 assistant 气泡并等待终态。 */ +async function waitChatTerminal(page, promptSnippet, timeoutMs = 180_000) { + const userBubble = page + .locator('[data-chat-role="user"]') + .filter({ hasText: promptSnippet }) + .last(); + await userBubble.waitFor({ timeout: 30_000 }); + const assistant = userBubble.locator( + 'xpath=ancestor::*[@data-index][1]/following-sibling::*[@data-index][1]//*[@data-chat-role="assistant"]', + ); + await assistant.waitFor({ timeout: 60_000 }); + const TERMINAL = + /^(completed|pending_author_confirmation|failed|cancelled|quality_failed|blocked_pending_governance)$/; + try { + await page.waitForFunction( + ({ re }) => { + const els = document.querySelectorAll('[data-chat-role="assistant"]'); + const last = els[els.length - 1]; + return last && new RegExp(re).test(last.getAttribute('data-chat-status') || ''); + }, + { re: TERMINAL.source }, + { timeout: timeoutMs }, + ); + const status = await page + .locator('[data-chat-role="assistant"]') + .last() + .getAttribute('data-chat-status'); + return { done: true, status }; + } catch { + const status = await page + .locator('[data-chat-role="assistant"]') + .last() + .getAttribute('data-chat-status') + .catch(() => null); + return { done: false, status }; + } +} + +/** 共享 dev 环境偶发账号被停用:登录前确保演示账号 active */ +async function ensureUsersActive(usernames) { + const token = await apiLogin(USERS.admin.username, USERS.admin.password); + const users = (await api(token, 'GET', '/api/v1/admin/users')).json?.users || []; + for (const name of usernames) { + const u = users.find((x) => x.username === name); + if (u && u.status !== 'active') { + await api(token, 'PUT', `/api/v1/admin/users/${u.id}/status`, { status: 'active' }); + console.log(`账号 ${name} 已恢复 active`); + } + } +} + +/** 确保演示项目成员齐备(创建弹窗的成员勾选不可靠,用 API 兜底;幂等) */ +async function ensureDemoMembers(projectId) { + const token = await apiLogin(USERS.zhanggong.username, USERS.zhanggong.password); + let pid = projectId; + if (!pid) { + const sessRes = await api(token, 'GET', '/api/v1/chat/sessions'); + const sessions = sessRes.json?.sessions || sessRes.json || []; + const demo = (Array.isArray(sessions) ? sessions : []).find((s) => + (s.title || s.project_name || '').includes(DEMO_PREFIX), + ); + if (!demo) return null; + pid = demo.project_id; + } + const members = (await api(token, 'GET', `/api/v1/projects/${pid}/members`)).json?.members || []; + const have = new Set(members.map((m) => m.user_id)); + // 解析演示账号 ID + const adminToken = await apiLogin(USERS.admin.username, USERS.admin.password); + const users = (await api(adminToken, 'GET', '/api/v1/admin/users')).json?.users || []; + const idOf = (uname) => users.find((u) => u.username === uname)?.id; + const wanted = [ + { uname: 'lizhuguan', duty: 'reviewer' }, + { uname: 'wangzong', duty: 'approver' }, + ]; + for (const w of wanted) { + const uid = idOf(w.uname); + if (!uid) continue; + if (!have.has(uid)) { + await api(token, 'POST', `/api/v1/projects/${pid}/members`, { + user_id: uid, + project_role: 'member', + document_duty: w.duty, + }); + console.log(`项目 ${pid} 添加成员 ${w.uname}(${uid}) duty=${w.duty}`); + } + } + saveState({ + ...(projectId ? {} : { demoProjectId: pid }), + uidZhang: idOf('zhanggong'), + uidLi: idOf('lizhuguan'), + uidWang: idOf('wangzong'), + }); + return pid; +} + +// ─── sections ─── + +async function secOnboarding() { + // 01 登录页(账号密码 Tab) + { + const { context, page } = await newPage(); + await page.goto(BASE + '/'); + await page.locator('input[placeholder*="系统用户名"]').waitFor({ timeout: 20_000 }); + await settle(page, 800); + await shot(page, '01-login.png'); + record('01-login.png', true, '登录页-账号密码 Tab'); + + // 02 注册 Tab + const regTab = page.getByRole('button', { name: /用户自主注册/ }); + if (await regTab.isVisible({ timeout: 3000 }).catch(() => false)) { + await regTab.click(); + await page.locator('input[placeholder="如 zhang_san"]').fill('demo02'); + await page.locator('input[placeholder="如 张三"]').fill('演示用户02'); + await page.locator('input[placeholder="至少 6 位字符"]').fill('123456'); + await page.locator('input[placeholder="再次输入密码"]').fill('123456'); + await page.locator('input[placeholder="如 研发中心 / 临床部"]').fill('研发中心'); + await page.locator('input[placeholder="如 EMP-888"]').fill('EMP-102'); + await settle(page, 400); + await shot(page, '02-login-register.png'); + record('02-login-register.png', true, '注册 Tab 填好示例值(未提交)'); + } else { + record('02-login-register.png', false, '注册 Tab 不可见(服务端已关闭自主注册)'); + } + await context.close(); + } + + // 03 首次登录强制改密 + { + const adminToken = await apiLogin(USERS.admin.username, USERS.admin.password); + let demoUser = 'demo01'; + const usersRes = await api(adminToken, 'GET', '/api/v1/admin/users'); + const users = usersRes.json?.users || []; + if (users.some((u) => u.username === 'demo01')) { + // 已存在:若已完成改密则换后缀账号 + demoUser = users.some((u) => u.username === 'demo01') ? 'demo01' : demoUser; + } else { + const cr = await api(adminToken, 'POST', '/api/v1/admin/users', { + username: 'demo01', + name: '演示员工01', + initial_password: 'InitPass123', + role_id: 'role-engineer', + position: '方案工程师', + department: '研发中心', + }); + if (cr.status >= 400) throw new Error(`create demo01 failed: ${cr.text}`); + } + const { context, page } = await newPage(); + await uiLogin(page, demoUser, 'InitPass123'); + const force = page.getByPlaceholder('请输入初始密码'); + if (await force.isVisible({ timeout: 15_000 }).catch(() => false)) { + await page.fill('input[placeholder="请输入初始密码"]', 'InitPass123'); + await settle(page, 400); + await shot(page, '03-first-login-password.png'); + record('03-first-login-password.png', true, '首次登录强制改密弹窗'); + } else { + // demo01 已完成过改密:另建 demo01b + await context.close(); + const alt = 'demo01b'; + const users2 = (await api(adminToken, 'GET', '/api/v1/admin/users')).json?.users || []; + if (!users2.some((u) => u.username === alt)) { + await api(adminToken, 'POST', '/api/v1/admin/users', { + username: alt, + name: '演示员工01', + initial_password: 'InitPass123', + role_id: 'role-engineer', + position: '方案工程师', + department: '研发中心', + }); + } + const { context: c2, page: p2 } = await newPage(); + await uiLogin(p2, alt, 'InitPass123'); + await p2.getByPlaceholder('请输入初始密码').waitFor({ timeout: 15_000 }); + await p2.fill('input[placeholder="请输入初始密码"]', 'InitPass123'); + await settle(p2, 400); + await shot(p2, '03-first-login-password.png'); + record('03-first-login-password.png', true, `首次登录强制改密弹窗(账号 ${alt})`); + await c2.close(); + return; + } + await context.close(); + } +} + +async function secWorkbench() { + await ensureUsersActive(['zhanggong']); + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); + await ensureDemoProject(page); + await ensureDemoMembers(); + // 等聊天输入区与文档区就绪 + await page.getByPlaceholder(/向项目助手提问/).waitFor({ timeout: 20_000 }); + await settle(page, 1500); + + await shot(page, '10-workbench-overview.png'); + record('10-workbench-overview.png', true, '三栏工作台总览(张工)'); + + await page.locator('header').screenshot({ path: path.join(OUT, '11-topbar.png') }); + record('11-topbar.png', true, '顶栏特写(当前项目 + 项目助手在线)'); + + await page.locator('aside').screenshot({ path: path.join(OUT, '12-left-rail.png') }); + record('12-left-rail.png', true, '左栏项目会话列表特写'); + + // 13 个人设置 + await page.locator('button[title="个人资料与系统偏好设置"]').click(); + await page.waitForTimeout(1000); + await settle(page, 500); + await shot(page, '13-user-settings.png'); + record('13-user-settings.png', true, '个人设置弹窗'); + await closeModal(page); + + // 14 我的项目权限 + await page.locator('div[title="点击查看账号信息与当前项目各文档法定权限矩阵"]').click(); + await page.getByText('当前账号与项目职责权限矩阵').waitFor({ timeout: 10_000 }); + await settle(page, 600); + await shot(page, '14-my-rights.png'); + record('14-my-rights.png', true, '我的项目权限弹窗'); + await closeModal(page); + + await context.close(); +} + +async function secProject() { + await ensureUsersActive(['zhanggong']); + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); + await ensureDemoProject(page); + await ensureDemoMembers(); + + // 20 新建立项会话弹窗(填好示例,不提交) + await page.getByRole('button', { name: '新建立项会话' }).click(); + const heading = page.getByText('发起医疗器械新项目立项', { exact: true }); + await heading.waitFor({ timeout: 30_000 }); + const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); + await modal.locator('input[placeholder*="无线便携式"]').fill('说明书演示-高频电刀系统'); + await modal + .locator('textarea') + .first() + .fill('用于普外科高频切割与凝血,需符合 GB 9706.1 与 YY 1057 标准要求。(示例填写,未提交)'); + const memberName = modal.getByText('李主管', { exact: true }); + if (await memberName.isVisible({ timeout: 5000 }).catch(() => false)) { + await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click(); + } + await settle(page, 600); + await shot(page, '20-create-project.png'); + record('20-create-project.png', true, '新建立项会话弹窗(示例已填,未提交)'); + const cancel = modal.getByRole('button', { name: '取消', exact: true }); + if (await cancel.isVisible().catch(() => false)) await cancel.click(); + else await page.keyboard.press('Escape'); + await page.waitForTimeout(600); + + // 21 项目成员弹窗 + await page.locator('aside').getByText('项目成员', { exact: true }).click(); + await page.getByText('项目成员与职责权限管理').waitFor({ timeout: 10_000 }); + await settle(page, 800); + await shot(page, '21-project-members.png'); + record('21-project-members.png', true, '项目成员与职责分配弹窗'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(400); + // 成员弹窗点遮罩关闭 + const membersModal = page.getByText('项目成员与职责权限管理'); + if (await membersModal.isVisible().catch(() => false)) { + await page.mouse.click(20, 450); + await page.waitForTimeout(400); + } + + // 22/23 项目工作流配置弹窗(规划态:选择→配置职责→激活;已激活:只读视图) + const eyeBtn = page.locator('button[title="查看工作流设置"]'); + const activated = await configureAndActivateWorkflow(page, { captureShots: true }); + if (activated) { + saveState({ workflowActivated: true }); + } else if (await eyeBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + // 已激活(重跑场景):顶栏切换器特写 + 冻结只读设置视图 + await shot(page, '22-workflow-switcher.png', { clip: { x: 0, y: 0, width: 1600, height: 52 } }); + record('22-workflow-switcher.png', true, '顶栏工作流切换器(运行中状态,重跑替代视角)'); + await eyeBtn.click(); + await page.getByText('项目工作流配置').waitFor({ timeout: 10_000 }); + await settle(page, 800); + await shot(page, '23-project-settings.png'); + record('23-project-settings.png', true, '项目设置弹窗(已激活只读视图,重跑替代视角)'); + await closeModal(page); + } else { + record('22-workflow-switcher.png', false, '未定位到工作流设置入口'); + record('23-project-settings.png', false, '未定位到工作流设置入口'); + } + + await context.close(); +} + +const CLAIM_PROJECT_NAME = '说明书演示-内窥镜止血系统'; + +/** 为李主管/王总监注册项目私聊会话(等价于 UI 添加成员时的 upsertChatSession;幂等) */ +async function ensureMemberSessions(pid) { + const st = loadState(); + for (const [key, uid] of [ + ['lizhuguan', st.uidLi], + ['wangzong', st.uidWang], + ]) { + if (!uid || !pid) continue; + const token = await apiLogin(USERS[key].username, USERS[key].password); + const list = (await api(token, 'GET', `/api/v1/chat/sessions?user_id=${uid}&limit=100`)).json || []; + if (!list.some((s) => s.project_id === pid)) { + await api(token, 'POST', '/api/v1/chat/sessions', { + session_id: `sess-p${pid}-u${uid}`, + title: DEMO_PROJECT_NAME, + user_id: uid, + project_id: pid, + }); + console.log(`已为 ${key} 注册项目 ${pid} 会话`); + } + } +} + +/** + * 50-doc-claim 补拍专用:认领按钮要求「受控 + 非模板 + 编制人未认领」。 + * 主项目文档已被编制(保存正文即自动认领),故用第二个演示项目走真实生成链路, + * 编制人不编辑、直接打开文档捕获「认领此文档」按钮。 + */ +async function secClaim50() { + await ensureUsersActive(['zhanggong']); + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); + await ensureDemoProject(page, CLAIM_PROJECT_NAME); + const pid = await findProjectIdByName(CLAIM_PROJECT_NAME); + if (pid) await ensureDemoMembers(pid); + // 激活工作流(intake 纳入人工审批 + 三权指派) + await configureAndActivateWorkflow(page, { captureShots: false }); + + // 让 PI 真实生成 intake 受控交付物(如已生成则跳过) + let docId = null; + const zhangToken = await apiLogin(USERS.zhanggong.username, USERS.zhanggong.password); + if (pid) { + const docs = (await api(zhangToken, 'GET', `/api/v1/projects/${pid}/documents?category=workflow`)).json || []; + const intake = docs.find((d) => d.task_key === 'intake'); + if (intake && !intake.is_template_only) docId = intake.doc_id; + } + if (!docId) { + const box = page.getByPlaceholder(/向项目助手提问/); + const genPrompt = '请基于本项目编制首轮《用户初始需求收集表》受控交付物,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; + await box.fill(genPrompt); + await page.getByRole('button', { name: /发送/ }).click(); + const term = await waitChatTerminal(page, genPrompt.slice(0, 12), 300_000); + console.log(`claim50 编制指令终态: ${term.status} done=${term.done}`); + await page.waitForTimeout(3000); + } + // 刷新后重新进入(拿到最新文档清单),打开 intake 文档但不编辑 + await page.reload(); + await page.getByText('新建立项会话').waitFor({ timeout: 30_000 }); + await settle(page, 1500); + await page.locator('aside').getByText(CLAIM_PROJECT_NAME, { exact: false }).first().click(); + await page.waitForTimeout(2000); + await openIntakeDoc(page); + const claimBtn = page.getByRole('button', { name: /认领此文档/ }); + if (await claimBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { + await settle(page, 500); + await shot(page, '50-doc-claim.png'); + record('50-doc-claim.png', true, '编制人视角「认领此文档」(认领演示项目真实状态)'); + await claimBtn.click(); + await page.waitForTimeout(1200); + } else { + // 替代方案:产品当前逻辑下编制人打开/保存非模板文档即自动认领(服务端 author_claimed 与前端 isClaimed 联动), + // 「认领此文档」按钮不再可达;截取编制人视角的编制中状态栏(含「已被编制人认领」徽标与「提交送审」按钮)。 + const claimedBadge = page.getByText('已被编制人认领'); + if (await claimedBadge.isVisible({ timeout: 5000 }).catch(() => false)) { + await settle(page, 500); + await shot(page, '50-doc-claim.png'); + record( + '50-doc-claim.png', + true, + '替代视角:编制中状态栏(已被编制人认领 + 提交送审);认领按钮因自动认领逻辑不可达', + ); + } else { + await shot(page, '50-doc-claim-debug.png'); + record('50-doc-claim.png', false, '认领按钮与已认领徽标均未出现(调试图见 50-doc-claim-debug.png)'); + } + } + await context.close(); +} + +async function secChat() { + await ensureUsersActive(['zhanggong']); + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); + await ensureDemoProject(page); + + // 30 真实 PI 对话 + const box = page.getByPlaceholder(/向项目助手提问/); + await box.waitFor({ timeout: 20_000 }); + // 已有相同对话则直接截图,避免重复发问 + const existing = page.locator('[data-chat-role="user"]').filter({ hasText: CHAT_PROMPT.slice(0, 12) }); + if (await existing.first().isVisible({ timeout: 3000 }).catch(() => false)) { + const term = await waitChatTerminal(page, CHAT_PROMPT.slice(0, 12), 10_000); + await settle(page, 800); + await shot(page, '30-chat-real.png'); + record('30-chat-real.png', true, `真实 PI 对话(复用已有会话,状态 ${term.status})`); + } else { + await box.fill(CHAT_PROMPT); + await page.getByRole('button', { name: /发送/ }).click(); + const term = await waitChatTerminal(page, CHAT_PROMPT.slice(0, 12), 180_000); + await settle(page, 1000); + // 滚动到对话底部 + await page.mouse.wheel(0, 3000); + await page.waitForTimeout(500); + await shot(page, '30-chat-real.png'); + record( + '30-chat-real.png', + term.done, + term.done + ? `真实 PI 对话完成(状态 ${term.status})` + : `3 分钟内未完成,截流式进行中状态(当前 ${term.status || '未知'})`, + ); + } + + // 32 意图计划确认卡片(自然出现才截) + const planCard = page.getByTestId('plan-confirm-card'); + if (await planCard.first().isVisible({ timeout: 3000 }).catch(() => false)) { + await shot(page, '32-intent-plan-card.png'); + record('32-intent-plan-card.png', true, '意图计划确认卡片'); + } else { + record('32-intent-plan-card.png', false, '本轮对话未出现意图计划确认卡片(缺图)'); + } + + // 31 附件上传状态(不发送) + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'manual-shot-')); + const f1 = path.join(tmpDir, '用户需求访谈纪要.txt'); + const f2 = path.join(tmpDir, '竞品参数对照表.csv'); + fs.writeFileSync(f1, '用户需求访谈纪要(示例附件)\n1. 预期用途:骨科微创手术超声吸引。\n2. 目标用户:外科医生。\n'); + fs.writeFileSync(f2, '参数,竞品A,竞品B\n频率,kHz,25,36\n功率,W,120,150\n'); + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles([f1, f2]); + await page.getByText('用户需求访谈纪要.txt').waitFor({ timeout: 10_000 }); + await settle(page, 500); + await shot(page, '31-chat-attachment.png'); + record('31-chat-attachment.png', true, '输入区附件上传状态(2 个文件,未发送)'); + // 清理附件 + for (const b of await page.locator('button[title="移除此文档"]').all()) { + await b.click().catch(() => {}); + } + + // 33 历史记录/搜索 + await page.locator('button[title*="搜索历史对话"]').click(); + await page.waitForTimeout(800); + const searchInput = page.locator('input[placeholder*="搜索"], input[placeholder*="关键词"]').first(); + if (await searchInput.isVisible({ timeout: 5000 }).catch(() => false)) { + await searchInput.fill('需求'); + await page.waitForTimeout(1500); + } + await settle(page, 500); + await shot(page, '33-history.png'); + record('33-history.png', true, '历史记录搜索界面(关键词「需求」)'); + await closeModal(page); + + await context.close(); +} + +async function secDoc() { + await ensureUsersActive(['zhanggong']); + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); + await ensureDemoProject(page); + + // intake 受控文档可能尚未生成(选择器 0 份):先让 PI 真实编制首轮交付物 + let docId = await openIntakeDoc(page).catch(() => null); + if (!docId) { + console.log('受控文档尚未生成,发送真实编制指令…'); + const box = page.getByPlaceholder(/向项目助手提问/); + const genPrompt = '请基于本项目编制首轮《用户初始需求收集表》受控交付物,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; + await box.fill(genPrompt); + await page.getByRole('button', { name: /发送/ }).click(); + const term = await waitChatTerminal(page, genPrompt.slice(0, 12), 300_000); + console.log(`编制指令终态: ${term.status} done=${term.done}`); + await page.waitForTimeout(3000); + docId = await openIntakeDoc(page).catch(() => null); + } + if (docId) saveState({ intakeDocId: docId }); + + // 40 文档块编辑态 + const pane = page.getByTestId('active-document-pane'); + let edited = false; + const tryEdit = async () => { + const block = pane.getByTestId('document-block').first(); + if (!(await block.isVisible({ timeout: 10_000 }).catch(() => false))) return false; + await block.dblclick(); + const dialog = page.getByRole('dialog', { name: '编辑受控块' }); + if (!(await dialog.isVisible({ timeout: 8000 }).catch(() => false))) return false; + const editor = dialog.locator('textarea, [contenteditable="true"]').first(); + const cur = await editor.inputValue().catch(() => ''); + if (!cur.includes('说明书截图演示')) { + await editor.fill((cur ? cur + '\n\n' : '') + '本段落用于用户说明书截图演示编辑态。'); + } + await settle(page, 400); + await shot(page, '40-doc-editor.png'); + record('40-doc-editor.png', true, '文档块编辑弹窗(双击段落进入)'); + await dialog.getByRole('button', { name: '保存修改', exact: true }).click(); + await page.waitForTimeout(1500); + return true; + }; + edited = await tryEdit(); + if (!edited) { + // 模板预览态无法编辑:让 PI 真实生成首轮受控交付物后重试 + console.log('模板预览态,发送真实编制指令生成交付物…'); + const box = page.getByPlaceholder(/向项目助手提问/); + const genPrompt = '请基于本项目编制首轮《用户初始需求收集表》受控交付物,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; + await box.fill(genPrompt); + await page.getByRole('button', { name: /发送/ }).click(); + const term = await waitChatTerminal(page, genPrompt.slice(0, 12), 240_000); + console.log(`编制指令终态: ${term.status} done=${term.done}`); + await openIntakeDoc(page); + await page.waitForTimeout(2000); + edited = await tryEdit(); + } + if (!edited) record('40-doc-editor.png', false, '文档块编辑弹窗未能打开'); + + // 41 版本历史抽屉 + const histBtn = page.getByRole('button', { name: '版本历史', exact: true }); + if (await histBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await histBtn.click(); + const drawer = page.getByRole('dialog', { name: '版本历史' }); + await drawer.waitFor({ timeout: 10_000 }); + await settle(page, 500); + await shot(page, '41-version-history.png'); + record('41-version-history.png', true, '版本历史抽屉'); + await drawer.getByRole('button', { name: '关闭' }).click(); + } else { + record('41-version-history.png', false, '版本历史按钮不可用(版本账本未就绪)'); + } + + // 42 导出菜单展开 + const exportBtn = page.locator('button[title^="导出交付物"]'); + if (await exportBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await exportBtn.click(); + await page.getByText('选择交付物导出渲染格式').waitFor({ timeout: 5000 }); + await settle(page, 300); + await shot(page, '42-export.png'); + record('42-export.png', true, '导出菜单展开(DOCX/XLSX/Markdown)'); + await page.keyboard.press('Escape'); + await page.mouse.click(800, 500); + } else { + record('42-export.png', false, '导出入口不可见'); + } + + // 43 文档可见性设置弹窗 + const visBtn = page.getByRole('button', { name: /权限覆盖|已设权限覆盖/ }); + if (await visBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await visBtn.click(); + await page.getByText(/单文档可见性覆盖策略/).waitFor({ timeout: 10_000 }); + await settle(page, 500); + await shot(page, '43-doc-visibility.png'); + record('43-doc-visibility.png', true, '文档可见性设置弹窗'); + await closeModal(page); + } else { + record('43-doc-visibility.png', false, '权限覆盖按钮不可见(当前文档/角色无入口)'); + } + + await context.close(); +} + +async function docgovState(token, docId) { + const r = await api(token, 'GET', `/api/v1/docgov/document?doc_id=${encodeURIComponent(docId)}`); + return r.status === 200 ? r.json : null; +} + +async function openDemoAndDoc(page) { + await ensureDemoProject(page); + return openIntakeDoc(page); +} + +async function secDocgov() { + await ensureUsersActive(['zhanggong','lizhuguan','wangzong']); + await ensureDemoMembers(); + await ensureMemberSessions(loadState().demoProjectId); + const zhangToken = await apiLogin(USERS.zhanggong.username, USERS.zhanggong.password); + const liToken = await apiLogin(USERS.lizhuguan.username, USERS.lizhuguan.password); + const wangToken = await apiLogin(USERS.wangzong.username, USERS.wangzong.password); + + // 先拿到 docId:优先 state 文件,否则用张工的 UI 打开一次 + let docId = loadState().intakeDocId || null; + if (!docId) { + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); + docId = await openDemoAndDoc(page); + await context.close(); + if (docId) saveState({ intakeDocId: docId }); + } + if (!docId) { + record('50~55', false, '未能定位 intake 受控文档 docId'); + return; + } + const rec0 = await docgovState(zhangToken, docId); + const state = rec0?.current_state || 'drafting'; + const claimed = Boolean(rec0?.author_claimed); + console.log(`docgov: docId=${docId} state=${state} claimed=${claimed} duties=${rec0?.author_name}/${rec0?.reviewer_name}/${rec0?.approver_name}`); + + // 50 编制人认领 + if (state === 'drafting' && !claimed) { + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); + await openDemoAndDoc(page); + const claimBtn = page.getByRole('button', { name: /认领此文档/ }); + if (await claimBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { + await settle(page, 500); + await shot(page, '50-doc-claim.png'); + record('50-doc-claim.png', true, '编制人视角「认领此文档」'); + await claimBtn.click(); + await page.waitForTimeout(1500); + } else { + record('50-doc-claim.png', false, '认领按钮不可见(职责未分配或状态不符)'); + } + // 51 提交送审 + const submitBtn = page.getByRole('button', { name: /提交送审/ }); + if (await submitBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { + await settle(page, 500); + await shot(page, '51-doc-submit-review.png'); + record('51-doc-submit-review.png', true, '编制人「提交送审」(DocGovActionBar)'); + await submitBtn.click(); + await page.waitForTimeout(1500); + } else { + record('51-doc-submit-review.png', false, '提交送审按钮不可见'); + } + await context.close(); + } else if (state === 'drafting' && claimed) { + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); + await openDemoAndDoc(page); + const submitBtn = page.getByRole('button', { name: /提交送审/ }); + if (await submitBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { + await shot(page, '51-doc-submit-review.png'); + record('51-doc-submit-review.png', true, '编制人「提交送审」(已认领续跑)'); + await submitBtn.click(); + await page.waitForTimeout(1500); + } + await context.close(); + } else { + record('50-doc-claim.png / 51-doc-submit-review.png', false, `文档已不在编制态(${state}),跳过(沿用已有截图)`); + } + + const rec1 = await docgovState(zhangToken, docId); + // 52 审核人审核 + if (rec1?.current_state === 'reviewing') { + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.lizhuguan.username, USERS.lizhuguan.password); + await openDemoAndDoc(page); + const passBtn = page.getByRole('button', { name: /审核通过/ }); + if (await passBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { + await settle(page, 500); + await shot(page, '52-doc-review.png'); + record('52-doc-review.png', true, '审核人视角审核决议(通过/驳回)'); + await passBtn.click(); + await page.waitForTimeout(1500); + } else { + record('52-doc-review.png', false, '审核按钮不可见(李主管非该文档审核负责人?)'); + } + await context.close(); + } else { + record('52-doc-review.png', false, `文档不在审核态(${rec1?.current_state}),跳过`); + } + + const rec2 = await docgovState(zhangToken, docId); + // 53 批准人批准 + if (rec2?.current_state === 'approving') { + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.wangzong.username, USERS.wangzong.password); + await openDemoAndDoc(page); + const approveBtn = page.getByRole('button', { name: /终审批准生效/ }); + if (await approveBtn.isVisible({ timeout: 10_000 }).catch(() => false)) { + await settle(page, 500); + await shot(page, '53-doc-approve.png'); + record('53-doc-approve.png', true, '批准人视角终审批准'); + await approveBtn.click(); + await page.waitForTimeout(1500); + } else { + record('53-doc-approve.png', false, '批准按钮不可见(王总监非该文档批准负责人?)'); + } + await context.close(); + } else { + record('53-doc-approve.png', false, `文档不在批准态(${rec2?.current_state}),跳过`); + } + + // 54/55 阶段门禁 + const rec3 = await docgovState(zhangToken, docId); + if (rec3?.current_state === 'approved') { + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.zhanggong.username, USERS.zhanggong.password); + await ensureDemoProject(page); + await page.waitForTimeout(2500); // StageAdvanceBanner 轮询加载 + await settle(page, 800); + const readyBanner = page.getByText(/必需文档已批准,可推进/); + const waitBanner = page.getByText(/等待必需文档批准/); + if (await readyBanner.isVisible({ timeout: 10_000 }).catch(() => false)) { + await shot(page, '54-stage-gate-ready.png'); + record('54-stage-gate-ready.png', true, '阶段门禁就绪 Banner'); + const advBtn = page.getByRole('button', { name: /确认推进/ }); + if (await advBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await advBtn.click(); + await page.waitForTimeout(2000); + await settle(page, 800); + await shot(page, '55-stage-advance.png'); + record('55-stage-advance.png', true, '项目创建人确认推进阶段(已推进)'); + } else { + record('55-stage-advance.png', false, '确认推进按钮不可见(当前账号非创建人?)'); + } + } else if (await waitBanner.isVisible({ timeout: 3000 }).catch(() => false)) { + await shot(page, '54-stage-gate-ready.png'); + record('54-stage-gate-ready.png', false, '门禁仍等待其他必需文档批准(截的是等待态 Banner)'); + record('55-stage-advance.png', false, '门禁未就绪,无法推进'); + } else { + record('54-stage-gate-ready.png', false, 'StageAdvanceBanner 未出现(无 live 工作流实例?)'); + record('55-stage-advance.png', false, '同上'); + } + await context.close(); + } else { + record('54-stage-gate-ready.png', false, `文档未批准(${rec3?.current_state}),门禁无法就绪`); + record('55-stage-advance.png', false, '同上'); + } +} + +async function secAdmin() { + const adminToken = await apiLogin(USERS.admin.username, USERS.admin.password); + + // 为 62 准备一条待审批注册记录:临时开启审批 → 注册 demo02 + const cfg0 = await fetch(`${BASE}/api/v1/auth/public-config`).then((r) => r.json()); + const users = (await api(adminToken, 'GET', '/api/v1/admin/users')).json?.users || []; + const demo02 = users.find((u) => u.username === 'demo02'); + const needPending = !demo02 || demo02.status !== 'pending_approval'; + if (needPending) { + await api(adminToken, 'PUT', '/api/v1/admin/config', { + allow_registration: true, + require_approval: true, + }); + if (!demo02) { + await fetch(`${BASE}/api/v1/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'demo02', + password: '123456', + name: '演示用户02', + department: '研发中心', + employee_id: 'EMP-102', + }), + }).then((r) => r.text()); + } + } + + const { context, page } = await newPage(); + await uiLoginWorkbench(page, USERS.admin.username, USERS.admin.password); + await page.locator('button[title*="系统管理与准入治理"]').click(); + await page.getByText('系统管理', { exact: true }).waitFor({ timeout: 15_000 }); + await settle(page, 1500); + + // 60 系统运行总览(默认页) + await shot(page, '60-admin-overview.png'); + record('60-admin-overview.png', true, '系统运行总览'); + + // 61 用户与准入治理 + 开通受控员工账号弹窗 + await page.getByRole('button', { name: '用户与准入治理' }).click(); + await page.getByRole('button', { name: '开通受控员工账号' }).waitFor({ timeout: 15_000 }); + await settle(page, 1200); + await page.getByRole('button', { name: '开通受控员工账号' }).click(); + const form = page.locator('form').filter({ hasText: '用户名 (系统账号)' }); + await form.waitFor({ timeout: 10_000 }); + await form.locator('input[placeholder="如 li_si"]').fill('demo03'); + await form.locator('input[placeholder="如 李四"]').fill('演示员工03'); + const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]'); + await passBox.fill('InitPass123'); + await settle(page, 400); + await shot(page, '61-admin-users.png'); + record('61-admin-users.png', true, '账号列表 + 开通受控员工账号弹窗(示例已填,未提交)'); + // 关闭弹窗(不提交) + const formCancel = form.getByRole('button', { name: /取消|关闭/ }); + if (await formCancel.first().isVisible({ timeout: 2000 }).catch(() => false)) { + await formCancel.first().click(); + } else { + await page.keyboard.press('Escape'); + } + await page.waitForTimeout(500); + + // 62 注册审批中心 + await page.getByRole('button', { name: /注册审批中心/ }).click(); + await page.getByText('新员工注册审批与职位指派中心').waitFor({ timeout: 10_000 }); + await settle(page, 800); + await shot(page, '62-admin-approvals.png'); + const hasPending = await page.getByText('演示用户02').isVisible({ timeout: 3000 }).catch(() => false); + record('62-admin-approvals.png', true, hasPending ? '注册审批中心(含 demo02 待审核记录)' : '注册审批中心(当前无待审核记录)'); + + // 63 准入策略 + await page.getByRole('button', { name: /系统准入策略/ }).click(); + await page.getByText(/保存并立即应用准入策略/).waitFor({ timeout: 10_000 }); + await settle(page, 500); + await shot(page, '63-admin-access-policy.png'); + record('63-admin-access-policy.png', true, '准入策略开关'); + + // 64 RBAC + await page.getByRole('button', { name: 'RBAC 角色权限' }).click(); + await settle(page, 1500); + await shot(page, '64-admin-roles.png'); + record('64-admin-roles.png', true, 'RBAC 角色权限矩阵'); + + // 65 特性功能开关 + await page.getByRole('button', { name: '特性功能开关' }).click(); + await settle(page, 1200); + await shot(page, '65-admin-features.png'); + record('65-admin-features.png', true, '特性功能开关'); + + // 66 知识库连接 + await page.getByRole('button', { name: '知识库连接' }).click(); + await settle(page, 1200); + await shot(page, '66-admin-kb.png'); + record('66-admin-kb.png', true, '知识库连接'); + + // 67 工作流定义 + await page.getByRole('button', { name: '工作流定义' }).click(); + await settle(page, 1200); + await shot(page, '67-admin-workflows.png'); + record('67-admin-workflows.png', true, '工作流定义'); + + // 68 文档与模板库 + await page.getByRole('button', { name: '文档与模板库' }).click(); + await settle(page, 1200); + await shot(page, '68-admin-templates.png'); + record('68-admin-templates.png', true, '文档与模板库'); + + await context.close(); + + // 恢复准入策略为采集前状态 + await api(adminToken, 'PUT', '/api/v1/admin/config', { + allow_registration: cfg0.allow_registration, + require_approval: cfg0.require_approval, + }); + console.log(`准入策略已恢复为 ${JSON.stringify(cfg0)}`); +} + +// ─── main ─── +const SECTIONS = { + onboarding: secOnboarding, + claim50: secClaim50, + workbench: secWorkbench, + project: secProject, + chat: secChat, + doc: secDoc, + docgov: secDocgov, + admin: secAdmin, +}; + +const args = process.argv.slice(2).filter((a) => !a.startsWith('--')); +const selected = args.length === 0 || args.includes('all') ? Object.keys(SECTIONS) : args; + +browser = await chromium.launch({ headless: true, channel: 'chromium' }); +try { + for (const s of selected) { + if (!SECTIONS[s]) { + console.log(`未知 section: ${s}(可选 ${Object.keys(SECTIONS).join('|')}|all)`); + continue; + } + console.log(`\n=== section: ${s} ===`); + try { + await SECTIONS[s](); + } catch (err) { + console.error(`[FAIL] section ${s}: ${err.message}`); + record(`section:${s}`, false, `异常中断: ${err.message}`); + } + } +} finally { + await browser.close(); +} + +fs.writeFileSync( + path.join(OUT, '_capture-report.json'), + JSON.stringify({ capturedAt: new Date().toISOString(), results }, null, 2), +); +const fails = results.filter((r) => !r.ok); +console.log(`\n完成:${results.length - fails.length}/${results.length} 项成功,报告见 images/_capture-report.json`); diff --git a/docs/user-manual/scripts/manual.css b/docs/user-manual/scripts/manual.css new file mode 100644 index 00000000..fd914cd9 --- /dev/null +++ b/docs/user-manual/scripts/manual.css @@ -0,0 +1,36 @@ +/* 系统使用说明书 HTML/PDF 样式 */ +:root { color-scheme: light; } +html { font-size: 15px; } +body { + font-family: "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", "PingFang SC", "WenQuanYi Micro Hei", sans-serif; + line-height: 1.75; + color: #1f2329; + max-width: 880px; + margin: 0 auto; + padding: 2rem 2.5rem; +} +h1, h2, h3, h4 { color: #0f2a43; line-height: 1.35; } +h1 { font-size: 1.7rem; border-bottom: 3px solid #1f6feb; padding-bottom: .4rem; } +h2 { font-size: 1.35rem; border-bottom: 1px solid #d0d7de; padding-bottom: .3rem; margin-top: 2.2rem; } +h3 { font-size: 1.1rem; margin-top: 1.6rem; } +h1#toc, nav#TOC { page-break-after: always; } +img { + display: block; + max-width: 100%; + margin: 1rem auto; + border: 1px solid #d0d7de; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,.08); +} +table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: .92rem; } +th, td { border: 1px solid #d0d7de; padding: .4rem .6rem; text-align: left; } +th { background: #f0f4f8; } +code { background: #f6f8fa; padding: .1em .35em; border-radius: 4px; font-size: .88em; } +pre { background: #f6f8fa; padding: .8rem 1rem; border-radius: 6px; overflow-x: auto; } +pre code { background: none; padding: 0; } +blockquote { + margin: 1rem 0; padding: .6rem 1rem; + border-left: 4px solid #1f6feb; background: #f0f6ff; color: #344054; +} +hr { border: none; border-top: 1px solid #d0d7de; margin: 2rem 0; } +a { color: #1f6feb; text-decoration: none; } diff --git a/docs/user-manual/scripts/pdf-print.mjs b/docs/user-manual/scripts/pdf-print.mjs new file mode 100644 index 00000000..b1e33fd1 --- /dev/null +++ b/docs/user-manual/scripts/pdf-print.mjs @@ -0,0 +1,18 @@ +// 在 playwright docker 镜像内运行:chromium 打印 HTML → PDF +// 用法:node pdf-print.mjs +import { createRequire } from 'node:module'; +const require = createRequire('/home/weichen/projects/Agent-System/web/package.json'); +const { chromium } = require('playwright'); + +const [htmlPath, pdfPath] = process.argv.slice(2); +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' }); +await page.pdf({ + path: pdfPath, + format: 'A4', + printBackground: true, + margin: { top: '18mm', bottom: '18mm', left: '15mm', right: '15mm' }, +}); +await browser.close(); +console.log('[ok] PDF:', pdfPath); diff --git a/internal/app/app.go b/internal/app/app.go index 16ba6ba3..22d6ee19 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -220,6 +220,10 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { }, FeatureValues: sysadminFeatureValues(cfg), }) + if instSvc != nil { + instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin}) + svc.SetInstanceDutySource(&projectInstanceDutyAdapter{inst: instSvc}) + } rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov} rt.DocNotify.SkipWrite = func(ctx context.Context) bool { return rt.WriterLive(ctx, events.ConsumerNotify) @@ -282,7 +286,6 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string { "INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier), "WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled), "MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled), - "SEMANTIC_FALLBACK_ENABLED": strconv.FormatBool(iflags.SemanticFallbackEnabled), "MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier), "MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment), "MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate), diff --git a/internal/app/author_only_policy_adapter.go b/internal/app/author_only_policy_adapter.go new file mode 100644 index 00000000..958a62ed --- /dev/null +++ b/internal/app/author_only_policy_adapter.go @@ -0,0 +1,38 @@ +package app + +import ( + "context" + + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/sysadmin" + + "github.com/jackc/pgx/v5" +) + +// sysadminAuthorOnlyPolicyAdapter 将 sysadmin.AuthorOnlyPolicySource 适配为 +// projectinstance 本地窄接口,避免 projectinstance 反向依赖 sysadmin。 +type sysadminAuthorOnlyPolicyAdapter struct { + svc *sysadmin.Service +} + +func (a *sysadminAuthorOnlyPolicyAdapter) GetAuthorOnlyApprovalPolicy(ctx context.Context) (projectinstance.AuthorOnlyPolicySnapshot, error) { + if a == nil || a.svc == nil { + return projectinstance.AuthorOnlyPolicySnapshot{}, sysadmin.ErrStoreUnavailable + } + pol, err := a.svc.GetAuthorOnlyApprovalPolicy(ctx) + if err != nil { + return projectinstance.AuthorOnlyPolicySnapshot{}, err + } + return projectinstance.AuthorOnlyPolicySnapshot{Allowed: pol.Allowed, Revision: pol.Revision}, nil +} + +func (a *sysadminAuthorOnlyPolicyAdapter) LockAuthorOnlyApprovalPolicy(ctx context.Context, tx pgx.Tx) (projectinstance.AuthorOnlyPolicySnapshot, error) { + if a == nil || a.svc == nil { + return projectinstance.AuthorOnlyPolicySnapshot{}, sysadmin.ErrStoreUnavailable + } + pol, err := a.svc.LockAuthorOnlyApprovalPolicy(ctx, tx) + if err != nil { + return projectinstance.AuthorOnlyPolicySnapshot{}, err + } + return projectinstance.AuthorOnlyPolicySnapshot{Allowed: pol.Allowed, Revision: pol.Revision}, nil +} diff --git a/internal/app/instance_duty_adapter.go b/internal/app/instance_duty_adapter.go new file mode 100644 index 00000000..301d4c51 --- /dev/null +++ b/internal/app/instance_duty_adapter.go @@ -0,0 +1,46 @@ +package app + +import ( + "context" + "strings" + + "chat-agent-system/internal/projectinstance" +) + +// projectInstanceDutyAdapter 将活跃工作流实例的逐文档职责投影为 bizdev 窄接口, +// 避免 bizdev 导入 projectinstance。 +type projectInstanceDutyAdapter struct { + inst *projectinstance.Service +} + +func (a *projectInstanceDutyAdapter) ResolveGovernedDuty(ctx context.Context, projectID int64, taskKey string) (author, reviewer, approver int64, dutyPolicy string, governed bool, err error) { + if a == nil || a.inst == nil || projectID <= 0 { + return 0, 0, 0, "", false, nil + } + taskKey = strings.TrimSpace(taskKey) + if taskKey == "" { + return 0, 0, 0, "", false, nil + } + detail, _, derr := a.inst.GetLiveInstanceDetail(ctx, projectID) + if derr != nil || detail == nil { + // 无活跃实例(规划中/legacy)→ 不受治理,非错误 + return 0, 0, 0, "", false, nil + } + for _, st := range detail.Instance.ConfigSnapshot.Stages { + for _, dt := range st.DocTasks { + if dt.TaskKey != taskKey || !dt.Enabled { + continue + } + mode, effective, isAuthorOnly := projectinstance.ResolveDocTaskDuty(dt) + if mode != projectinstance.DutyModeFull { + return 0, 0, 0, "", false, nil + } + policy := projectinstance.DutyPolicySeparated + if isAuthorOnly { + policy = projectinstance.DutyPolicyAuthorOnly + } + return effective.AuthorUserID, effective.ReviewerUserID, effective.ApproverUserID, policy, true, nil + } + } + return 0, 0, 0, "", false, nil +} diff --git a/internal/app/instance_duty_adapter_test.go b/internal/app/instance_duty_adapter_test.go new file mode 100644 index 00000000..5d7a7a22 --- /dev/null +++ b/internal/app/instance_duty_adapter_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "context" + "sync" + "testing" + + "chat-agent-system/internal/projectinstance" + + "github.com/jackc/pgx/v5" +) + +type stubAuthorOnlyPolicy struct { + allowed bool + revision int64 +} + +func (s *stubAuthorOnlyPolicy) GetAuthorOnlyApprovalPolicy(_ context.Context) (projectinstance.AuthorOnlyPolicySnapshot, error) { + return projectinstance.AuthorOnlyPolicySnapshot{Allowed: s.allowed, Revision: s.revision}, nil +} + +func (s *stubAuthorOnlyPolicy) LockAuthorOnlyApprovalPolicy(ctx context.Context, _ pgx.Tx) (projectinstance.AuthorOnlyPolicySnapshot, error) { + return s.GetAuthorOnlyApprovalPolicy(ctx) +} + +type stubMemberLookup struct { + mu sync.Mutex + ids map[int64]struct{} +} + +func (s *stubMemberLookup) ActiveMemberIDs(_ context.Context, _ int64) (map[int64]struct{}, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[int64]struct{}, len(s.ids)) + for id := range s.ids { + out[id] = struct{}{} + } + return out, nil +} + +func TestProjectInstanceDutyAdapter_AuthorOnlyLive(t *testing.T) { + ctx := context.Background() + store := projectinstance.NewMemoryStore() + _ = projectinstance.SeedProjectMeta(store, 501, projectinstance.LifecyclePlanning, "", 1, 0) + svc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil) + svc.SetAuthorOnlyPolicySource(&stubAuthorOnlyPolicy{allowed: true, revision: 1}) + svc.SetActiveMemberLookup(&stubMemberLookup{ids: map[int64]struct{}{7: {}}}) + + plan := projectinstance.ValidTestPlan() + plan.Stages[0].DocTasks[0].DutyPolicy = projectinstance.DutyPolicyAuthorOnly + plan.Stages[0].DocTasks[0].Duties = projectinstance.DocDuty{AuthorUserID: 7} + + if _, err := svc.SaveWorkflowPlan(ctx, 501, 1, true, projectinstance.SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 501, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + if _, err := svc.Activate(ctx, 501, 1, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate: %v", err) + } + + adapter := &projectInstanceDutyAdapter{inst: svc} + author, reviewer, approver, policy, governed, err := adapter.ResolveGovernedDuty(ctx, 501, "intake") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if !governed || policy != projectinstance.DutyPolicyAuthorOnly { + t.Fatalf("governed=%v policy=%q", governed, policy) + } + if author != 7 || reviewer != 7 || approver != 7 { + t.Fatalf("want 7/7/7 got %d/%d/%d", author, reviewer, approver) + } +} + +func TestProjectInstanceDutyAdapter_NoLiveInstance(t *testing.T) { + ctx := context.Background() + store := projectinstance.NewMemoryStore() + _ = projectinstance.SeedProjectMeta(store, 502, projectinstance.LifecyclePlanning, "", 1, 0) + svc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil) + adapter := &projectInstanceDutyAdapter{inst: svc} + _, _, _, policy, governed, err := adapter.ResolveGovernedDuty(ctx, 502, "intake") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if governed || policy != "" { + t.Fatalf("want ungoverned, got governed=%v policy=%q", governed, policy) + } +} diff --git a/internal/app/plan_owner_test.go b/internal/app/plan_owner_test.go index 81e8fef6..8dffc254 100644 --- a/internal/app/plan_owner_test.go +++ b/internal/app/plan_owner_test.go @@ -120,7 +120,11 @@ func TestLiveTaskStarterReplaysIdempotencyKey(t *testing.T) { } func TestTaskGenerateRealPIStopGate(t *testing.T) { - // Real PI + PG + revoke/restart evidence lives in - // TestMultiIntentU8_RealPI_ConfirmGenerate_PG (internal/server). + // Permanent redirect, not a TODO: internal/app has no PG pool / pi-agent runner + // harness, and internal/server already imports internal/app (server.go), so pulling + // internal/server's real-PI+PG fixtures in here would create an import cycle. + // Real PI + PG + revoke/restart evidence for this stop-gate lives in + // TestMultiIntentU8_RealPI_ConfirmGenerate_PG (internal/server), which exercises the + // same LiveTaskStarter path against a live pi-agent runner + isolated PG. t.Skip("redirect: run TestMultiIntentU8_RealPI_ConfirmGenerate_PG with TEST_DATABASE_URL + runner") } diff --git a/internal/bizdev/document_duty_resolve_test.go b/internal/bizdev/document_duty_resolve_test.go index e519fc78..723f3325 100644 --- a/internal/bizdev/document_duty_resolve_test.go +++ b/internal/bizdev/document_duty_resolve_test.go @@ -5,14 +5,28 @@ import ( "testing" "chat-agent-system/internal/docduty" + "chat-agent-system/internal/docgov" ) +type fakeInstanceDuty struct { + author, reviewer, approver int64 + policy string + governed bool + err error + called bool +} + +func (f *fakeInstanceDuty) ResolveGovernedDuty(_ context.Context, _ int64, _ string) (int64, int64, int64, string, bool, error) { + f.called = true + return f.author, f.reviewer, f.approver, f.policy, f.governed, f.err +} + func TestResolveTaskDocumentDuties_UsesCreatorNotHardcodedReviewer(t *testing.T) { req := TaskRunRequest{ UserID: 170, ProjectID: 6145, AuthorName: "编制人A", } - got := resolveTaskDocumentDuties(context.Background(), nil, req) + got, _, _ := resolveTaskDocumentDuties(context.Background(), nil, req, nil) if got.Author.UserID != 170 { t.Fatalf("author want 170 got %+v", got.Author) } @@ -28,13 +42,87 @@ func TestResolveTaskDocumentDuties_IgnoresNonMemberRequestReviewer(t *testing.T) req := TaskRunRequest{ UserID: 170, ReviewerID: 2, ReviewerName: "审核负责人", } - got := resolveTaskDocumentDuties(context.Background(), nil, req) + got, _, _ := resolveTaskDocumentDuties(context.Background(), nil, req, nil) if got.Reviewer.UserID != 0 { t.Fatalf("request reviewer 2 is not a member: %+v", got.Reviewer) } } +func TestResolveTaskDocumentDuties_GovernedUsesInstance(t *testing.T) { + src := &fakeInstanceDuty{ + author: 9, reviewer: 9, approver: 9, + policy: "author_only", governed: true, + } + req := TaskRunRequest{ + UserID: 170, ProjectID: 42, TaskKey: "intake", + AuthorName: "编制人A", ReviewerID: 2, + } + got, policy, governed := resolveTaskDocumentDuties(context.Background(), nil, req, src) + if !governed || policy != "author_only" { + t.Fatalf("governed=%v policy=%q", governed, policy) + } + if got.Author.UserID != 9 || got.Reviewer.UserID != 9 || got.Approver.UserID != 9 { + t.Fatalf("want A/A/A=9, got %+v", got) + } + if !src.called { + t.Fatal("expected ResolveGovernedDuty called") + } +} + +func TestResolveTaskDocumentDuties_UngovernedFallsBack(t *testing.T) { + src := &fakeInstanceDuty{governed: false} + req := TaskRunRequest{ + UserID: 170, ProjectID: 42, TaskKey: "intake", + AuthorName: "编制人A", + } + got, policy, governed := resolveTaskDocumentDuties(context.Background(), nil, req, src) + if governed || policy != "" { + t.Fatalf("want ungoverned, got governed=%v policy=%q", governed, policy) + } + if got.Author.UserID != 170 { + t.Fatalf("author want 170 got %+v", got.Author) + } + if got.Reviewer.UserID != 0 { + t.Fatalf("no members → reviewer empty, got %+v", got.Reviewer) + } +} + +func TestGovernedSkipsMemoryHealOverwrite(t *testing.T) { + doc := &docgov.DocumentRecord{ + DocID: "doc_x", AuthorID: 9, AuthorName: "A", + ReviewerID: 9, ReviewerName: "A", + ApproverID: 9, ApproverName: "A", + DutyLocked: true, DutyPolicy: "author_only", + } + assigned := docduty.Binding{ + Author: docduty.Holder{UserID: 1, Name: "作者"}, + Reviewer: docduty.Holder{UserID: 2, Name: "审核"}, + Approver: docduty.Holder{UserID: 3, Name: "批准"}, + } + memberIDs := map[int64]struct{}{1: {}, 2: {}, 3: {}, 9: {}} + + governed := true + if !governed { + next, _ := docduty.HealBinding(bindingFromDoc(doc), assigned, memberIDs) + writeBindingToDoc(doc, next) + } + if doc.ReviewerID != 9 || doc.ApproverID != 9 { + t.Fatalf("governed must keep A/A/A, got R=%d Ap=%d", doc.ReviewerID, doc.ApproverID) + } + + doc2 := &docgov.DocumentRecord{ + DocID: "doc_y", AuthorID: 9, ReviewerID: 9, ApproverID: 9, + } + next, changed := docduty.HealBinding(bindingFromDoc(doc2), assigned, memberIDs) + if !changed { + t.Fatal("expected heal change for ungoverned path") + } + writeBindingToDoc(doc2, next) + if doc2.ReviewerID != 2 || doc2.ApproverID != 3 { + t.Fatalf("ungoverned heal want 2/3, got R=%d Ap=%d", doc2.ReviewerID, doc2.ApproverID) + } +} + func TestBindingFromDocRoundTrip(t *testing.T) { - // compile-time guard that helper stays aligned with Resolve slots _ = docduty.Binding{} } diff --git a/internal/bizdev/rdev_flow_test.go b/internal/bizdev/rdev_flow_test.go new file mode 100644 index 00000000..d874e647 --- /dev/null +++ b/internal/bizdev/rdev_flow_test.go @@ -0,0 +1,44 @@ +package bizdev + +import ( + "context" + "errors" + "strings" + "testing" + + "chat-agent-system/internal/config" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/squad" +) + +// TestRDev_PrecheckAndKindOrchestration 覆盖 rdev 硬门禁与 kind 编排(stub,无真实 PI)。 +func TestRDev_PrecheckAndKindOrchestration(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AllowInMemory: true, AgentBackend: "stub"} + svc, err := NewService(cfg) + if err != nil { + t.Fatal(err) + } + + _, err = svc.ExecuteTask(context.Background(), TaskRunRequest{ + TaskKey: "rd_proposal", + ProjectID: 9001, + Backend: "stub", + WorkDir: t.TempDir(), + }) + if err == nil || !errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) { + t.Fatalf("want ErrPrerequisiteNotMet, got %v", err) + } + if !strings.Contains(err.Error(), "CDO") { + t.Fatalf("message: %v", err) + } + + if n := len(squad.DefaultWorkflowSteps("rd_proposal")); n < 4 { + t.Fatalf("document steps=%d", n) + } + if n := len(squad.DefaultWorkflowSteps("rd_planning_review")); n != 3 { + t.Fatalf("review steps=%d", n) + } + if n := len(squad.DefaultWorkflowSteps("rd_prototype_photos")); n != 4 { + t.Fatalf("upload steps=%d", n) + } +} diff --git a/internal/bizdev/rdev_precheck_test.go b/internal/bizdev/rdev_precheck_test.go new file mode 100644 index 00000000..18828547 --- /dev/null +++ b/internal/bizdev/rdev_precheck_test.go @@ -0,0 +1,43 @@ +package bizdev + +import ( + "context" + "errors" + "strings" + "testing" + + "chat-agent-system/internal/projectworkflow" +) + +func TestEnsureRDevMainCompleted_RejectsWhenMissing(t *testing.T) { + s := &Service{ + completedMainHook: func(context.Context, int64) (bool, error) { return false, nil }, + } + err := s.ensureRDevMainCompleted(context.Background(), 42) + if err == nil { + t.Fatal("expected prerequisite error") + } + if !errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) { + t.Fatalf("want ErrPrerequisiteNotMet, got %v", err) + } + if !strings.Contains(err.Error(), "CDO") { + t.Fatalf("message missing CDO hint: %v", err) + } +} + +func TestEnsureRDevMainCompleted_AllowsWhenCompleted(t *testing.T) { + s := &Service{ + completedMainHook: func(context.Context, int64) (bool, error) { return true, nil }, + } + if err := s.ensureRDevMainCompleted(context.Background(), 42); err != nil { + t.Fatal(err) + } +} + +func TestResolveDocStage_SkipsMaturityForRD(t *testing.T) { + // rd_ tasks without StageKey fall back to V0.5 (not maturity labels); StageKey wins. + got := resolveDocStage(TaskRunRequest{TaskKey: "rd_proposal", StageKey: "RD01_planning"}) + if got != "RD01_planning" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/bizdev/resolve_doc_stage_test.go b/internal/bizdev/resolve_doc_stage_test.go new file mode 100644 index 00000000..fa5c1e72 --- /dev/null +++ b/internal/bizdev/resolve_doc_stage_test.go @@ -0,0 +1,30 @@ +package bizdev + +import "testing" + +func TestResolveDocStage_PrefersStageKey(t *testing.T) { + got := resolveDocStage(TaskRunRequest{TaskKey: "intake", StageKey: "RD01_planning"}) + if got != "RD01_planning" { + t.Fatalf("got %q, want RD01_planning", got) + } +} + +func TestResolveDocStage_MainTaskFallback(t *testing.T) { + cases := []struct { + task string + want string + }{ + {"intake", string(StageV01_Triage)}, + {"research", string(StageV05_Feasibility)}, + {"feasibility_dev", string(StageV05_Feasibility)}, + {"cdo_quote", string(StageV10_Quoting)}, + {"system_reqs", string(StageV05_Feasibility)}, + {"", string(StageV05_Feasibility)}, + } + for _, tc := range cases { + got := resolveDocStage(TaskRunRequest{TaskKey: tc.task}) + if got != tc.want { + t.Fatalf("task=%q got %q want %q", tc.task, got, tc.want) + } + } +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 78869523..138d43fa 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -51,6 +51,24 @@ type Service struct { stageTaskHook func(context.Context, TaskRunRequest) (*TaskRunResult, error) // createDocumentHook 仅测试注入:覆盖账本创建失败路径。 createDocumentHook func(ctx context.Context, docID string, projectID int64, taskKey, title, stage, workDir string, authorID int64, authorName string, reviewerID int64, reviewerName string, approverID int64, approverName string) (*docgov.DocumentRecord, error) + // completedMainHook 仅测试注入:覆盖「main 实例已 completed」查询。 + completedMainHook func(ctx context.Context, projectID int64) (bool, error) + // instanceDuty 可选:活跃工作流实例对 taskKey 的治理职责(由 app 装配,避免导入 projectinstance)。 + instanceDuty InstanceDutySource +} + +// InstanceDutySource 返回项目当前活跃实例对 taskKey 的治理结论。 +// governed=false 表示未受实例治理,调用方应回退项目默认职责逻辑。 +type InstanceDutySource interface { + ResolveGovernedDuty(ctx context.Context, projectID int64, taskKey string) (author, reviewer, approver int64, dutyPolicy string, governed bool, err error) +} + +// SetInstanceDutySource 注入实例职责解析源(组合根可选装配)。 +func (s *Service) SetInstanceDutySource(src InstanceDutySource) { + if s == nil { + return + } + s.instanceDuty = src } // NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。 @@ -287,6 +305,59 @@ func (s *Service) ListTasks() []TaskMeta { return s.squad.ListTasks() } +// resolveDocStage 解析写入文档账本的 Stage 字段。 +// 优先请求透传的冻结实例阶段键;否则按 main 三阶段任务映射;再回落 V0.5_feasibility。 +func resolveDocStage(req TaskRunRequest) string { + if sk := strings.TrimSpace(req.StageKey); sk != "" { + return sk + } + switch strings.TrimSpace(req.TaskKey) { + case "intake": + return string(StageV01_Triage) + case "research", "feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design": + return string(StageV05_Feasibility) + case "cdo_quote": + return string(StageV10_Quoting) + default: + return string(StageV05_Feasibility) + } +} + +const rdevPrerequisiteMessage = "请先完成商务对接流程(CDO 报价批准)后再启动研发设计控制流程" + +func (s *Service) ensureRDevMainCompleted(ctx context.Context, projectID int64) error { + if projectID <= 0 { + return fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, rdevPrerequisiteMessage) + } + ok, err := s.hasCompletedMainInstance(ctx, projectID) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, rdevPrerequisiteMessage) + } + return nil +} + +func (s *Service) hasCompletedMainInstance(ctx context.Context, projectID int64) (bool, error) { + if s.completedMainHook != nil { + return s.completedMainHook(ctx, projectID) + } + if s.pool == nil { + return false, nil + } + var ok bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM project_workflow_instances + WHERE project_id = $1 + AND status = 'completed' + AND config_snapshot->>'workflow_definition_key' = 'main' + ) + `, projectID).Scan(&ok) + return ok, err +} + func (s *Service) ListStages() []StageMeta { return SupportedStages } @@ -300,6 +371,16 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun }, err } + if strings.HasPrefix(strings.TrimSpace(req.TaskKey), "rd_") { + if err := s.ensureRDevMainCompleted(ctx, req.ProjectID); err != nil { + return &TaskRunResult{ + TaskKey: req.TaskKey, + Success: false, + ErrorReason: err.Error(), + }, err + } + } + materials, inputManifest, err := s.ResolveMaterials(ctx, req) if err != nil { return nil, err @@ -456,7 +537,55 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun } } - duties := resolveTaskDocumentDuties(ctx, s.pool, req) + // 研发设计控制:项目上下文快照 + 同项目已批准文档摘要注入 + if strings.HasPrefix(strings.TrimSpace(req.TaskKey), "rd_") && req.ProjectID > 0 { + priorDocs := make([]squad.PriorDocItem, 0) + facts := map[string]string{} + if req.Fields != nil { + for k, v := range req.Fields { + facts[k] = v + } + } + if s.ctxMgr != nil { + if coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && coll != nil { + for k, v := range coll.BlackboardFacts { + facts[k] = v + } + } + } + // 优先注入已批准 SR(PM 或 rd_system_reqs) + for _, key := range []string{"system_reqs", "rd_system_reqs"} { + docID := fmt.Sprintf("doc_proj%d_%s", req.ProjectID, key) + if s.docGov == nil { + continue + } + doc, err := s.docGov.GetDocument(ctx, docID) + if err != nil || doc == nil || doc.CurrentState != docgov.StateApproved || doc.WorkDir == "" { + continue + } + if b, readErr := os.ReadFile(filepath.Join(doc.WorkDir, "output.md")); readErr == nil && len(b) > 0 { + priorDocs = append(priorDocs, squad.PriorDocItem{ + DocKey: key, Title: doc.Title, Content: string(b), Stage: "approved", + }) + } + } + dialogueInputs := s.collectSnapshotDialogueInputs(ctx, req.ProjectID, req.UserID) + ctxSvc := projectcontext.NewService(s.pool) + snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{ + ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID, + ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs, + Knowledge: hits, Dialogue: dialogueInputs.Dialogue, Decisions: dialogueInputs.Decisions, + }) + if snap != nil { + snap.ExcludedPrivateCount += dialogueInputs.ExcludedPrivate + snap.ExcludedNoAccessCount += dialogueInputs.ExcludedNoAccess + if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 { + materials = append(materials, extra...) + } + } + } + + duties, dutyPolicy, governed := resolveTaskDocumentDuties(ctx, s.pool, req, s.instanceDuty) authorID, authorName := duties.Author.UserID, duties.Author.Name reviewerID, reviewerName := duties.Reviewer.UserID, duties.Reviewer.Name approverID, approverName := duties.Approver.UserID, duties.Approver.Name @@ -476,17 +605,18 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun var appliedProposalID string if s.docGov != nil && strings.TrimSpace(docID) != "" { + docStage := resolveDocStage(req) var createErr error if s.createDocumentHook != nil { docRec, createErr = s.createDocumentHook( ctx, docID, req.ProjectID, req.TaskKey, artifactTitle, - "V0.5_feasibility", req.WorkDir, + docStage, req.WorkDir, authorID, authorName, reviewerID, reviewerName, approverID, approverName, ) } else { docRec, createErr = s.docGov.CreateOrGetDocument( ctx, docID, req.ProjectID, req.TaskKey, artifactTitle, - "V0.5_feasibility", req.WorkDir, + docStage, req.WorkDir, authorID, authorName, reviewerID, reviewerName, approverID, approverName, ) } @@ -500,6 +630,21 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun DocRecord: docRec, }, createErr } + if governed && docRec != nil { + if lockErr := s.docGov.LockDocumentDuty(ctx, docRec.DocID, dutyPolicy); lockErr != nil { + log.Printf("[docgov] lock document duty %s failed: %v", docRec.DocID, lockErr) + return &TaskRunResult{ + TaskKey: req.TaskKey, + ArtifactTitle: artifactTitle, + WorkDir: req.WorkDir, + Success: false, + ErrorReason: "lock document duty failed: " + lockErr.Error(), + DocRecord: docRec, + }, lockErr + } + docRec.DutyLocked = true + docRec.DutyPolicy = dutyPolicy + } if s.pool != nil && req.ProjectID > 0 { assigned, _, healErr := docduty.HealProjectDocuments(ctx, s.pool, req.ProjectID) if healErr != nil { @@ -513,7 +658,8 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun DocRecord: docRec, }, healErr } - if docRec != nil { + // 受实例治理的文档不得用项目默认职责覆盖内存态返回值。 + if !governed && docRec != nil { members, creator, loadErr := docduty.LoadProjectMembers(ctx, s.pool, req.ProjectID) if loadErr != nil { log.Printf("[docduty] load members project %d after generate failed: %v", req.ProjectID, loadErr) @@ -679,13 +825,17 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun case <-stopHB: return case <-ticker.C: - _, _ = s.docGov.HeartbeatLease(context.Background(), docID, string(docgov.LeaseHolderSquad), leaseHolderID, leaseTTL) + hbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, _ = s.docGov.HeartbeatLease(hbCtx, docID, string(docgov.LeaseHolderSquad), leaseHolderID, leaseTTL) + cancel() } } }() defer func() { if leaseAcquired { - _, _ = s.docGov.ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderSquad), leaseHolderID) + relCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, _ = s.docGov.ReleaseLease(relCtx, docID, string(docgov.LeaseHolderSquad), leaseHolderID) + cancel() if streamer := stream.FromContext(ctx); streamer != nil { streamer.EmitGovernance(stream.GovernancePayload{ DocID: docID, @@ -1339,7 +1489,35 @@ func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req squad.ModifyW return s.squad.ResumeOrModifyWorkspace(ctx, req) } -func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req TaskRunRequest) docduty.Binding { +func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req TaskRunRequest, instanceDuty InstanceDutySource) (docduty.Binding, string, bool) { + if instanceDuty != nil && req.ProjectID > 0 && strings.TrimSpace(req.TaskKey) != "" { + authorID, reviewerID, approverID, dutyPolicy, governed, err := instanceDuty.ResolveGovernedDuty(ctx, req.ProjectID, req.TaskKey) + if err == nil && governed { + bound := docduty.Binding{ + Author: docduty.Holder{UserID: authorID}, + Reviewer: docduty.Holder{UserID: reviewerID}, + Approver: docduty.Holder{UserID: approverID}, + } + if pool != nil { + if members, creator, loadErr := docduty.LoadProjectMembers(ctx, pool, req.ProjectID); loadErr == nil { + bound.Author.Name = holderName(members, creator, authorID, req.AuthorName) + bound.Reviewer.Name = holderName(members, creator, reviewerID, req.ReviewerName) + bound.Approver.Name = holderName(members, creator, approverID, req.ApproverName) + } + } + if bound.Author.Name == "" { + bound.Author.Name = strings.TrimSpace(req.AuthorName) + } + if bound.Reviewer.Name == "" { + bound.Reviewer.Name = strings.TrimSpace(req.ReviewerName) + } + if bound.Approver.Name == "" { + bound.Approver.Name = strings.TrimSpace(req.ApproverName) + } + return bound, dutyPolicy, true + } + } + reqDuties := docduty.RequestDuties{ AuthorID: req.AuthorID, AuthorName: req.AuthorName, ReviewerID: req.ReviewerID, ReviewerName: req.ReviewerName, @@ -1353,11 +1531,26 @@ func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req Task if bound.Author.UserID == 0 && req.UserID > 0 { bound.Author = docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)} } - return bound + return bound, "", false } } creator := docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)} - return docduty.Resolve(nil, creator, reqDuties) + return docduty.Resolve(nil, creator, reqDuties), "", false +} + +func holderName(members []docduty.Member, creator docduty.Holder, userID int64, fallback string) string { + if userID <= 0 { + return strings.TrimSpace(fallback) + } + if creator.UserID == userID && strings.TrimSpace(creator.Name) != "" { + return strings.TrimSpace(creator.Name) + } + for _, m := range members { + if m.UserID == userID && strings.TrimSpace(m.Name) != "" { + return strings.TrimSpace(m.Name) + } + } + return strings.TrimSpace(fallback) } func bindingFromDoc(doc *docgov.DocumentRecord) docduty.Binding { diff --git a/internal/bizdev/types.go b/internal/bizdev/types.go index e8f65d14..bb2ea531 100644 --- a/internal/bizdev/types.go +++ b/internal/bizdev/types.go @@ -63,6 +63,7 @@ type TaskRunRequest struct { UserContext string `json:"user_context,omitempty"` MemoryBankFiles map[string]string `json:"memory_bank_files,omitempty"` DocID string `json:"doc_id,omitempty"` + StageKey string `json:"stage_key,omitempty"` // 冻结实例阶段键;空则 resolveDocStage 回落 AuthorID int64 `json:"author_id,omitempty"` AuthorName string `json:"author_name,omitempty"` ReviewerID int64 `json:"reviewer_id,omitempty"` diff --git a/internal/config/compose_env_consistency_test.go b/internal/config/compose_env_consistency_test.go new file mode 100644 index 00000000..2189b081 --- /dev/null +++ b/internal/config/compose_env_consistency_test.go @@ -0,0 +1,110 @@ +package config_test + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func composeRepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +var composeServiceENV = regexp.MustCompile(`(?m)^\s+ENV:\s*(\S+)\s*$`) + +func extractServiceENV(composeYAML, serviceName string) (string, error) { + // Find " serviceName:" at top-level service indentation (two spaces). + marker := "\n " + serviceName + ":\n" + idx := strings.Index(composeYAML, marker) + if idx < 0 { + // file may start with the service after "services:\n" + alt := "\nservices:\n " + serviceName + ":\n" + idx = strings.Index(composeYAML, alt) + if idx < 0 { + return "", os.ErrNotExist + } + idx += len("\nservices:") + } + rest := composeYAML[idx+1:] // start at " serviceName:" + // Next top-level service starts at "\n :" where name is not indented further. + next := regexp.MustCompile(`(?m)^ [a-zA-Z0-9_-]+:\s*$`) + locs := next.FindAllStringIndex(rest, -1) + block := rest + if len(locs) >= 2 { + // locs[0] is current service header; locs[1] is next sibling + block = rest[:locs[1][0]] + } + m := composeServiceENV.FindStringSubmatch(block) + if m == nil { + return "", os.ErrNotExist + } + return strings.Trim(m[1], `"'`), nil +} + +func TestComposeAppEventsENVAligned(t *testing.T) { + root := composeRepoRoot(t) + files := []string{ + "docker-compose.dev.yml", + "docker-compose.test.yml", + "docker-compose.main.yml", + } + for _, name := range files { + raw, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + text := string(raw) + appEnv, err := extractServiceENV(text, "app") + if err != nil { + t.Fatalf("%s: app ENV: %v", name, err) + } + eventsEnv, err := extractServiceENV(text, "events") + if err != nil { + t.Fatalf("%s: events ENV: %v", name, err) + } + if appEnv != eventsEnv { + t.Fatalf("%s: app ENV=%q != events ENV=%q", name, appEnv, eventsEnv) + } + } +} + +func TestComposeMainAppDependsOnEventsHealthy(t *testing.T) { + root := composeRepoRoot(t) + raw, err := os.ReadFile(filepath.Join(root, "docker-compose.main.yml")) + if err != nil { + t.Fatal(err) + } + text := string(raw) + marker := "\n app:\n" + idx := strings.Index(text, marker) + if idx < 0 { + t.Fatal("app service not found in docker-compose.main.yml") + } + rest := text[idx+1:] + next := regexp.MustCompile(`(?m)^ [a-zA-Z0-9_-]+:\s*$`) + locs := next.FindAllStringIndex(rest, -1) + block := rest + if len(locs) >= 2 { + block = rest[:locs[1][0]] + } + // Nail E-2: app depends_on events with service_healthy + re := regexp.MustCompile(`(?m)^\s+events:\s*\n\s+condition:\s*service_healthy\s*$`) + if !re.MatchString(block) { + t.Fatalf("docker-compose.main.yml app must depends_on events with condition: service_healthy\n--- app block ---\n%s", block) + } + appEnv, err := extractServiceENV(text, "app") + if err != nil { + t.Fatal(err) + } + if appEnv != "main" { + t.Fatalf("main app ENV want main, got %q", appEnv) + } +} diff --git a/internal/config/compose_flag_declaration_test.go b/internal/config/compose_flag_declaration_test.go new file mode 100644 index 00000000..038a237d --- /dev/null +++ b/internal/config/compose_flag_declaration_test.go @@ -0,0 +1,111 @@ +package config_test + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +var composeFilesForFlagCheck = []string{ + "docker-compose.dev.yml", + "docker-compose.test.yml", + "docker-compose.main.yml", +} + +func readComposeFile(t *testing.T, name string) string { + t.Helper() + root := composeRepoRoot(t) + raw, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + return string(raw) +} + +// serviceBlockText extracts one top-level compose service block's raw YAML text. +func serviceBlockText(t *testing.T, composeYAML, serviceName string) string { + t.Helper() + marker := "\n " + serviceName + ":\n" + idx := strings.Index(composeYAML, marker) + if idx < 0 { + t.Fatalf("service %q not found", serviceName) + } + rest := composeYAML[idx+1:] + next := regexp.MustCompile(`(?m)^ [a-zA-Z0-9_-]+:\s*$`) + locs := next.FindAllStringIndex(rest, -1) + if len(locs) >= 2 { + return rest[:locs[1][0]] + } + return rest +} + +// E-3: MULTI_INTENT_PLAN_TIER and U9-U12 must be declared (legacy/false) in all +// three environments' app service so operator-set env vars actually reach the +// container. Values must never change from legacy/false in this test. +func TestComposeAppDeclaresMultiIntentPlanTierAndExpansionFlags(t *testing.T) { + wantKeys := []string{ + "MULTI_INTENT_U9_SEGMENT:", + "MULTI_INTENT_U10_MUTATE:", + "MULTI_INTENT_U11_DOCGOV:", + "MULTI_INTENT_U12_RUN_CONTROL:", + } + for _, name := range composeFilesForFlagCheck { + block := serviceBlockText(t, readComposeFile(t, name), "app") + if !strings.Contains(block, "MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy}") { + t.Errorf("%s: app service must declare MULTI_INTENT_PLAN_TIER with legacy default", name) + } + for _, key := range wantKeys { + if !strings.Contains(block, key) { + t.Errorf("%s: app service missing %s declaration", name, key) + } + } + } +} + +// A-4: SEMANTIC_SUMMARY_SHADOW / PI_CHANGE_REPORT_NARRATIVE must be declared in +// all three environments (test/main were missing before Wave 2). +func TestComposeAppDeclaresSemanticSummaryAndNarrativeShadowFlags(t *testing.T) { + for _, name := range composeFilesForFlagCheck { + block := serviceBlockText(t, readComposeFile(t, name), "app") + for _, key := range []string{"SEMANTIC_SUMMARY_SHADOW:", "PI_CHANGE_REPORT_NARRATIVE:"} { + if !strings.Contains(block, key) { + t.Errorf("%s: app service missing %s declaration", name, key) + } + } + } +} + +// E-6: main must explicitly disable demo-user seeding at the compose layer +// (defense in depth; internal/auth/bootstrap.go already gates on cfg.Env=="main"). +func TestComposeMainSeedDemoUsersExplicitFalse(t *testing.T) { + block := serviceBlockText(t, readComposeFile(t, "docker-compose.main.yml"), "app") + if !strings.Contains(block, `SEED_DEMO_USERS: "false"`) { + t.Fatalf("docker-compose.main.yml: app service must declare SEED_DEMO_USERS: \"false\"") + } +} + +// E-4: CURSOR_RUNNER_URL has zero Go readers repo-wide; neither it nor the +// cursor-agent placeholder service should exist in any environment. +func TestComposeNoDeadCursorRunnerConfig(t *testing.T) { + for _, name := range composeFilesForFlagCheck { + text := readComposeFile(t, name) + if strings.Contains(text, "CURSOR_RUNNER_URL") { + t.Errorf("%s: dead CURSOR_RUNNER_URL must stay removed (E-4)", name) + } + if strings.Contains(text, "\n cursor-agent:\n") { + t.Errorf("%s: dead cursor-agent placeholder service must stay removed (E-4)", name) + } + } +} + +// X-1: SEMANTIC_FALLBACK_ENABLED has zero Go readers repo-wide and no design +// doc describing intended semantics; Wave 5 decision = delete, not re-declare. +func TestComposeNoDeadSemanticFallbackConfig(t *testing.T) { + for _, name := range composeFilesForFlagCheck { + if strings.Contains(readComposeFile(t, name), "SEMANTIC_FALLBACK_ENABLED") { + t.Errorf("%s: dead SEMANTIC_FALLBACK_ENABLED must stay removed (Wave5 X-1)", name) + } + } +} diff --git a/internal/ctxmgr/case_indexing.go b/internal/ctxmgr/case_indexing.go index 6cf2a76f..7aa2142c 100644 --- a/internal/ctxmgr/case_indexing.go +++ b/internal/ctxmgr/case_indexing.go @@ -196,7 +196,7 @@ func (m *ContextManager) SearchSimilarCases(ctx context.Context, opts CaseSearch results = append(results, item) } } - return results, nil + return results, rows.Err() } m.mu.RLock() diff --git a/internal/ctxmgr/chat_digest.go b/internal/ctxmgr/chat_digest.go index 2a0adf1f..508ed506 100644 --- a/internal/ctxmgr/chat_digest.go +++ b/internal/ctxmgr/chat_digest.go @@ -50,6 +50,9 @@ func (m *ContextManager) GenerateDailyDigest(ctx context.Context, sessionID, day rec.CreatedAt = createdAt msgs = append(msgs, rec) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan digest messages failed: %w", err) + } } else { m.mu.RLock() for _, r := range m.messages[sessionID] { @@ -367,6 +370,9 @@ func (m *ContextManager) ListDailyDigests(ctx context.Context, sessionID, from, _ = json.Unmarshal(oqBytes, &d.OpenQuestions) digests = append(digests, d) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan digests failed: %w", err) + } return digests, nil } diff --git a/internal/ctxmgr/chat_history.go b/internal/ctxmgr/chat_history.go index 253ed6ef..d61dd695 100644 --- a/internal/ctxmgr/chat_history.go +++ b/internal/ctxmgr/chat_history.go @@ -32,7 +32,7 @@ func (m *ContextManager) EnsureChatSession(ctx context.Context, sessionID string VALUES ($1, $2, $3, $4, now(), now()) ON CONFLICT (session_id) DO UPDATE SET title = COALESCE(NULLIF($4, ''), chat_sessions.title), - user_id = COALESCE($2, chat_sessions.user_id), + user_id = COALESCE(chat_sessions.user_id, $2), project_id = COALESCE($3, chat_sessions.project_id), updated_at = now() `, sessionID, uid, pid, title) @@ -79,7 +79,7 @@ func (m *ContextManager) EnsureChatSession(ctx context.Context, sessionID string if title != "" { sess.Title = title } - if userID > 0 { + if userID > 0 && sess.UserID <= 0 { sess.UserID = userID } if projectID > 0 { @@ -97,6 +97,22 @@ func (m *ContextManager) EnsureChatSession(ctx context.Context, sessionID string return ©, nil } +// ErrSessionOwnerMismatch 表示调用者试图操作已绑定给其他用户的会话。 +var ErrSessionOwnerMismatch = errors.New("ctxmgr: session owner mismatch") + +// EnsureChatSessionOwned 在写入前校验归属:已绑定且不匹配调用者时返回 +// ErrSessionOwnerMismatch;未绑定或本就是调用者本人时正常落地。 +func (m *ContextManager) EnsureChatSessionOwned( + ctx context.Context, sessionID string, callerUserID, projectID int64, title string, +) (*ChatSessionSummary, error) { + if callerUserID > 0 { + if owner, err := m.GetSessionOwnerID(ctx, sessionID); err == nil && owner > 0 && owner != callerUserID { + return nil, ErrSessionOwnerMismatch + } + } + return m.EnsureChatSession(ctx, sessionID, callerUserID, projectID, title) +} + // ListChatSessions 分页查询历史会话列表 func (m *ContextManager) ListChatSessions(ctx context.Context, filter SessionFilter) ([]ChatSessionSummary, error) { if filter.Limit <= 0 { @@ -136,7 +152,7 @@ func (m *ContextManager) ListChatSessions(ctx context.Context, filter SessionFil sessions = append(sessions, item) } } - return sessions, nil + return sessions, rows.Err() } m.mu.RLock() @@ -208,7 +224,7 @@ func (m *ContextManager) GetChatHistory(ctx context.Context, sessionID string, l msgs = append(msgs, rec) } } - return msgs, nil + return msgs, rows.Err() } m.mu.RLock() diff --git a/internal/ctxmgr/chat_memory.go b/internal/ctxmgr/chat_memory.go index 5b0ec34b..e75d70d8 100644 --- a/internal/ctxmgr/chat_memory.go +++ b/internal/ctxmgr/chat_memory.go @@ -285,6 +285,9 @@ func (m *ContextManager) SearchChatMemory(ctx context.Context, opts ChatMemorySe } } } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan memory chunks failed: %w", err) + } sort.Slice(candidates, func(i, j int) bool { return candidates[i].FinalScore > candidates[j].FinalScore diff --git a/internal/ctxmgr/chat_search.go b/internal/ctxmgr/chat_search.go index 88b84a36..375309d4 100644 --- a/internal/ctxmgr/chat_search.go +++ b/internal/ctxmgr/chat_search.go @@ -101,7 +101,7 @@ func (m *ContextManager) searchChatMessagesKeywordDB(ctx context.Context, q Chat MatchedBy: "keyword", }) } - return hits, nil + return hits, rows.Err() } // searchChatMessagesFuzzyDB 模糊相似度检索 (Postgres pg_trgm similarity) @@ -160,7 +160,7 @@ func (m *ContextManager) searchChatMessagesFuzzyDB(ctx context.Context, q ChatSe MatchedBy: "fuzzy", }) } - return hits, nil + return hits, rows.Err() } // searchChatMessagesSemanticDB 语义向量检索 (通过 context_memory_chunks 或降级为 keyword) @@ -232,6 +232,9 @@ func (m *ContextManager) searchChatMessagesSemanticDB(ctx context.Context, q Cha MatchedBy: "semantic", }) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan semantic search hits failed: %w", err) + } if len(hits) == 0 { return m.searchChatMessagesKeywordDB(ctx, q) diff --git a/internal/ctxmgr/chat_turns.go b/internal/ctxmgr/chat_turns.go index 2d2c7c84..b31d2527 100644 --- a/internal/ctxmgr/chat_turns.go +++ b/internal/ctxmgr/chat_turns.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "errors" "fmt" - "sync" "time" ) @@ -165,8 +164,25 @@ func (m *ContextManager) acquireChatTurnDB( return &AcquireTurnResult{Record: rec, WonLease: true}, nil } -var memTurnsMu sync.Mutex -var memTurns = map[string]*ChatTurnRecord{} // sessionID|clientTurnID -> record + +const maxMemTurns = 2000 +const memTurnTTL = 2 * time.Hour + +// pruneStaleTurnsLocked 调用方需持有 m.mu 写锁 +func (m *ContextManager) pruneStaleTurnsLocked(ttl time.Duration) int { + if ttl <= 0 { + ttl = memTurnTTL + } + now := time.Now() + cleaned := 0 + for k, rec := range m.turns { + if rec == nil || now.Sub(rec.UpdatedAt) > ttl { + delete(m.turns, k) + cleaned++ + } + } + return cleaned +} func turnMemKey(sessionID, clientTurnID string) string { return sessionID + "|" + clientTurnID @@ -176,11 +192,11 @@ func (m *ContextManager) acquireChatTurnMemory( sessionID, clientTurnID, bodyHash, requestID string, userID, projectID int64, ) (*AcquireTurnResult, error) { - memTurnsMu.Lock() - defer memTurnsMu.Unlock() + m.mu.Lock() + defer m.mu.Unlock() key := turnMemKey(sessionID, clientTurnID) - if existing, ok := memTurns[key]; ok { + if existing, ok := m.turns[key]; ok { if existing.RequestBodySHA256 != bodyHash { return nil, ErrIdempotencyKeyReused } @@ -205,7 +221,7 @@ func (m *ContextManager) acquireChatTurnMemory( now := time.Now() leaseUntil := now.Add(5 * time.Minute) - if existing, ok := memTurns[key]; ok { + if existing, ok := m.turns[key]; ok { existing.Status = TurnStatusProcessing existing.OwnerToken = fmt.Sprintf("mem_%d", now.UnixNano()) existing.LastRequestID = requestID @@ -215,8 +231,12 @@ func (m *ContextManager) acquireChatTurnMemory( return &AcquireTurnResult{Record: existing, WonLease: true}, nil } + if len(m.turns) > maxMemTurns { + m.pruneStaleTurnsLocked(memTurnTTL) + } + rec := &ChatTurnRecord{ - ID: int64(len(memTurns) + 1), + ID: int64(len(m.turns) + 1), SessionID: sessionID, ClientTurnID: clientTurnID, UserID: userID, @@ -231,7 +251,7 @@ func (m *ContextManager) acquireChatTurnMemory( UpdatedAt: now, LeaseExpiresAt: &leaseUntil, } - memTurns[key] = rec + m.turns[key] = rec return &AcquireTurnResult{Record: rec, WonLease: true}, nil } @@ -244,9 +264,9 @@ func (m *ContextManager) UpdateChatTurnUserMessage(ctx context.Context, sessionI `, userMessageID, sessionID, clientTurnID) return err } - memTurnsMu.Lock() - defer memTurnsMu.Unlock() - if rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]; ok { + m.mu.Lock() + defer m.mu.Unlock() + if rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]; ok { rec.UserMessageID = userMessageID rec.UpdatedAt = time.Now() } @@ -264,9 +284,9 @@ func (m *ContextManager) CompleteChatTurn(ctx context.Context, sessionID, client `, TurnStatusCompleted, assistantMessageID, now, sessionID, clientTurnID) return err } - memTurnsMu.Lock() - defer memTurnsMu.Unlock() - if rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]; ok { + m.mu.Lock() + defer m.mu.Unlock() + if rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]; ok { rec.Status = TurnStatusCompleted rec.AssistantMessageID = assistantMessageID rec.CompletedAt = &now @@ -284,9 +304,9 @@ func (m *ContextManager) FailChatTurn(ctx context.Context, sessionID, clientTurn `, TurnStatusFailed, sessionID, clientTurnID) return err } - memTurnsMu.Lock() - defer memTurnsMu.Unlock() - if rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]; ok { + m.mu.Lock() + defer m.mu.Unlock() + if rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]; ok { rec.Status = TurnStatusFailed rec.UpdatedAt = time.Now() } @@ -323,9 +343,9 @@ func (m *ContextManager) GetChatTurn(ctx context.Context, sessionID, clientTurnI rec.LeaseExpiresAt = leaseExpiresAt return &rec, nil } - memTurnsMu.Lock() - defer memTurnsMu.Unlock() - rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)] + m.mu.Lock() + defer m.mu.Unlock() + rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)] if !ok { return nil, ErrChatTurnNotFound } diff --git a/internal/ctxmgr/chat_turns_test.go b/internal/ctxmgr/chat_turns_test.go index 354ad713..8e0dae68 100644 --- a/internal/ctxmgr/chat_turns_test.go +++ b/internal/ctxmgr/chat_turns_test.go @@ -2,12 +2,13 @@ package ctxmgr import ( "context" + "strconv" "sync" "testing" + "time" ) func TestAcquireChatTurn_DuplicateSameBody(t *testing.T) { - memTurns = map[string]*ChatTurnRecord{} mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() sessionID := "sess-dup" @@ -32,7 +33,6 @@ func TestAcquireChatTurn_DuplicateSameBody(t *testing.T) { } func TestAcquireChatTurn_ConcurrentDuplicate(t *testing.T) { - memTurns = map[string]*ChatTurnRecord{} mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() sessionID := "sess-conc" @@ -65,7 +65,6 @@ func TestAcquireChatTurn_ConcurrentDuplicate(t *testing.T) { } func TestAcquireChatTurn_DifferentBodyRejected(t *testing.T) { - memTurns = map[string]*ChatTurnRecord{} mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() sessionID := "sess-reuse" @@ -80,3 +79,32 @@ func TestAcquireChatTurn_DifferentBodyRejected(t *testing.T) { t.Fatalf("expected ErrIdempotencyKeyReused, got %v", err) } } + +func TestAcquireChatTurn_StaleEntriesPruned(t *testing.T) { + mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + ctx := context.Background() + mgr.mu.Lock() + now := time.Now() + for i := 0; i < maxMemTurns+50; i++ { + key := turnMemKey("sess-prune", "old-"+strconv.Itoa(i)) + mgr.turns[key] = &ChatTurnRecord{ + ID: int64(i + 1), + SessionID: "sess-prune", + ClientTurnID: "old-" + strconv.Itoa(i), + Status: TurnStatusCompleted, + UpdatedAt: now.Add(-3 * time.Hour), + } + } + mgr.mu.Unlock() + bodyHash := HashTurnBody("sess-prune", "fresh") + _, err := mgr.AcquireChatTurn(ctx, "sess-prune", "fresh-turn", bodyHash, "req-fresh", 1, 1) + if err != nil { + t.Fatalf("acquire: %v", err) + } + mgr.mu.RLock() + n := len(mgr.turns) + mgr.mu.RUnlock() + if n > maxMemTurns { + t.Fatalf("expected turns pruned under capacity, got %d", n) + } +} diff --git a/internal/ctxmgr/chat_unread.go b/internal/ctxmgr/chat_unread.go index d2a768de..b2d5499b 100644 --- a/internal/ctxmgr/chat_unread.go +++ b/internal/ctxmgr/chat_unread.go @@ -322,6 +322,10 @@ func (m *ContextManager) markChatReadPG(ctx context.Context, req MarkChatReadReq futureIDs = append(futureIDs, mid) } } + if err := rows.Err(); err != nil { + rows.Close() + return nil, fmt.Errorf("scan future unread ids failed: %w", err) + } rows.Close() } diff --git a/internal/ctxmgr/chat_window.go b/internal/ctxmgr/chat_window.go index 22cf0420..e735bd55 100644 --- a/internal/ctxmgr/chat_window.go +++ b/internal/ctxmgr/chat_window.go @@ -72,6 +72,9 @@ func (m *ContextManager) getChatWindowPG(ctx context.Context, q ChatWindowQuery) descDays = append(descDays, d.Format("2006-01-02")) } } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan chat window days failed: %w", err) + } if len(descDays) == 0 { return &ChatWindowResult{ @@ -601,6 +604,9 @@ func (m *ContextManager) ListChatDates(ctx context.Context, sessionID string) ([ st.Categories[cat] += cnt } } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan chat dates failed: %w", err) + } res := make([]ChatDateStat, 0, len(orderedKeys)) for _, k := range orderedKeys { @@ -688,7 +694,7 @@ func (m *ContextManager) ListChatCategories(ctx context.Context, sessionID strin LastMessageID: maxID, }) } - return res, nil + return res, rows.Err() } m.mu.RLock() diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 430e60e5..864ec7a3 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -1,6 +1,7 @@ package ctxmgr import ( + "log/slog" "context" "encoding/json" "errors" @@ -32,6 +33,7 @@ type ContextManager struct { summaries map[string]*ChatSummary // session_id -> rolling summary digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest readPositions map[string]*ChatReadPosition // session_id:user_id -> read position + turns map[string]*ChatTurnRecord // sessionID|clientTurnID -> 幂等回合(仅内存降级) cache *MultiLevelCache // 多级统一缓存系统 (C1-C5) semanticSummarizer SemanticSummarizer // no-tool summarizer; nil → heuristic default nextActivityID int64 @@ -59,6 +61,7 @@ func NewContextManager(pool *pgxpool.Pool, cas *storage.CAS, decayCfg TimeDecayC summaries: make(map[string]*ChatSummary), digests: make(map[string]map[string]*DailyDigest), readPositions: make(map[string]*ChatReadPosition), + turns: make(map[string]*ChatTurnRecord), cache: NewMultiLevelCache(), nextActivityID: 1, nextMessageID: 1, @@ -190,13 +193,13 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID } } - // 确保会话存在 + // 确保会话存在(user_id 已有值优先,防止跨用户覆盖归属) _, _ = m.pool.Exec(ctx, ` INSERT INTO chat_sessions (session_id, user_id, project_id, title, created_at, updated_at) VALUES ($1, $2, $3, '对话协同会话', now(), now()) ON CONFLICT (session_id) DO UPDATE SET updated_at = now(), - user_id = COALESCE($2, chat_sessions.user_id), + user_id = COALESCE(chat_sessions.user_id, $2), project_id = COALESCE($3, chat_sessions.project_id) `, sessionID, uid, pid) @@ -383,6 +386,9 @@ func (m *ContextManager) GetUserContext(ctx context.Context, projectID, userID i userCtx.RecentChats = append(userCtx.RecentChats, r) } } + if err := rows.Err(); err != nil { + slog.Warn("ctxmgr_recent_chats_scan_incomplete", "error", err) + } } } else { m.mu.RLock() @@ -410,6 +416,9 @@ func (m *ContextManager) GetUserContext(ctx context.Context, projectID, userID i rawActs = append(rawActs, it) } } + if err := rows.Err(); err != nil { + slog.Warn("ctxmgr_user_activities_scan_incomplete", "error", err) + } } } else { m.mu.RLock() @@ -481,6 +490,9 @@ func (m *ContextManager) GetProjectContext(ctx context.Context, projectID int64) } } } + if err := rows.Err(); err != nil { + slog.Warn("ctxmgr_project_activities_scan_incomplete", "error", err) + } pCtx.AllActivities = ApplyTimeDecayToActivities(rawActs, m.decayCfg) } } else { @@ -727,6 +739,9 @@ func (m *ContextManager) ReflectUserPreference(ctx context.Context, userID int64 msgs = append(msgs, r) } } + if err := rows.Err(); err != nil { + slog.Warn("ctxmgr_reflect_msgs_scan_incomplete", "error", err) + } } } else { m.mu.RLock() diff --git a/internal/ctxmgr/project_dialogue.go b/internal/ctxmgr/project_dialogue.go index 69a0467e..49d3b2d0 100644 --- a/internal/ctxmgr/project_dialogue.go +++ b/internal/ctxmgr/project_dialogue.go @@ -69,6 +69,9 @@ func (m *ContextManager) CollectProjectDialogue(ctx context.Context, projectID i } msgs = append(msgs, rec) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan project dialogue failed: %w", err) + } } else { m.mu.RLock() for sessionID, sessionMsgs := range m.messages { diff --git a/internal/ctxmgr/recategorize.go b/internal/ctxmgr/recategorize.go index 98250818..2a8a4c8f 100644 --- a/internal/ctxmgr/recategorize.go +++ b/internal/ctxmgr/recategorize.go @@ -64,6 +64,9 @@ func (m *ContextManager) RecategorizeMessages(ctx context.Context, sessionID str } msgs = append(msgs, r) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan messages for recategorize failed: %w", err) + } var lastUserMsg *recatMsg for _, msg := range msgs { diff --git a/internal/ctxmgr/semantic_summary.go b/internal/ctxmgr/semantic_summary.go index d8ab6e58..de8d72ab 100644 --- a/internal/ctxmgr/semantic_summary.go +++ b/internal/ctxmgr/semantic_summary.go @@ -253,7 +253,9 @@ func applySemanticShadow(ctx context.Context, sum *ChatSummary, msgs []ChatMessa if report.Protocol == "" { report.Protocol = semanticProtocolV1 } - // Explicitly do NOT swap sum.Narrative even if REPLACE env is true. + // Explicitly do NOT swap sum.Narrative even if REPLACE env is true + // (Wave 5 X-2 sealed; see wave5-deadcode-decisions.md#x-2). + // Keep the call so envBool parsing stays covered; do not "fix" by wiring REPLACE. _ = SemanticSummaryReplaceEnabled() sum.Shadow = report } diff --git a/internal/ctxmgr/summary_flags.go b/internal/ctxmgr/summary_flags.go index 31bf2764..c6d43a26 100644 --- a/internal/ctxmgr/summary_flags.go +++ b/internal/ctxmgr/summary_flags.go @@ -7,12 +7,18 @@ import ( ) const ( - EnvSemanticSummaryShadow = "SEMANTIC_SUMMARY_SHADOW" + EnvSemanticSummaryShadow = "SEMANTIC_SUMMARY_SHADOW" + // SEALED (Wave 5 X-2, 2026-09-18): REPLACE must stay unwired indefinitely per + // system-health-remediation-plan.md Wave5 + red-line "SEMANTIC_SUMMARY_REPLACE + // 不接线". Enabling REPLACE to swap served narrative requires a separate + // explicit user authorization + fail-closed rollout design; do not wire it as + // a side effect of an unrelated change. See .doc/ops/short-term/2026-09-18- + // wave5-deadcode-decisions.md#x-2. EnvSemanticSummaryReplace = "SEMANTIC_SUMMARY_REPLACE" ) // SummaryFeatureFlags controls semantic summary rollout. -// REPLACE is reserved and must remain unwired in PU6 shadow delivery. +// REPLACE is reserved and must remain unwired in PU6 shadow delivery (Wave 5 X-2 sealed). type SummaryFeatureFlags struct { Shadow bool Replace bool @@ -51,7 +57,8 @@ func SemanticSummaryShadowEnabled() bool { } // SemanticSummaryReplaceEnabled reports the REPLACE env bit. -// PU6 must not use this to swap the served deterministic narrative. +// SEALED (Wave 5 X-2): PU6 must not use this to swap the served deterministic narrative. +// See .doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md#x-2. func SemanticSummaryReplaceEnabled() bool { return CurrentSummaryFlags().Replace } diff --git a/internal/docduty/duty_locked_filter_test.go b/internal/docduty/duty_locked_filter_test.go new file mode 100644 index 00000000..a7d17773 --- /dev/null +++ b/internal/docduty/duty_locked_filter_test.go @@ -0,0 +1,18 @@ +package docduty + +import ( + "strings" + "testing" +) + +func TestClearDutySlotSQL_SkipsLockedRows(t *testing.T) { + for _, duty := range []string{DutyReviewer, DutyApprover} { + sql, ok := clearDutySlotSQL(duty) + if !ok { + t.Fatalf("%s: expected sql", duty) + } + if !strings.Contains(sql, "NOT duty_locked") { + t.Fatalf("%s sql missing duty_locked filter:\n%s", duty, sql) + } + } +} diff --git a/internal/docduty/duty_locked_pg_test.go b/internal/docduty/duty_locked_pg_test.go new file mode 100644 index 00000000..59a83306 --- /dev/null +++ b/internal/docduty/duty_locked_pg_test.go @@ -0,0 +1,124 @@ +package docduty + +import ( + "context" + "fmt" + "testing" + "time" +) + +func TestHealProjectDocuments_SkipsDutyLocked_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + suffix := time.Now().UnixNano() + authorID := insertDutyUser(t, ctx, pool, suffix, "lk_author") + reviewerID := insertDutyUser(t, ctx, pool, suffix, "lk_reviewer") + approverID := insertDutyUser(t, ctx, pool, suffix, "lk_approver") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-lock-%d", suffix), "locked heal", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2,$3)`, authorID, reviewerID, approverID) + }) + + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'author', 'active', now(), now()), + ($1, $3, 'member', 'reviewer', 'active', now(), now()), + ($1, $4, 'member', 'approver', 'active', now(), now()) + `, projectID, authorID, reviewerID, approverID); err != nil { + t.Fatalf("insert members: %v", err) + } + + lockedDoc := fmt.Sprintf("doc_proj%d_intake", projectID) + openDoc := fmt.Sprintf("doc_proj%d_research", projectID) + // 锁定文档:A/A/A,与项目默认分离职责不同 + insertDutyDoc(t, ctx, pool, projectID, lockedDoc, "intake", authorID, authorID, authorID) + if _, err := pool.Exec(ctx, ` + UPDATE document_records + SET duty_locked = true, duty_policy = 'author_only' + WHERE doc_id = $1 + `, lockedDoc); err != nil { + t.Fatalf("lock doc: %v", err) + } + // 未锁定文档:错误槽位,应被 heal 纠正 + insertDutyDoc(t, ctx, pool, projectID, openDoc, "research", authorID, 0, 0) + + _, n, err := HealProjectDocuments(ctx, pool, projectID) + if err != nil { + t.Fatalf("heal: %v", err) + } + if n == 0 { + t.Fatal("expected unlocked doc to be healed") + } + + assertDocAuthorID(t, ctx, pool, lockedDoc, authorID) + assertDocReviewer(t, ctx, pool, lockedDoc, authorID) + assertDocApprover(t, ctx, pool, lockedDoc, authorID) + assertDocReviewer(t, ctx, pool, openDoc, reviewerID) + assertDocApprover(t, ctx, pool, openDoc, approverID) +} + +func TestApplySlotRevocations_SkipsDutyLocked_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + suffix := time.Now().UnixNano() + authorID := insertDutyUser(t, ctx, pool, suffix, "rv_author") + reviewerID := insertDutyUser(t, ctx, pool, suffix, "rv_reviewer") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-revlock-%d", suffix), "locked revoke", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2)`, authorID, reviewerID) + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'author', 'active', now(), now()), + ($1, $3, 'member', 'reviewer', 'active', now(), now()) + `, projectID, authorID, reviewerID); err != nil { + t.Fatalf("insert members: %v", err) + } + + lockedDoc := fmt.Sprintf("doc_proj%d_intake", projectID) + openDoc := fmt.Sprintf("doc_proj%d_research", projectID) + insertDutyDoc(t, ctx, pool, projectID, lockedDoc, "intake", authorID, reviewerID, 0) + insertDutyDoc(t, ctx, pool, projectID, openDoc, "research", authorID, reviewerID, 0) + if _, err := pool.Exec(ctx, ` + UPDATE document_records SET duty_locked = true, duty_policy = 'author_only' WHERE doc_id = $1 + `, lockedDoc); err != nil { + t.Fatalf("lock: %v", err) + } + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + if err := ApplySlotRevocations(ctx, tx, projectID, PlanSlotRevocations(MemberDutyChange{ + UserID: reviewerID, PreviousDuty: DutyReviewer, NextDuty: DutyNone, + })); err != nil { + _ = tx.Rollback(ctx) + t.Fatal(err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + + assertDocReviewer(t, ctx, pool, lockedDoc, reviewerID) + assertDocReviewer(t, ctx, pool, openDoc, 0) +} diff --git a/internal/docduty/revoke.go b/internal/docduty/revoke.go index 641cf084..07017df6 100644 --- a/internal/docduty/revoke.go +++ b/internal/docduty/revoke.go @@ -153,13 +153,13 @@ func clearDutySlotSQL(duty string) (string, bool) { return ` UPDATE document_records SET reviewer_id = NULL, reviewer_name = '', updated_at = now() - WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND reviewer_id = $2 + WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND reviewer_id = $2 AND NOT duty_locked `, true case DutyApprover: return ` UPDATE document_records SET approver_id = NULL, approver_name = '', updated_at = now() - WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND approver_id = $2 + WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND approver_id = $2 AND NOT duty_locked `, true default: return "", false diff --git a/internal/docduty/store.go b/internal/docduty/store.go index ec45112d..da12502f 100644 --- a/internal/docduty/store.go +++ b/internal/docduty/store.go @@ -108,7 +108,7 @@ func backfillMissingDocumentDuties(ctx context.Context, db dutyDB, projectID int COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''), COALESCE(approver_id, 0), COALESCE(approver_name, '') FROM document_records - WHERE project_id = $1 AND COALESCE(task_key, '') <> '' + WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND NOT duty_locked `, projectID) if err != nil { return 0, fmt.Errorf("docduty: list documents: %w", err) diff --git a/internal/docgov/duty_lock_test.go b/internal/docgov/duty_lock_test.go new file mode 100644 index 00000000..16373e6a --- /dev/null +++ b/internal/docgov/duty_lock_test.go @@ -0,0 +1,55 @@ +package docgov + +import ( + "context" + "testing" +) + +func TestLockDocumentDuty_Memory(t *testing.T) { + ctx := context.Background() + engine := NewGovernanceEngine(nil) + doc, err := engine.CreateOrGetDocument( + ctx, "doc_lock_mem", 9, "intake", "锁定测试", + "V0.5", "ws/lock", + 1, "A", 1, "A", 1, "A", + ) + if err != nil { + t.Fatalf("create: %v", err) + } + if doc.DutyLocked || doc.DutyPolicy != "" { + t.Fatalf("defaults: locked=%v policy=%q", doc.DutyLocked, doc.DutyPolicy) + } + if err := engine.LockDocumentDuty(ctx, doc.DocID, "author_only"); err != nil { + t.Fatalf("lock: %v", err) + } + got, err := engine.GetDocument(ctx, doc.DocID) + if err != nil { + t.Fatalf("get: %v", err) + } + if !got.DutyLocked || got.DutyPolicy != "author_only" { + t.Fatalf("after lock: locked=%v policy=%q", got.DutyLocked, got.DutyPolicy) + } +} + +func TestListWorkflowDocuments_IncludesDutyPolicy_Memory(t *testing.T) { + ctx := context.Background() + engine := NewGovernanceEngine(nil) + doc, err := engine.CreateOrGetDocument( + ctx, "doc_proj9_intake", 9, "intake", "投影测试", + "V0.5", "ws/proj", + 7, "编制", 7, "编制", 7, "编制", + ) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := engine.LockDocumentDuty(ctx, doc.DocID, "author_only"); err != nil { + t.Fatalf("lock: %v", err) + } + list, err := engine.ListWorkflowDocumentsByProject(ctx, 9) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 1 || list[0].DutyPolicy != "author_only" { + t.Fatalf("want duty_policy=author_only, got %+v", list) + } +} diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index 9e650e44..7e066959 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -1,6 +1,7 @@ package docgov import ( + "log/slog" "context" "encoding/json" "fmt" @@ -218,6 +219,43 @@ func (e *GovernanceEngine) CreateOrGetDocument( return doc, nil } +// LockDocumentDuty 将文档职责钉住为实例治理结论;heal/revoke 跳过 duty_locked 行。 +// 不改写 author/reviewer/approver 列——那些在 CreateOrGetDocument 时已由调用方写入。 +func (e *GovernanceEngine) LockDocumentDuty(ctx context.Context, docID string, dutyPolicy string) error { + docID = strings.TrimSpace(docID) + if docID == "" { + return fmt.Errorf("docgov: empty doc_id") + } + dutyPolicy = strings.TrimSpace(dutyPolicy) + + e.mu.Lock() + defer e.mu.Unlock() + + doc, err := e.ensureDocumentLocked(ctx, docID) + if err != nil { + return err + } + now := time.Now() + doc.DutyLocked = true + doc.DutyPolicy = dutyPolicy + doc.UpdatedAt = now + + if e.pool != nil { + tag, execErr := e.pool.Exec(ctx, ` + UPDATE document_records + SET duty_locked = true, duty_policy = $2, updated_at = $3 + WHERE doc_id = $1 + `, docID, dutyPolicy, now) + if execErr != nil { + return fmt.Errorf("docgov: lock document duty: %w", execErr) + } + if tag.RowsAffected() == 0 { + return ErrDocumentNotFound + } + } + return nil +} + // MarkDocumentContentUpdated 当正文生成或更新时标记正文哈希及是否脱离模板状态。 // 未预加载时从 DB 补偿装入;已是目标哈希则幂等成功,保证 repair 重试也能同步记录。 func (e *GovernanceEngine) MarkDocumentContentUpdated(ctx context.Context, docID string, contentSHA256 string, isTemplateOnly bool) error { @@ -985,6 +1023,9 @@ func (e *GovernanceEngine) ListAuditLogs(ctx context.Context, docID string) ([]* e.memAudits[docID] = append(e.memAudits[docID], &a) } } + if err := rows.Err(); err != nil { + slog.Warn("docgov_audit_scan_incomplete", "error", err) + } } } @@ -1021,6 +1062,9 @@ func (e *GovernanceEngine) ListComments(ctx context.Context, docID string) ([]*D e.memComms[docID] = append(e.memComms[docID], &c) } } + if err := rows.Err(); err != nil { + slog.Warn("docgov_comments_scan_incomplete", "error", err) + } } } @@ -1043,7 +1087,8 @@ func (e *GovernanceEngine) loadDocumentFromDBLocked(ctx context.Context, docID s last_rejected_reason, last_rejected_by_role, last_rejected_by_name, last_rejected_at, approved_at, config, created_at, updated_at, COALESCE(lease_holder_type, ''), COALESCE(lease_holder_id, ''), COALESCE(lease_work_dir, ''), - lease_expires_at, lease_heartbeat_at + lease_expires_at, lease_heartbeat_at, + COALESCE(duty_locked, false), COALESCE(duty_policy, '') FROM document_records WHERE doc_id = $1 `, docID) @@ -1062,6 +1107,7 @@ func (e *GovernanceEngine) loadDocumentFromDBLocked(ctx context.Context, docID s &doc.LastRejectedReason, &doc.LastRejectedByRole, &doc.LastRejectedByName, &doc.LastRejectedAt, &doc.ApprovedAt, &cfgJSON, &doc.CreatedAt, &doc.UpdatedAt, &leaseType, &leaseID, &leaseWD, &doc.LeaseExpiresAt, &doc.LeaseHeartbeatAt, + &doc.DutyLocked, &doc.DutyPolicy, ) if err != nil { return nil, err diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go index 30e2cb38..96294223 100644 --- a/internal/docgov/project_docs.go +++ b/internal/docgov/project_docs.go @@ -36,6 +36,7 @@ type WorkflowDocumentSummary struct { ReviewerName string `json:"reviewer_name"` ApproverID int64 `json:"approver_id"` ApproverName string `json:"approver_name"` + DutyPolicy string `json:"duty_policy,omitempty"` } // DocumentContent 受控文档正文读取结果。 @@ -67,7 +68,8 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p lease_expires_at, lease_heartbeat_at, COALESCE(author_id, 0), COALESCE(author_name, ''), COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''), - COALESCE(approver_id, 0), COALESCE(approver_name, '') + COALESCE(approver_id, 0), COALESCE(approver_name, ''), + COALESCE(duty_policy, '') FROM document_records WHERE project_id = $1 AND task_key <> '' ORDER BY task_key ASC, doc_id ASC @@ -86,7 +88,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p &item.IsTemplateOnly, &item.ContentSHA256, &item.WorkDir, &item.Version, &updatedAt, &item.LeaseHolderType, &item.LeaseHolderID, &item.LeaseExpiresAt, &item.LeaseHeartbeatAt, &item.AuthorID, &item.AuthorName, &item.ReviewerID, &item.ReviewerName, - &item.ApproverID, &item.ApproverName, + &item.ApproverID, &item.ApproverName, &item.DutyPolicy, ); err != nil { return nil, err } @@ -124,6 +126,7 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p ReviewerName: doc.ReviewerName, ApproverID: doc.ApproverID, ApproverName: doc.ApproverName, + DutyPolicy: doc.DutyPolicy, }) } } diff --git a/internal/docgov/state_machine.go b/internal/docgov/state_machine.go index d75ac8ef..8dd09008 100644 --- a/internal/docgov/state_machine.go +++ b/internal/docgov/state_machine.go @@ -83,7 +83,7 @@ func (sm *StateMachine) TransitionSubmitForReview(doc *DocumentRecord, actorID i ActorRole: actorRole, FromState: fromState, ToState: toState, - Details: map[string]any{"timestamp": now}, + Details: map[string]any{"timestamp": now, "duty_policy": doc.DutyPolicy}, CreatedAt: now, }, nil } @@ -120,7 +120,7 @@ func (sm *StateMachine) TransitionReviewApprove(doc *DocumentRecord, actorID int ActorRole: actorRole, FromState: fromState, ToState: toState, - Details: map[string]any{"comment": comment, "timestamp": now}, + Details: map[string]any{"comment": comment, "timestamp": now, "duty_policy": doc.DutyPolicy}, CreatedAt: now, }, nil } @@ -169,6 +169,7 @@ func (sm *StateMachine) TransitionReviewReject(doc *DocumentRecord, actorID int6 Details: map[string]any{ "reject_reason": trimmedReason, "timestamp": now, + "duty_policy": doc.DutyPolicy, }, CreatedAt: now, }, nil @@ -207,8 +208,9 @@ func (sm *StateMachine) TransitionApproverApprove(doc *DocumentRecord, actorID i FromState: fromState, ToState: toState, Details: map[string]any{ - "comment": comment, - "timestamp": now, + "comment": comment, + "timestamp": now, + "duty_policy": doc.DutyPolicy, }, CreatedAt: now, }, nil @@ -276,6 +278,7 @@ func (sm *StateMachine) TransitionApproverReject(doc *DocumentRecord, actorID in "reject_reason": trimmedReason, "rollback_to": toState, "timestamp": now, + "duty_policy": doc.DutyPolicy, }, CreatedAt: now, }, nil diff --git a/internal/docgov/state_machine_duty_policy_test.go b/internal/docgov/state_machine_duty_policy_test.go new file mode 100644 index 00000000..f55b9c19 --- /dev/null +++ b/internal/docgov/state_machine_duty_policy_test.go @@ -0,0 +1,74 @@ +package docgov + +import ( + "testing" +) + +func TestTransitionAuditDetailsIncludeDutyPolicy(t *testing.T) { + sm := NewStateMachine() + doc := &DocumentRecord{ + DocID: "doc_audit_policy", + CurrentState: StateDrafting, + AuthorID: 7, + ReviewerID: 7, + ApproverID: 7, + DutyPolicy: "author_only", + DutyLocked: true, + Config: DefaultDocumentConfig(), + } + + audit, err := sm.TransitionSubmitForReview(doc, 7, RoleAuthor, "A") + if err != nil { + t.Fatalf("submit: %v", err) + } + if audit.Details["duty_policy"] != "author_only" { + t.Fatalf("submit details: %+v", audit.Details) + } + + audit, err = sm.TransitionReviewApprove(doc, 7, RoleReviewer, "A", "ok") + if err != nil { + t.Fatalf("review: %v", err) + } + if audit.Details["duty_policy"] != "author_only" { + t.Fatalf("review details: %+v", audit.Details) + } + + audit, err = sm.TransitionApproverApprove(doc, 7, RoleApprover, "A", "ok") + if err != nil { + t.Fatalf("approve: %v", err) + } + if audit.Details["duty_policy"] != "author_only" { + t.Fatalf("approve details: %+v", audit.Details) + } +} + +func TestTransitionRejectAuditDetailsIncludeDutyPolicy(t *testing.T) { + sm := NewStateMachine() + doc := &DocumentRecord{ + DocID: "doc_audit_reject", + CurrentState: StateReviewing, + IsLocked: true, + AuthorID: 7, + ReviewerID: 7, + ApproverID: 7, + DutyPolicy: "author_only", + Config: DefaultDocumentConfig(), + } + audit, err := sm.TransitionReviewReject(doc, 7, RoleReviewer, "A", "need fix") + if err != nil { + t.Fatalf("review reject: %v", err) + } + if audit.Details["duty_policy"] != "author_only" { + t.Fatalf("review reject details: %+v", audit.Details) + } + + doc.CurrentState = StateApproving + doc.IsLocked = true + audit, err = sm.TransitionApproverReject(doc, 7, RoleApprover, "A", "need rework", StateDrafting) + if err != nil { + t.Fatalf("approver reject: %v", err) + } + if audit.Details["duty_policy"] != "author_only" { + t.Fatalf("approver reject details: %+v", audit.Details) + } +} diff --git a/internal/docgov/types.go b/internal/docgov/types.go index 01ea18aa..5a1dd710 100644 --- a/internal/docgov/types.go +++ b/internal/docgov/types.go @@ -166,6 +166,10 @@ type DocumentRecord struct { ApproverID int64 `json:"approver_id"` ApproverName string `json:"approver_name"` + // DutyLocked 为 true 时,职责权威来自工作流实例规划,heal/revoke 不得覆盖。 + DutyLocked bool `json:"duty_locked"` + DutyPolicy string `json:"duty_policy,omitempty"` + // 认领状态 (Claiming State) AuthorClaimed bool `json:"author_claimed"` // 编制人是否已认领 (非模板状态查看/编辑后为 true) AuthorClaimedAt *time.Time `json:"author_claimed_at"` diff --git a/internal/docrevision/pg_store.go b/internal/docrevision/pg_store.go index 8c8cf96e..a710c122 100644 --- a/internal/docrevision/pg_store.go +++ b/internal/docrevision/pg_store.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "time" "chat-agent-system/internal/app/uow" @@ -65,8 +66,12 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &sn.SourceKind, &sn.ActorUserID, &sn.ActorRef, &actorRaw, &sn.SourceRunID, &sn.CreatedAt); err != nil { return err } - _ = json.Unmarshal(blockRaw, &sn.BlockMap) - _ = json.Unmarshal(actorRaw, &sn.ActorSnapshot) + if err := json.Unmarshal(blockRaw, &sn.BlockMap); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal block_map for doc %s snapshot %d: %w", docID, sn.ID, err) + } + if err := json.Unmarshal(actorRaw, &sn.ActorSnapshot); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal actor_snapshot for doc %s snapshot %d: %w", docID, sn.ID, err) + } snaps = append(snaps, snapRow{snap: sn}) shas[sn.ContentSHA256] = struct{}{} } @@ -141,8 +146,12 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &ev.EditSessionID, &ev.RequestID, &ev.IdempotencyKey, &ev.CreatedAt); err != nil { return err } - _ = json.Unmarshal(actorRaw, &ev.ActorSnapshot) - _ = json.Unmarshal(changeRaw, &ev.ChangeSet) + if err := json.Unmarshal(actorRaw, &ev.ActorSnapshot); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal actor_snapshot for doc %s event %d: %w", docID, ev.ID, err) + } + if err := json.Unmarshal(changeRaw, &ev.ChangeSet); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal change_set for doc %s event %d: %w", docID, ev.ID, err) + } cp := ev s.events[cp.ID] = &cp if cp.IdempotencyKey != "" { @@ -173,12 +182,24 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &rep.ModelSnapshot, &rep.PromptVersion, &factRaw, &rep.CorrectsReportID, &rep.RawJSON, &rep.CreatedAt); err != nil { return err } - _ = json.Unmarshal(sumRaw, &rep.Summary) - _ = json.Unmarshal(riskRaw, &rep.RiskItems) - _ = json.Unmarshal(projRaw, &rep.ProjectImpacts) - _ = json.Unmarshal(docRaw, &rep.DocumentImpacts) - _ = json.Unmarshal(qRaw, &rep.OpenQuestions) - _ = json.Unmarshal(factRaw, &rep.DeterministicFacts) + if err := json.Unmarshal(sumRaw, &rep.Summary); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal summary for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(riskRaw, &rep.RiskItems); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal risk_items for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(projRaw, &rep.ProjectImpacts); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal project_impacts for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(docRaw, &rep.DocumentImpacts); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal document_impacts for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(qRaw, &rep.OpenQuestions); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal open_questions for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(factRaw, &rep.DeterministicFacts); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal deterministic_facts for doc %s report %d: %w", docID, rep.ID, err) + } cp := rep s.reports[cp.ID] = &cp if cp.ID > s.nextRepID { @@ -205,7 +226,9 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &c.CreatedAt, &c.DecidedAt); err != nil { return err } - _ = json.Unmarshal(actorRaw, &c.ActorSnapshot) + if err := json.Unmarshal(actorRaw, &c.ActorSnapshot); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal actor_snapshot for doc %s candidate %d: %w", docID, c.ID, err) + } cp := c s.candidates[cp.ID] = &cp if cp.ID > s.nextCandID { diff --git a/internal/docrevision/pg_store_hydrate_corrupt_test.go b/internal/docrevision/pg_store_hydrate_corrupt_test.go new file mode 100644 index 00000000..74b0c674 --- /dev/null +++ b/internal/docrevision/pg_store_hydrate_corrupt_test.go @@ -0,0 +1,72 @@ +package docrevision_test + +import ( + "context" + "strings" + "testing" + "time" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/docrevision" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG(t *testing.T) { + dsn := db.SkipIfNoTestDatabaseURL(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + if err := db.RunMigrations(ctx, pool, db.ResolveMigrationsDir()); err != nil { + t.Fatalf("migrate: %v", err) + } + + var n int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_name='document_snapshots'`).Scan(&n); err != nil || n == 0 { + t.Skip("document_snapshots table missing after migrate") + } + + docID := "doc_hydrate_corrupt_jsonb" + _, _ = pool.Exec(ctx, `DELETE FROM document_change_events WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_agent_candidates WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_change_reports WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_versions WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_snapshots WHERE doc_id=$1`, docID) + + sha := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 64 chars + _, err = pool.Exec(ctx, ` + INSERT INTO document_content_blobs (content_sha256, content_markdown, byte_size, created_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (content_sha256) DO NOTHING + `, sha, "# corrupt fixture\n", int64(18)) + if err != nil { + t.Fatalf("insert content blob: %v", err) + } + + // jsonb 列拒绝非法 JSON;写入合法但类型不匹配的值,使 Go Unmarshal 到 map/struct 失败。 + _, err = pool.Exec(ctx, ` + INSERT INTO document_snapshots ( + doc_id, content_sha256, parent_snapshot_id, block_map, source_kind, + actor_user_id, actor_ref, actor_snapshot, source_run_id, created_at + ) VALUES ( + $1, $2, NULL, '"not-an-object"'::jsonb, 'human', + 1, 'tester', '{}'::jsonb, '', now() + ) + `, docID, sha) + if err != nil { + t.Fatalf("insert typed-wrong block_map: %v", err) + } + + svc := docrevision.NewService(pool) + _, err = svc.GetRevisionState(ctx, docID) + if err == nil { + t.Fatal("expected hydrate error for type-mismatched block_map JSONB") + } + if !strings.Contains(err.Error(), "hydrateDocLocked") || !strings.Contains(err.Error(), "block_map") { + t.Fatalf("expected explicit hydrate/block_map error, got: %v", err) + } +} diff --git a/internal/events/worker.go b/internal/events/worker.go index 5b5be912..4ec0cd7d 100644 --- a/internal/events/worker.go +++ b/internal/events/worker.go @@ -22,10 +22,11 @@ type Dispatcher struct { Batch int Lease time.Duration Heartbeat time.Duration - Handlers map[string]Handler - Metrics *Metrics + // Handlers 并发读写受 mu 保护;请勿绕过 Register() 直接写此字段。 + Handlers map[string]Handler + Metrics *Metrics - mu sync.Mutex + mu sync.RWMutex claiming bool inflight map[string]struct{} cancelFns []context.CancelFunc @@ -151,7 +152,11 @@ func (d *Dispatcher) shadowScan(ctx context.Context, consumer string) (int, erro if err != nil { return 0, err } - h := d.Handlers[consumer] + h := func() Handler { + d.mu.RLock() + defer d.mu.RUnlock() + return d.Handlers[consumer] + }() var n int for _, lease := range leases { digest := Digest([]byte(lease.EventID + ":" + consumer + ":" + ModeShadow)) @@ -231,7 +236,9 @@ func (d *Dispatcher) handle(ctx context.Context, lease Lease) { }() defer close(done) + d.mu.RLock() h := d.Handlers[lease.Consumer] + d.mu.RUnlock() if h == nil { _ = d.fail(ctx, lease, "NO_HANDLER", true) return diff --git a/internal/freedoc/service.go b/internal/freedoc/service.go index b464e837..16f9eb73 100644 --- a/internal/freedoc/service.go +++ b/internal/freedoc/service.go @@ -583,7 +583,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, } results = append(results, sm) } - return results, nil + return results, rows.Err() } // 内存模式 diff --git a/internal/intent/flags.go b/internal/intent/flags.go index 0fc5a677..2286f4db 100644 --- a/internal/intent/flags.go +++ b/internal/intent/flags.go @@ -20,12 +20,11 @@ const ( // FeatureFlags Phase 5 意图识别灰度开关(环境变量覆盖,默认保守) type FeatureFlags struct { - IntentV2Shadow bool - IntentV2Decision bool - V2DecisionTier V2DecisionTier - WorkflowIntentsEnabled bool - MultiIntentEnabled bool - SemanticFallbackEnabled bool + IntentV2Shadow bool + IntentV2Decision bool + V2DecisionTier V2DecisionTier + WorkflowIntentsEnabled bool + MultiIntentEnabled bool } var ( @@ -40,7 +39,6 @@ func defaultFeatureFlags() FeatureFlags { V2DecisionTier: parseV2DecisionTier(os.Getenv("INTENT_V2_DECISION_TIER")), WorkflowIntentsEnabled: envBool("WORKFLOW_INTENTS_ENABLED", true), MultiIntentEnabled: envBool("MULTI_INTENT_ENABLED", true), - SemanticFallbackEnabled: envBool("SEMANTIC_FALLBACK_ENABLED", false), } } @@ -59,7 +57,10 @@ func parseV2DecisionTier(raw string) V2DecisionTier { case "all": return V2TierAll default: - return V2TierQuery + // E-7: fail closed to none, aligned with intentflow.parsePlanTier's + // conservative fallback. An unrecognized value must never silently + // enable V2 query routing. + return V2TierNone } } diff --git a/internal/intent/flags_test.go b/internal/intent/flags_test.go index d5935640..90d4781d 100644 --- a/internal/intent/flags_test.go +++ b/internal/intent/flags_test.go @@ -23,6 +23,12 @@ func TestTierAllowsRisk(t *testing.T) { } } +func TestUnknownV2DecisionTierFailsClosedToNone(t *testing.T) { + if parseV2DecisionTier("definitely-not-a-tier") != V2TierNone { + t.Fatal("unknown INTENT_V2_DECISION_TIER must fail closed to none, not query") + } +} + func TestBuildV2ShadowReport_MismatchDetection(t *testing.T) { SetFeatureFlagsForTest(FeatureFlags{IntentV2Shadow: true}) defer ResetFeatureFlagsForTest() diff --git a/internal/kb/retrieve.go b/internal/kb/retrieve.go index 5f4b1c89..05b23f71 100644 --- a/internal/kb/retrieve.go +++ b/internal/kb/retrieve.go @@ -266,7 +266,7 @@ func (r *Retriever) searchProjectAsset(ctx context.Context, q string, opts Searc Source: "项目数字资产库", }) } - return out, nil + return out, rows.Err() } func regStatusFilter(includeRepealed bool) string { diff --git a/internal/projectinstance/duty_action.go b/internal/projectinstance/duty_action.go index d6311d33..94b3d9b7 100644 --- a/internal/projectinstance/duty_action.go +++ b/internal/projectinstance/duty_action.go @@ -63,7 +63,8 @@ func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool, if !dt.Enabled { return DocAccessDecision{Allowed: false, Code: AccessCodeDocTaskDisabled, Message: "该文档已被创建者禁用"} } - switch classifyDocDuty(dt.Duties) { + mode, effective, _ := ResolveDocTaskDuty(dt) + switch mode { case DutyModeNone: switch action { case DocAccessActionRun, DocAccessActionEdit: @@ -90,7 +91,7 @@ func classifyAccess(dt DocTaskPlan, action string, userID int64, isCreator bool, } case DutyModeFull: role := requiredDutyRole(action) - if userHoldsDuty(dt.Duties, userID, role) { + if userHoldsDuty(effective, userID, role) { return allow } return DocAccessDecision{ diff --git a/internal/projectinstance/duty_action_test.go b/internal/projectinstance/duty_action_test.go index 52642c48..7cffbbba 100644 --- a/internal/projectinstance/duty_action_test.go +++ b/internal/projectinstance/duty_action_test.go @@ -90,3 +90,34 @@ func TestClassifyAccess_NoneAndPreActivation(t *testing.T) { t.Fatalf("pre-activation submit: %+v", preSubmit) } } + +func TestClassifyAccess_AuthorOnlyMatrix(t *testing.T) { + dt := DocTaskPlan{ + Enabled: true, + DutyPolicy: DutyPolicyAuthorOnly, + Duties: DocDuty{AuthorUserID: 7}, + } + cases := []struct { + name string + userID int64 + action string + wantAllow bool + wantCode string + }{ + {"author run", 7, DocAccessActionRun, true, ""}, + {"author edit", 7, DocAccessActionEdit, true, ""}, + {"author submit", 7, DocAccessActionSubmit, true, ""}, + {"author review", 7, DocAccessActionReview, true, ""}, + {"author approve", 7, DocAccessActionApprove, true, ""}, + {"other run denied", 2, DocAccessActionRun, false, AccessCodeForbiddenDuty}, + {"other review denied", 2, DocAccessActionReview, false, AccessCodeForbiddenDuty}, + {"other approve denied", 3, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, + {"creator cannot elevate", 9, DocAccessActionApprove, false, AccessCodeForbiddenDuty}, + } + for _, tc := range cases { + got := classifyAccess(dt, tc.action, tc.userID, tc.userID == 9, 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) + } + } +} diff --git a/internal/projectinstance/duty_mode.go b/internal/projectinstance/duty_mode.go index 09bdbe51..6026f351 100644 --- a/internal/projectinstance/duty_mode.go +++ b/internal/projectinstance/duty_mode.go @@ -18,9 +18,13 @@ const ( issueNoGovernedDocument = "NO_GOVERNED_DOCUMENT" issueDutyAssigneeNotActive = "DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER" issueSeparationOfDutyViolation = "SEPARATION_OF_DUTY_VIOLATION" + issueUnknownDutyPolicy = "UNKNOWN_DUTY_POLICY" + issueAuthorOnlyAuthorRequired = "AUTHOR_ONLY_AUTHOR_REQUIRED" + issueAuthorOnlyDutyConflict = "AUTHOR_ONLY_DUTY_CONFLICT" ) // ClassifyDocDuty 供运行时/门禁复用,禁止平行再写一套三职判定。 +// 仅按三职 ID 完整度分类;治理调用应改用 ResolveDocTaskDuty。 func ClassifyDocDuty(d DocDuty) DutyMode { return classifyDocDuty(d) } @@ -46,12 +50,49 @@ func classifyDocDuty(d DocDuty) DutyMode { } } +// ResolveDocTaskDuty 按逐文档 duty_policy 解析完整度、有效三职与是否兼任。 +// author_only 的规范存储可只含 author;有效三职派生为 A/A/A。 +func ResolveDocTaskDuty(dt DocTaskPlan) (mode DutyMode, effective DocDuty, isAuthorOnly bool) { + if !dt.Enabled { + return DutyModeNone, DocDuty{}, false + } + switch dt.DutyPolicy { + case "", DutyPolicySeparated: + return classifyDocDuty(dt.Duties), dt.Duties, false + case DutyPolicyAuthorOnly: + if dt.Duties.AuthorUserID <= 0 { + return DutyModePartial, DocDuty{}, true + } + a := dt.Duties.AuthorUserID + return DutyModeFull, DocDuty{AuthorUserID: a, ReviewerUserID: a, ApproverUserID: a}, true + default: + return DutyModePartial, DocDuty{}, false + } +} + func docTaskRequiresDuty(td workflowdef.DocTaskDef) bool { return td.Required || td.Blocking || !td.IsCreatorConfigurable() } func normalizeDocTaskDuties(dt DocTaskPlan) DocTaskPlan { - if !dt.Enabled || classifyDocDuty(dt.Duties) == DutyModeNone { + if !dt.Enabled { + dt.Duties = DocDuty{} + dt.DutyPolicy = "" + return dt + } + if dt.DutyPolicy == DutyPolicyAuthorOnly { + a := dt.Duties.AuthorUserID + if a > 0 { + if dt.Duties.ReviewerUserID == a { + dt.Duties.ReviewerUserID = 0 + } + if dt.Duties.ApproverUserID == a { + dt.Duties.ApproverUserID = 0 + } + } + return dt + } + if classifyDocDuty(dt.Duties) == DutyModeNone { dt.Duties = DocDuty{} } return dt @@ -70,8 +111,37 @@ func validateDocTaskDuties(dt DocTaskPlan, td workflowdef.DocTaskDef) []workflow if !dt.Enabled { return nil } - mode := classifyDocDuty(dt.Duties) field := dt.TaskKey + ".duties" + switch dt.DutyPolicy { + case "", DutyPolicySeparated: + // fall through to separated rules below + case DutyPolicyAuthorOnly: + if dt.Duties.AuthorUserID <= 0 { + return []workflowdef.ValidationIssue{{ + Code: issueAuthorOnlyAuthorRequired, + Field: field, + Message: fmt.Sprintf("文档「%s」兼任模式必须指定编制人", dutyTaskLabel(dt)), + }} + } + a := dt.Duties.AuthorUserID + if (dt.Duties.ReviewerUserID > 0 && dt.Duties.ReviewerUserID != a) || + (dt.Duties.ApproverUserID > 0 && dt.Duties.ApproverUserID != a) { + return []workflowdef.ValidationIssue{{ + Code: issueAuthorOnlyDutyConflict, + Field: field, + Message: fmt.Sprintf("文档「%s」兼任模式不能指定与编制人不同的审核人或批准人", dutyTaskLabel(dt)), + }} + } + return nil + default: + return []workflowdef.ValidationIssue{{ + Code: issueUnknownDutyPolicy, + Field: dt.TaskKey + ".duty_policy", + Message: fmt.Sprintf("文档「%s」的职责模式无效", dutyTaskLabel(dt)), + }} + } + + mode := classifyDocDuty(dt.Duties) switch mode { case DutyModePartial: return []workflowdef.ValidationIssue{{ @@ -109,7 +179,11 @@ func countFullDutyDocs(plan WorkflowPlan) int { continue } for _, dt := range st.DocTasks { - if dt.Enabled && classifyDocDuty(dt.Duties) == DutyModeFull { + if !dt.Enabled { + continue + } + mode, _, _ := ResolveDocTaskDuty(dt) + if mode == DutyModeFull { n++ } } @@ -127,7 +201,11 @@ func validateActiveMemberDuties(plan WorkflowPlan, active map[int64]struct{}) [] continue } for _, dt := range st.DocTasks { - if !dt.Enabled || classifyDocDuty(dt.Duties) != DutyModeFull { + if !dt.Enabled { + continue + } + mode, effective, _ := ResolveDocTaskDuty(dt) + if mode != DutyModeFull { continue } for _, item := range []struct { @@ -135,9 +213,9 @@ func validateActiveMemberDuties(plan WorkflowPlan, active map[int64]struct{}) [] role string label string }{ - {dt.Duties.AuthorUserID, "author", "编制"}, - {dt.Duties.ReviewerUserID, "reviewer", "审核"}, - {dt.Duties.ApproverUserID, "approver", "批准"}, + {effective.AuthorUserID, "author", "编制"}, + {effective.ReviewerUserID, "reviewer", "审核"}, + {effective.ApproverUserID, "approver", "批准"}, } { if _, ok := active[item.id]; ok { continue diff --git a/internal/projectinstance/duty_mode_test.go b/internal/projectinstance/duty_mode_test.go index 349472cd..bb000a66 100644 --- a/internal/projectinstance/duty_mode_test.go +++ b/internal/projectinstance/duty_mode_test.go @@ -100,9 +100,91 @@ func TestCountFullDutyDocs(t *testing.T) { {Enabled: true, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}}, {Enabled: true}, {Enabled: false, Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}}, + {Enabled: true, DutyPolicy: DutyPolicyAuthorOnly, Duties: DocDuty{AuthorUserID: 9}}, }, }}} - if n := countFullDutyDocs(plan); n != 1 { - t.Fatalf("countFullDutyDocs=%d want 1", n) + if n := countFullDutyDocs(plan); n != 2 { + t.Fatalf("countFullDutyDocs=%d want 2 (full + author_only)", n) } } + +func TestResolveDocTaskDuty(t *testing.T) { + t.Parallel() + mode, eff, only := ResolveDocTaskDuty(DocTaskPlan{Enabled: false, DutyPolicy: DutyPolicyAuthorOnly, Duties: DocDuty{AuthorUserID: 1}}) + if mode != DutyModeNone || only || eff != (DocDuty{}) { + t.Fatalf("disabled: mode=%s only=%v eff=%+v", mode, only, eff) + } + mode, eff, only = ResolveDocTaskDuty(DocTaskPlan{ + Enabled: true, DutyPolicy: DutyPolicyAuthorOnly, Duties: DocDuty{AuthorUserID: 7}, + }) + if mode != DutyModeFull || !only || eff != (DocDuty{AuthorUserID: 7, ReviewerUserID: 7, ApproverUserID: 7}) { + t.Fatalf("author_only: mode=%s only=%v eff=%+v", mode, only, eff) + } + mode, _, only = ResolveDocTaskDuty(DocTaskPlan{Enabled: true, DutyPolicy: DutyPolicyAuthorOnly}) + if mode != DutyModePartial || !only { + t.Fatalf("author_only missing author: mode=%s only=%v", mode, only) + } + mode, _, only = ResolveDocTaskDuty(DocTaskPlan{Enabled: true, DutyPolicy: "weird"}) + if mode != DutyModePartial || only { + t.Fatalf("unknown policy: mode=%s only=%v", mode, only) + } +} + +func TestNormalizeAuthorOnlyDuties(t *testing.T) { + t.Parallel() + same := normalizeDocTaskDuties(DocTaskPlan{ + Enabled: true, DutyPolicy: DutyPolicyAuthorOnly, + Duties: DocDuty{AuthorUserID: 5, ReviewerUserID: 5, ApproverUserID: 5}, + }) + if same.Duties.ReviewerUserID != 0 || same.Duties.ApproverUserID != 0 || same.Duties.AuthorUserID != 5 { + t.Fatalf("same-as-author must clear derived slots: %+v", same.Duties) + } + conflict := normalizeDocTaskDuties(DocTaskPlan{ + Enabled: true, DutyPolicy: DutyPolicyAuthorOnly, + Duties: DocDuty{AuthorUserID: 5, ReviewerUserID: 8}, + }) + if conflict.Duties.ReviewerUserID != 8 { + t.Fatalf("conflicting reviewer must remain for validation: %+v", conflict.Duties) + } +} + +func TestValidateAuthorOnlyDocTaskDuties(t *testing.T) { + t.Parallel() + optional := workflowdef.DocTaskDef{Key: "research", Title: "调研", CreatorConfigurable: true} + ok := validateDocTaskDuties(DocTaskPlan{ + TaskKey: "research", Enabled: true, DutyPolicy: DutyPolicyAuthorOnly, + Duties: DocDuty{AuthorUserID: 3}, + }, optional) + if len(ok) != 0 { + t.Fatalf("valid author_only: %+v", ok) + } + missing := validateDocTaskDuties(DocTaskPlan{ + TaskKey: "research", Enabled: true, DutyPolicy: DutyPolicyAuthorOnly, + }, optional) + if len(missing) != 1 || missing[0].Code != issueAuthorOnlyAuthorRequired { + t.Fatalf("missing author: %+v", missing) + } + conflict := validateDocTaskDuties(DocTaskPlan{ + TaskKey: "research", Enabled: true, DutyPolicy: DutyPolicyAuthorOnly, + Duties: DocDuty{AuthorUserID: 3, ReviewerUserID: 9}, + }, optional) + if len(conflict) != 1 || conflict[0].Code != issueAuthorOnlyDutyConflict { + t.Fatalf("conflict: %+v", conflict) + } + unknown := validateDocTaskDuties(DocTaskPlan{ + TaskKey: "research", Enabled: true, DutyPolicy: "nope", + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + }, optional) + if len(unknown) != 1 || unknown[0].Code != issueUnknownDutyPolicy { + t.Fatalf("unknown: %+v", unknown) + } + // separated A/A/A still rejected + sep := validateDocTaskDuties(DocTaskPlan{ + TaskKey: "research", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 1, ApproverUserID: 1}, + }, optional) + if len(sep) != 1 || sep[0].Code != issueSeparationOfDutyViolation { + t.Fatalf("separated A/A/A: %+v", sep) + } +} + diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index 13147ace..5f2b69ac 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -25,8 +25,11 @@ var ( ErrStageNotActive = errors.New("stage is not the active stage") ErrInvalidStage = errors.New("invalid stage key for instance") ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch") - ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") - ErrTerminationNotAllowed = errors.New("termination not allowed for instance state") + ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") + ErrTerminationNotAllowed = errors.New("termination not allowed for instance state") + ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled") + ErrAuthorOnlyPolicyUnavailable = errors.New("author-only approval policy store unavailable") + ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview") ) // ValidationError 保留具体校验问题,供 HTTP 层返回可操作的错误信息。 diff --git a/internal/projectinstance/gate.go b/internal/projectinstance/gate.go index a9de4029..fdf3c91a 100644 --- a/internal/projectinstance/gate.go +++ b/internal/projectinstance/gate.go @@ -25,7 +25,8 @@ func evaluateStageReadiness( continue } td, hasDef := taskDefMap[dt.TaskKey] - approvalManaged := classifyDocDuty(dt.Duties) != DutyModeNone + mode, _, _ := ResolveDocTaskDuty(dt) + approvalManaged := mode != DutyModeNone required := approvalManaged && (!hasDef || td.BlocksWhenEnabled()) state := docStates[dt.TaskKey] if state == "" { diff --git a/internal/projectinstance/gate_test.go b/internal/projectinstance/gate_test.go index be9406e4..6d519253 100644 --- a/internal/projectinstance/gate_test.go +++ b/internal/projectinstance/gate_test.go @@ -56,3 +56,36 @@ func TestEvaluateStageReadiness_DutyModes(t *testing.T) { t.Fatalf("approved full + unmanaged none should be ready, issues=%+v", issues) } } + +func TestEvaluateStageReadiness_AuthorOnlyIsManaged(t *testing.T) { + t.Parallel() + stageDef := &workflowdef.StageDef{ + Key: "V0.1_triage", + DocTasks: []workflowdef.DocTaskDef{ + {Key: "intake", BlockingWhenEnabled: true, CreatorConfigurable: true}, + }, + } + plan := StagePlan{ + StageKey: "V0.1_triage", + Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "intake", Enabled: true, + DutyPolicy: DutyPolicyAuthorOnly, + Duties: DocDuty{AuthorUserID: 7}, + }}, + } + ready, issues, items := evaluateStageReadiness(plan, stageDef, map[string]string{}) + if ready { + t.Fatal("author_only blocking doc without approval must keep gate closed") + } + if len(issues) != 1 || issues[0].TaskKey != "intake" { + t.Fatalf("issues=%+v", issues) + } + if len(items) != 1 || !items[0].ApprovalManaged || !items[0].Required { + t.Fatalf("author_only must be approval-managed: %+v", items) + } + ready, _, _ = evaluateStageReadiness(plan, stageDef, map[string]string{"intake": "approved"}) + if !ready { + t.Fatal("approved author_only doc should open the gate") + } +} diff --git a/internal/projectinstance/hash_test.go b/internal/projectinstance/hash_test.go new file mode 100644 index 00000000..2222b5f9 --- /dev/null +++ b/internal/projectinstance/hash_test.go @@ -0,0 +1,45 @@ +package projectinstance + +import ( + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestComputePlanHash_LegacySnapshotStableWithoutDutyPolicy(t *testing.T) { + t.Parallel() + plan := WorkflowPlan{ + WorkflowDefinitionKey: workflowdef.OfficialKeyMain, + WorkflowVersion: 1, + Stages: []StagePlan{{ + StageKey: "s1", Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "intake", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + }}, + }}, + } + h1, err := computePlanHash(plan) + if err != nil { + t.Fatal(err) + } + // empty DutyPolicy must not appear in JSON (omitempty) — hash must match explicit separated unset + plan2 := plan + plan2.Stages[0].DocTasks[0].DutyPolicy = "" + h2, err := computePlanHash(plan2) + if err != nil { + t.Fatal(err) + } + if h1 != h2 { + t.Fatalf("empty duty_policy must be hash-stable: %s vs %s", h1, h2) + } + plan3 := plan + plan3.Stages[0].DocTasks[0].DutyPolicy = DutyPolicyAuthorOnly + h3, err := computePlanHash(plan3) + if err != nil { + t.Fatal(err) + } + if h3 == h1 { + t.Fatal("author_only duty_policy must change plan hash") + } +} diff --git a/internal/projectinstance/policy.go b/internal/projectinstance/policy.go new file mode 100644 index 00000000..b8d5ee54 --- /dev/null +++ b/internal/projectinstance/policy.go @@ -0,0 +1,95 @@ +package projectinstance + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// AuthorOnlyPolicySnapshot 系统级「允许编制人兼任审核批准」总授权快照。 +// 字段语义对齐 sysadmin.AuthorOnlyApprovalPolicy;本包不导入 sysadmin +// (module-boundaries.yaml 未授权),由组合根提供适配实现。 +type AuthorOnlyPolicySnapshot struct { + Allowed bool + Revision int64 +} + +// AuthorOnlyPolicySource 组合根注入的窄只读/事务锁接口。 +type AuthorOnlyPolicySource interface { + GetAuthorOnlyApprovalPolicy(ctx context.Context) (AuthorOnlyPolicySnapshot, error) + LockAuthorOnlyApprovalPolicy(ctx context.Context, tx pgx.Tx) (AuthorOnlyPolicySnapshot, error) +} + +func (s *Service) SetAuthorOnlyPolicySource(src AuthorOnlyPolicySource) { + if s == nil { + return + } + s.authorOnlyPolicy = src +} + +func planUsesAuthorOnly(plan WorkflowPlan) bool { + for _, st := range plan.Stages { + if !st.Enabled { + continue + } + for _, dt := range st.DocTasks { + if dt.Enabled && dt.DutyPolicy == DutyPolicyAuthorOnly { + return true + } + } + } + return false +} + +func (s *Service) requireAuthorOnlyAllowed(ctx context.Context) (AuthorOnlyPolicySnapshot, error) { + if s == nil || s.authorOnlyPolicy == nil { + return AuthorOnlyPolicySnapshot{}, ErrAuthorOnlyPolicyUnavailable + } + snap, err := s.authorOnlyPolicy.GetAuthorOnlyApprovalPolicy(ctx) + if err != nil { + return AuthorOnlyPolicySnapshot{}, fmt.Errorf("%w: %v", ErrAuthorOnlyPolicyUnavailable, err) + } + if !snap.Allowed { + return snap, ErrAuthorOnlyPolicyDisabled + } + return snap, nil +} + +func (s *Service) loadAuthorOnlyCapability(ctx context.Context) GovernancePolicyCapability { + cap := GovernancePolicyCapability{Allowed: false, Revision: 0} + if s == nil || s.authorOnlyPolicy == nil { + cap.BlockedReason = "unavailable" + return cap + } + snap, err := s.authorOnlyPolicy.GetAuthorOnlyApprovalPolicy(ctx) + if err != nil { + cap.BlockedReason = "unavailable" + return cap + } + cap.Allowed = snap.Allowed + cap.Revision = snap.Revision + if !snap.Allowed { + cap.BlockedReason = "policy_disabled" + } + return cap +} + +type activateAuthorOnlyLockKey struct{} + +func withActivateAuthorOnlyLock(ctx context.Context, fn func(context.Context, pgx.Tx) (AuthorOnlyPolicySnapshot, error)) context.Context { + if fn == nil { + return ctx + } + return context.WithValue(ctx, activateAuthorOnlyLockKey{}, fn) +} + +// runActivateAuthorOnlyLock 执行激活事务内策略行锁;第三个返回值表示是否注册过闭包。 +func runActivateAuthorOnlyLock(ctx context.Context, tx pgx.Tx) (AuthorOnlyPolicySnapshot, error, bool) { + fn, _ := ctx.Value(activateAuthorOnlyLockKey{}).(func(context.Context, pgx.Tx) (AuthorOnlyPolicySnapshot, error)) + if fn == nil { + return AuthorOnlyPolicySnapshot{}, nil, false + } + snap, err := fn(ctx, tx) + return snap, err, true +} diff --git a/internal/projectinstance/policy_test.go b/internal/projectinstance/policy_test.go new file mode 100644 index 00000000..d77d27cc --- /dev/null +++ b/internal/projectinstance/policy_test.go @@ -0,0 +1,196 @@ +package projectinstance + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/jackc/pgx/v5" +) + +type fakeAuthorOnlyPolicy struct { + mu sync.Mutex + allowed bool + revision int64 + failGet bool +} + +func (f *fakeAuthorOnlyPolicy) GetAuthorOnlyApprovalPolicy(_ context.Context) (AuthorOnlyPolicySnapshot, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.failGet { + return AuthorOnlyPolicySnapshot{}, errors.New("boom") + } + return AuthorOnlyPolicySnapshot{Allowed: f.allowed, Revision: f.revision}, nil +} + +func (f *fakeAuthorOnlyPolicy) LockAuthorOnlyApprovalPolicy(ctx context.Context, _ pgx.Tx) (AuthorOnlyPolicySnapshot, error) { + return f.GetAuthorOnlyApprovalPolicy(ctx) +} + +func (f *fakeAuthorOnlyPolicy) set(allowed bool, revision int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.allowed = allowed + f.revision = revision +} + +func authorOnlyPlan(authorID int64) WorkflowPlan { + plan := validPlan() + plan.Stages[0].DocTasks[0].DutyPolicy = DutyPolicyAuthorOnly + plan.Stages[0].DocTasks[0].Duties = DocDuty{AuthorUserID: authorID} + return plan +} + +func TestService_AuthorOnlySavePreviewActivate(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 90, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + pol := &fakeAuthorOnlyPolicy{allowed: true, revision: 3} + svc.SetAuthorOnlyPolicySource(pol) + svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}}}) + + settings, err := svc.SaveWorkflowPlan(ctx, 90, 1, true, SavePlanInput{ + Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + if settings.Plan == nil || settings.Plan.Stages[0].DocTasks[0].DutyPolicy != DutyPolicyAuthorOnly { + t.Fatalf("expected author_only plan, got %+v", settings.Plan) + } + if !settings.AuthorOnlyPolicy.Allowed || settings.AuthorOnlyPolicy.Revision != 3 { + t.Fatalf("settings capability: %+v", settings.AuthorOnlyPolicy) + } + + preview, err := svc.ActivationPreview(ctx, 90, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + if preview.Summary.AuthorOnlyDocTasks != 1 || preview.Summary.GovernedDocTasks != 1 { + t.Fatalf("preview summary: %+v", preview.Summary) + } + if len(preview.Summary.AuthorOnlyDocuments) != 1 || preview.Summary.AuthorOnlyDocuments[0].AuthorUserID != 1 { + t.Fatalf("author_only docs: %+v", preview.Summary.AuthorOnlyDocuments) + } + + result, err := svc.Activate(ctx, 90, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("Activate: %v", err) + } + if result.LifecycleStatus != LifecycleActive { + t.Fatalf("result: %+v", result) + } +} + +func TestService_AuthorOnlyRejectedWhenPolicyDisabled(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 91, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + svc.SetAuthorOnlyPolicySource(&fakeAuthorOnlyPolicy{allowed: false, revision: 0}) + svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}}}) + + _, err := svc.SaveWorkflowPlan(ctx, 91, 1, true, SavePlanInput{ + Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if !errors.Is(err, ErrAuthorOnlyPolicyDisabled) { + t.Fatalf("expected ErrAuthorOnlyPolicyDisabled, got %v", err) + } +} + +func TestService_AuthorOnlyTokenInvalidatedWhenPolicyChanges(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 92, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + pol := &fakeAuthorOnlyPolicy{allowed: true, revision: 1} + svc.SetAuthorOnlyPolicySource(pol) + svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}}}) + + if _, err := svc.SaveWorkflowPlan(ctx, 92, 1, true, SavePlanInput{ + Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 92, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + pol.set(false, 2) + _, err = svc.Activate(ctx, 92, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if !errors.Is(err, ErrAuthorOnlyPolicyDisabled) && !errors.Is(err, ErrAuthorOnlyPolicyChanged) { + t.Fatalf("expected policy disabled/changed, got %v", err) + } +} + +func TestService_AuthorOnlyTokenRevisionMismatch(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 93, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + pol := &fakeAuthorOnlyPolicy{allowed: true, revision: 5} + svc.SetAuthorOnlyPolicySource(pol) + svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}}}) + + if _, err := svc.SaveWorkflowPlan(ctx, 93, 1, true, SavePlanInput{ + Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 93, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + // 总开关再次开启但 revision 前进(false→true 也会 bump),旧 token 必须作废 + pol.set(true, 6) + _, err = svc.Activate(ctx, 93, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if !errors.Is(err, ErrAuthorOnlyPolicyChanged) { + t.Fatalf("expected ErrAuthorOnlyPolicyChanged, got %v", err) + } +} + +func TestService_AuthorOnlyForbiddenForNonCreator(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 94, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + svc.SetAuthorOnlyPolicySource(&fakeAuthorOnlyPolicy{allowed: true, revision: 1}) + _, err := svc.SaveWorkflowPlan(ctx, 94, 1, false, SavePlanInput{ + Plan: authorOnlyPlan(1), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if !errors.Is(err, ErrForbiddenNotCreator) { + t.Fatalf("expected ErrForbiddenNotCreator, got %v", err) + } +} + +func TestPreviewSummary_AuthorOnlySplit(t *testing.T) { + version := optionalDutyResolver().versions[validPlan().WorkflowDefinitionKey] + plan := optionalDutyPlan( + DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + DocDuty{}, + ) + plan.Stages[0].DocTasks[0].DutyPolicy = DutyPolicyAuthorOnly + plan.Stages[0].DocTasks[0].Duties = DocDuty{AuthorUserID: 9} + sum := previewSummary(plan, version) + if sum.AuthorOnlyDocTasks != 1 || sum.GovernedDocTasks != 1 || sum.UnmanagedDocTasks != 1 { + t.Fatalf("summary: %+v", sum) + } + if len(sum.AuthorOnlyDocuments) != 1 || sum.AuthorOnlyDocuments[0].AuthorUserID != 9 { + t.Fatalf("author_only docs: %+v", sum.AuthorOnlyDocuments) + } +} diff --git a/internal/projectinstance/rdev_pg_test.go b/internal/projectinstance/rdev_pg_test.go new file mode 100644 index 00000000..29639701 --- /dev/null +++ b/internal/projectinstance/rdev_pg_test.go @@ -0,0 +1,123 @@ +package projectinstance_test + +import ( + "context" + "testing" + "time" + + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/workflowdef" +) + +// TestPG_RDevActivateWithNAClose 激活 rdev(60 任务)并将 rd_sw_plan 规划关闭。 +func TestPG_RDevActivateWithNAClose(t *testing.T) { + pool := openPGPool(t) + defer pool.Close() + ctx := context.Background() + + wfStore := workflowdef.NewStore(pool) + def, err := workflowdef.LoadManifestDefinition("rdev.v2.json") + if err != nil { + t.Fatalf("load rdev manifest: %v", err) + } + if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{ + Key: workflowdef.OfficialKeyRDev, + Name: "研发设计控制流程", + Definition: def, + }, 1); err != nil { + t.Fatalf("seed rdev: %v", err) + } + + pwStore := projectworkflow.NewStore(pool) + instStore := projectinstance.NewStore(pool) + svc := projectinstance.NewService(instStore, wfStore, pwStore, nil) + + var projectID int64 + code := "pg-rdev-" + time.Now().Format("150405.000") + err = pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) + VALUES ($1, 'PG RDev Activation', 'active', 1, 'planning', '', 1, 0) RETURNING id + `, code).Scan(&projectID) + if err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + }) + + plan, err := svc.BuildDefaultPlan(ctx, workflowdef.OfficialKeyRDev, 0) + if err != nil { + t.Fatalf("default plan: %v", err) + } + if len(plan.Stages) != 13 { + t.Fatalf("want 13 stages, got %d", len(plan.Stages)) + } + taskCount := 0 + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + taskCount++ + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3, + } + if plan.Stages[i].DocTasks[j].TaskKey == "rd_sw_plan" { + plan.Stages[i].DocTasks[j].Enabled = false + } + } + } + if taskCount != 60 { + t.Fatalf("want 60 tasks, got %d", taskCount) + } + + if _, err := svc.SaveWorkflowPlan(ctx, projectID, 1, true, projectinstance.SavePlanInput{ + Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save plan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, projectID, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + activated, err := svc.Activate(ctx, projectID, 1, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("activate: %v", err) + } + if activated.Instance.Status != projectinstance.InstanceActive { + t.Fatalf("expected active, got %q", activated.Instance.Status) + } + if activated.Instance.ConfigSnapshot.WorkflowDefinitionKey != workflowdef.OfficialKeyRDev { + t.Fatalf("key=%s", activated.Instance.ConfigSnapshot.WorkflowDefinitionKey) + } + + var executorKey string + var enabled bool + err = pool.QueryRow(ctx, ` + SELECT dti.executor_key, dti.enabled + FROM project_doc_task_instances dti + JOIN project_stage_instances si ON si.id = dti.stage_instance_id + WHERE si.workflow_instance_id = $1 AND dti.task_key = 'rd_proposal' + `, activated.Instance.ID).Scan(&executorKey, &enabled) + if err != nil { + t.Fatalf("query rd_proposal: %v", err) + } + if executorKey != "squad.rdev_doc.v1" || !enabled { + t.Fatalf("rd_proposal executor=%q enabled=%v", executorKey, enabled) + } + + err = pool.QueryRow(ctx, ` + SELECT dti.enabled + FROM project_doc_task_instances dti + JOIN project_stage_instances si ON si.id = dti.stage_instance_id + WHERE si.workflow_instance_id = $1 AND dti.task_key = 'rd_sw_plan' + `, activated.Instance.ID).Scan(&enabled) + if err != nil { + t.Fatalf("query rd_sw_plan: %v", err) + } + if enabled { + t.Fatal("rd_sw_plan should be disabled (N/A planning close)") + } +} diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 779f8179..168bd3bf 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -20,11 +20,12 @@ type VersionResolver interface { // Service 项目工作流实例与激活领域服务。 type Service struct { - store Store - wfStore VersionResolver - projects projectworkflow.Store - docs DocumentReadinessChecker - members ActiveMemberLookup + store Store + wfStore VersionResolver + projects projectworkflow.Store + docs DocumentReadinessChecker + members ActiveMemberLookup + authorOnlyPolicy AuthorOnlyPolicySource } func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service { @@ -117,6 +118,7 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64 } } } + settings.AuthorOnlyPolicy = s.loadAuthorOnlyCapability(ctx) _ = userID return settings, nil } @@ -151,6 +153,11 @@ func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID return nil, err } in.Plan = normalizeWorkflowPlanDuties(in.Plan) + if planUsesAuthorOnly(in.Plan) { + if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil { + return nil, err + } + } validation := validatePlan(in.Plan, version) if !validation.Valid { return nil, newValidationError(validation) @@ -215,12 +222,22 @@ func (s *Service) ActivationPreview(ctx context.Context, projectID int64, userID return nil, err } + var policyRevision *int64 + if planUsesAuthorOnly(planInst.ConfigSnapshot) { + snap, err := s.requireAuthorOnlyAllowed(ctx) + if err != nil { + return nil, err + } + rev := snap.Revision + policyRevision = &rev + } + token, err := randomToken(24) if err != nil { return nil, err } expiresAt := time.Now().Add(15 * time.Minute) - if err := s.store.SaveActivationToken(ctx, token, projectID, planInst.PlanHash, selVer, planInst.ConfigSnapshot, userID, expiresAt); err != nil { + if err := s.store.SaveActivationToken(ctx, token, projectID, planInst.PlanHash, selVer, planInst.ConfigSnapshot, userID, expiresAt, policyRevision); err != nil { return nil, err } @@ -276,6 +293,12 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i ctx = withActivateMemberRecheck(ctx, func(ctx context.Context) error { return s.checkActiveMembers(ctx, projectID, planInst.ConfigSnapshot) }) + if planUsesAuthorOnly(planInst.ConfigSnapshot) { + if s.authorOnlyPolicy == nil { + return nil, ErrAuthorOnlyPolicyUnavailable + } + ctx = withActivateAuthorOnlyLock(ctx, s.authorOnlyPolicy.LockAuthorOnlyApprovalPolicy) + } activated, err := s.store.ActivateInstanceWithToken(ctx, in.ConfirmationToken, projectID, planInst, version, selVer, userID, planInst.PlanHash) if err != nil { return nil, err diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index cea85491..47ca72e3 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -83,7 +83,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI if !dt.Enabled { continue } - if docTaskDutyForUser(dt, userID) || (isCreator && classifyDocDuty(dt.Duties) == DutyModeNone) { + if docTaskDutyForUser(dt, userID) || (isCreator && docTaskDutyMode(dt) == DutyModeNone) { out.AllowedTaskKeys[dt.TaskKey] = true } } @@ -100,18 +100,24 @@ func stageDutyForUser(st StagePlan, userID int64) bool { return false } +func docTaskDutyMode(dt DocTaskPlan) DutyMode { + mode, _, _ := ResolveDocTaskDuty(dt) + return mode +} + func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool { - if classifyDocDuty(dt.Duties) != DutyModeFull { + mode, effective, _ := ResolveDocTaskDuty(dt) + if mode != DutyModeFull { return false } - return dt.Duties.AuthorUserID == userID || - dt.Duties.ReviewerUserID == userID || - dt.Duties.ApproverUserID == userID + return effective.AuthorUserID == userID || + effective.ReviewerUserID == userID || + effective.ApproverUserID == userID } func stageHasUnmanagedDoc(st StagePlan) bool { for _, dt := range st.DocTasks { - if dt.Enabled && classifyDocDuty(dt.Duties) == DutyModeNone { + if dt.Enabled && docTaskDutyMode(dt) == DutyModeNone { return true } } diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index b58a35dc..d246415c 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -265,6 +265,56 @@ func mustActivateTestPlan(t *testing.T, svc *Service, projectID, userID int64) { } } +func TestResolveStageAccess_AuthorOnlyAuthorCanAct(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 510, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + svc.SetAuthorOnlyPolicySource(&fakeAuthorOnlyPolicy{allowed: true, revision: 1}) + svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{7: {}}}) + + if _, err := svc.SaveWorkflowPlan(ctx, 510, 1, true, SavePlanInput{ + Plan: authorOnlyPlan(7), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 510, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + if _, err := svc.Activate(ctx, 510, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }); err != nil { + t.Fatalf("activate: %v", err) + } + + author, err := svc.ResolveStageAccess(ctx, 510, 7, false) + if err != nil { + t.Fatal(err) + } + if !author.TaskKeyAllowed("intake") { + t.Fatalf("author_only author must act on intake, got %+v", author) + } + if !author.StageKeyAllowed("V0.1_triage") { + t.Fatal("author_only author must access active stage") + } + + outsider, _ := svc.ResolveStageAccess(ctx, 510, 99, false) + if outsider.TaskKeyAllowed("intake") { + t.Fatal("non-author must not act on author_only intake") + } + d := svc.CheckDocTaskAccess(ctx, 510, 7, "intake", DocAccessActionApprove, false) + if !d.Allowed { + t.Fatalf("author_only author must approve: %+v", d) + } + d = svc.CheckDocTaskAccess(ctx, 510, 99, "intake", DocAccessActionApprove, false) + if d.Allowed || d.Code != AccessCodeForbiddenDuty { + t.Fatalf("outsider approve denied: %+v", d) + } +} + // errGoverningStore 仅覆盖治理查询失败路径。 type errGoverningStore struct { Store diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index d5eb30c1..e3555219 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -49,7 +49,7 @@ type Store interface { CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) - SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error + SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time, policyRevision *int64) error ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error) @@ -214,13 +214,24 @@ func (s *pgStore) UpsertPlanningInstance(ctx context.Context, inst *WorkflowInst SELECT id FROM project_workflow_instances WHERE project_id = $1 AND status = 'planning' LIMIT 1 `, inst.ProjectID).Scan(&existingID) if errors.Is(err, pgx.ErrNoRows) { + // Allocate next instance_no so planning drafts after a completed main/rdev + // instance do not collide on (project_id, instance_no). + var nextNo int + if err := tx.QueryRow(ctx, ` + SELECT COALESCE(MAX(instance_no), 0) + 1 + FROM project_workflow_instances + WHERE project_id = $1 + `, inst.ProjectID).Scan(&nextNo); err != nil { + return nil, err + } + inst.InstanceNo = nextNo err = tx.QueryRow(ctx, ` INSERT INTO project_workflow_instances ( project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, created_by, created_at, updated_at - ) VALUES ($1, $2, 1, 'planning', $3, $4, $5, $6, $7, $8) + ) VALUES ($1, $2, $3, 'planning', $4, $5, $6, $7, $8, $9) RETURNING id - `, inst.ProjectID, inst.WorkflowVersionID, inst.ConfigRevision, inst.PlanHash, snapRaw, + `, inst.ProjectID, inst.WorkflowVersionID, nextNo, inst.ConfigRevision, inst.PlanHash, snapRaw, inst.CreatedBy, inst.CreatedAt, inst.UpdatedAt).Scan(&inst.ID) if err != nil { return nil, err @@ -296,11 +307,15 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst * if err != nil { return err } - if !dt.Enabled || classifyDocDuty(dt.Duties) == DutyModeNone { + if !dt.Enabled { + continue + } + mode, effective, _ := ResolveDocTaskDuty(dt) + if mode == DutyModeNone { continue } for role, uid := range map[string]int64{ - DutyAuthor: dt.Duties.AuthorUserID, DutyReviewer: dt.Duties.ReviewerUserID, DutyApprover: dt.Duties.ApproverUserID, + DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID, } { if uid <= 0 { continue @@ -337,13 +352,35 @@ func (s *pgStore) activateInstanceTx(ctx context.Context, db pgxDB, token string } defer tx.Rollback(ctx) + usesAuthorOnly := planUsesAuthorOnly(inst.ConfigSnapshot) + var currentPolicyRev int64 + if usesAuthorOnly { + snap, lockErr, ok := runActivateAuthorOnlyLock(ctx, tx) + if !ok { + return nil, ErrAuthorOnlyPolicyUnavailable + } + if lockErr != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthorOnlyPolicyUnavailable, lockErr) + } + if !snap.Allowed { + return nil, ErrAuthorOnlyPolicyDisabled + } + currentPolicyRev = snap.Revision + } + if err := runActivateMemberRecheck(ctx); err != nil { return nil, err } if token != "" { - if err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash); err != nil { + tokenPolicyRev, err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash) + if err != nil { return nil, err } + if usesAuthorOnly { + if tokenPolicyRev == nil || *tokenPolicyRev != currentPolicyRev { + return nil, ErrAuthorOnlyPolicyChanged + } + } } tag, err := tx.Exec(ctx, ` @@ -407,34 +444,35 @@ type pgxDB interface { Begin(ctx context.Context) (pgx.Tx, error) } -func consumeActivationTokenTx(ctx context.Context, tx pgx.Tx, token string, projectID int64, planHash string) error { +func consumeActivationTokenTx(ctx context.Context, tx pgx.Tx, token string, projectID int64, planHash string) (*int64, error) { tokenHash := hashActivationToken(token) var storedHash string var usedAt *time.Time var expiresAt time.Time + var policyRev *int64 err := tx.QueryRow(ctx, ` - SELECT plan_hash, used_at, expires_at + SELECT plan_hash, used_at, expires_at, author_only_policy_revision FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2 - `, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt) + `, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt, &policyRev) if errors.Is(err, pgx.ErrNoRows) { - return ErrTokenInvalid + return nil, ErrTokenInvalid } if err != nil { - return err + return nil, err } if usedAt != nil || time.Now().After(expiresAt) || storedHash != planHash { - return ErrTokenInvalid + return nil, ErrTokenInvalid } tag, err := tx.Exec(ctx, ` UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL `, tokenHash) if err != nil { - return err + return nil, err } if tag.RowsAffected() == 0 { - return ErrTokenInvalid + return nil, ErrTokenInvalid } - return nil + return policyRev, nil } func (s *pgStore) CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) { @@ -521,13 +559,13 @@ func hashActivationToken(token string) string { return hex.EncodeToString(sum[:]) } -func (s *pgStore) SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error { +func (s *pgStore) SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time, policyRevision *int64) error { raw, _ := json.Marshal(snapshot) tokenHash := hashActivationToken(token) _, err := s.pool.Exec(ctx, ` - INSERT INTO workflow_activation_tokens (token, project_id, plan_hash, expected_project_version, config_snapshot, created_by, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, tokenHash, projectID, planHash, expectedVersion, raw, createdBy, expiresAt) + INSERT INTO workflow_activation_tokens (token, project_id, plan_hash, expected_project_version, config_snapshot, created_by, expires_at, author_only_policy_revision) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, tokenHash, projectID, planHash, expectedVersion, raw, createdBy, expiresAt, policyRevision) return err } @@ -651,6 +689,7 @@ type activationTokenRec struct { snapshot WorkflowPlan expiresAt time.Time used bool + policyRevision *int64 } func newMemStore() *memStore { @@ -821,12 +860,33 @@ func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInsta } func (m *memStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) { + usesAuthorOnly := planUsesAuthorOnly(inst.ConfigSnapshot) + var currentPolicyRev int64 + if usesAuthorOnly { + snap, lockErr, ok := runActivateAuthorOnlyLock(ctx, nil) + if !ok { + return nil, ErrAuthorOnlyPolicyUnavailable + } + if lockErr != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthorOnlyPolicyUnavailable, lockErr) + } + if !snap.Allowed { + return nil, ErrAuthorOnlyPolicyDisabled + } + currentPolicyRev = snap.Revision + } if err := runActivateMemberRecheck(ctx); err != nil { return nil, err } - if _, err := m.ConsumeActivationToken(ctx, token, projectID, planHash); err != nil { + tokenPolicyRev, err := m.consumeActivationTokenLocked(ctx, token, projectID, planHash) + if err != nil { return nil, err } + if usesAuthorOnly { + if tokenPolicyRev == nil || *tokenPolicyRev != currentPolicyRev { + return nil, ErrAuthorOnlyPolicyChanged + } + } return m.ActivateInstance(ctx, projectID, inst, version, expectedVersion, activatedBy) } @@ -973,28 +1033,43 @@ func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projec return ©Inst, nil } -func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time) error { +func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time, policyRevision *int64) error { m.mu.Lock() defer m.mu.Unlock() + var revCopy *int64 + if policyRevision != nil { + v := *policyRevision + revCopy = &v + } m.tokens[hashActivationToken(token)] = activationTokenRec{ projectID: projectID, planHash: planHash, expectedVer: expectedVersion, - snapshot: snapshot, expiresAt: expiresAt, + snapshot: snapshot, expiresAt: expiresAt, policyRevision: revCopy, } return nil } -func (m *memStore) ConsumeActivationToken(_ context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { +func (m *memStore) ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { + _, plan, err := m.consumeActivationTokenWithRev(ctx, token, projectID, planHash) + return plan, err +} + +func (m *memStore) consumeActivationTokenLocked(ctx context.Context, token string, projectID int64, planHash string) (*int64, error) { + rev, _, err := m.consumeActivationTokenWithRev(ctx, token, projectID, planHash) + return rev, err +} + +func (m *memStore) consumeActivationTokenWithRev(_ context.Context, token string, projectID int64, planHash string) (*int64, *WorkflowPlan, error) { m.mu.Lock() defer m.mu.Unlock() tokenHash := hashActivationToken(token) rec, ok := m.tokens[tokenHash] if !ok || rec.used || rec.projectID != projectID || rec.planHash != planHash || time.Now().After(rec.expiresAt) { - return nil, ErrTokenInvalid + return nil, nil, ErrTokenInvalid } rec.used = true m.tokens[tokenHash] = rec plan := rec.snapshot - return &plan, nil + return rec.policyRevision, &plan, nil } // SeedProjectMeta 供测试注入项目元数据。 diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index cd649c2a..ae84dab8 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -16,6 +16,11 @@ const ( DutyAuthor = "author" DutyReviewer = "reviewer" DutyApprover = "approver" + + // DutyPolicySeparated 标准三职分离(缺省/空值等价)。 + DutyPolicySeparated = "separated" + // DutyPolicyAuthorOnly 编制人兼任审核与批准。 + DutyPolicyAuthorOnly = "author_only" ) // DocDuty 文档三权职责指派。 @@ -27,11 +32,13 @@ type DocDuty struct { // DocTaskPlan 文档任务裁剪与职责。 type DocTaskPlan struct { - TaskKey string `json:"task_key"` - TaskTitle string `json:"task_title,omitempty"` - Enabled bool `json:"enabled"` - ApprovalConfigurable bool `json:"approval_configurable,omitempty"` - Duties DocDuty `json:"duties"` + TaskKey string `json:"task_key"` + TaskTitle string `json:"task_title,omitempty"` + Enabled bool `json:"enabled"` + ApprovalConfigurable bool `json:"approval_configurable,omitempty"` + // DutyPolicy 逐文档职责模式;空或缺省按 separated 解读(omitempty 保证旧快照 hash 不变)。 + DutyPolicy string `json:"duty_policy,omitempty"` + Duties DocDuty `json:"duties"` } // StagePlan 阶段裁剪与文档任务。 @@ -67,21 +74,29 @@ type WorkflowInstance struct { UpdatedAt time.Time `json:"updated_at"` } +// GovernancePolicyCapability 系统级编制人兼任总授权对项目设置的精简投影。 +type GovernancePolicyCapability struct { + Allowed bool `json:"allowed"` + Revision int64 `json:"revision"` + BlockedReason string `json:"blocked_reason,omitempty"` +} + // ProjectSettings 项目设置聚合视图。 type ProjectSettings struct { - ProjectID int64 `json:"project_id"` - LifecycleStatus string `json:"lifecycle_status"` - ActiveWorkflowKey string `json:"active_workflow_key"` - WorkflowSelectionVersion int `json:"workflow_selection_version"` - WorkflowPlanRevision int `json:"workflow_plan_revision"` - Plan *WorkflowPlan `json:"plan,omitempty"` - PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"` - LiveInstance *WorkflowInstance `json:"live_instance,omitempty"` - CanEditPlan bool `json:"can_edit_plan"` - CanActivate bool `json:"can_activate"` - ActiveStageKey string `json:"active_stage_key,omitempty"` - StageAdvanceReady bool `json:"stage_advance_ready,omitempty"` - PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"` + ProjectID int64 `json:"project_id"` + LifecycleStatus string `json:"lifecycle_status"` + ActiveWorkflowKey string `json:"active_workflow_key"` + WorkflowSelectionVersion int `json:"workflow_selection_version"` + WorkflowPlanRevision int `json:"workflow_plan_revision"` + Plan *WorkflowPlan `json:"plan,omitempty"` + PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"` + LiveInstance *WorkflowInstance `json:"live_instance,omitempty"` + CanEditPlan bool `json:"can_edit_plan"` + CanActivate bool `json:"can_activate"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + StageAdvanceReady bool `json:"stage_advance_ready,omitempty"` + PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"` + AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"` } // PublishedOption 可选已发布工作流。 @@ -113,8 +128,9 @@ type ActivationPreview struct { // PreviewDocRef 预览中列出的例外文档。 type PreviewDocRef struct { - TaskKey string `json:"task_key"` - Title string `json:"title"` + TaskKey string `json:"task_key"` + Title string `json:"title"` + AuthorUserID int64 `json:"author_user_id,omitempty"` } // PreviewSummary 激活影响摘要。 @@ -126,7 +142,9 @@ type PreviewSummary struct { GovernedDocTasks int `json:"governed_doc_tasks"` UnmanagedDocTasks int `json:"unmanaged_doc_tasks"` AssignedDuties int `json:"assigned_duties"` + AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"` UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"` + AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"` FirstStageKey string `json:"first_stage_key,omitempty"` FirstStageName string `json:"first_stage_name,omitempty"` } diff --git a/internal/projectinstance/validator.go b/internal/projectinstance/validator.go index fb33db0e..3d87c6a2 100644 --- a/internal/projectinstance/validator.go +++ b/internal/projectinstance/validator.go @@ -24,7 +24,7 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V if !workflowdef.IsOfficialWorkflowKey(plan.WorkflowDefinitionKey) { issues = append(issues, workflowdef.ValidationIssue{ Code: "WORKFLOW_NOT_SELECTABLE", Field: "workflow_definition_key", - Message: "仅允许选择正式工作流(医疗三或软件自举/交付)", + Message: "仅允许选择正式工作流(医疗四或软件自举/交付)", }) } if plan.WorkflowDefinitionKey != version.WorkflowKey { @@ -239,17 +239,27 @@ func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSumm continue } sum.EnabledDocTasks++ - switch classifyDocDuty(dt.Duties) { + mode, effective, isAuthorOnly := ResolveDocTaskDuty(dt) + switch mode { case DutyModeFull: sum.GovernedDocTasks++ - if dt.Duties.AuthorUserID > 0 { - sum.AssignedDuties++ - } - if dt.Duties.ReviewerUserID > 0 { - sum.AssignedDuties++ - } - if dt.Duties.ApproverUserID > 0 { - sum.AssignedDuties++ + if isAuthorOnly { + sum.AuthorOnlyDocTasks++ + sum.AuthorOnlyDocuments = append(sum.AuthorOnlyDocuments, PreviewDocRef{ + TaskKey: dt.TaskKey, + Title: dutyTaskLabel(dt), + AuthorUserID: effective.AuthorUserID, + }) + } else { + if effective.AuthorUserID > 0 { + sum.AssignedDuties++ + } + if effective.ReviewerUserID > 0 { + sum.AssignedDuties++ + } + if effective.ApproverUserID > 0 { + sum.AssignedDuties++ + } } case DutyModeNone: sum.UnmanagedDocTasks++ diff --git a/internal/projectworkflow/catalog.go b/internal/projectworkflow/catalog.go index 246f0a96..36a0b58e 100644 --- a/internal/projectworkflow/catalog.go +++ b/internal/projectworkflow/catalog.go @@ -28,11 +28,11 @@ type memoryCatalog struct { order []string } -// NewDefaultCatalog 创建默认工作流目录(医疗三 + 软件自举/交付) +// NewDefaultCatalog 创建默认工作流目录(医疗四 + 软件自举/交付) func NewDefaultCatalog() Catalog { c := &memoryCatalog{ definitions: make(map[string]*Definition), - order: []string{"main", "product_manager", "test_manager", "software_bootstrap", "software_delivery"}, + order: []string{"main", "product_manager", "test_manager", "rdev", "software_bootstrap", "software_delivery"}, } c.definitions["main"] = &Definition{ @@ -69,6 +69,17 @@ func NewDefaultCatalog() Catalog { Enabled: true, } + c.definitions["rdev"] = &Definition{ + Key: "rdev", + Name: "研发设计控制流程", + Description: "ISO 13485 设计控制:13 阶段 / 60 受控文档(DHF/DMR)", + StageKey: "RD01_planning", + TaskKey: "rd_proposal", + OutputDocType: "rd_proposal", + OutputDocTitle: "项目建议书", + Enabled: true, + } + c.definitions["software_bootstrap"] = &Definition{ Key: "software_bootstrap", Name: "软件项目自举流程", @@ -101,7 +112,7 @@ func WorkflowKeysForProductDomain(domain string) []string { case "non_medical_software": return []string{"software_bootstrap", "software_delivery"} case "medical_device": - return []string{"main", "product_manager", "test_manager"} + return []string{"main", "product_manager", "test_manager", "rdev"} default: return nil } diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 0241949c..2bea9f7a 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -72,7 +72,7 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { if err != nil { t.Fatalf("list keyed workflows: %v", err) } - if len(keyedWFs) != 5 { + if len(keyedWFs) != 6 { t.Fatalf("catalog must still mount after keyed create, got %d", len(keyedWFs)) } @@ -81,8 +81,8 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { if err != nil { t.Fatalf("ListProjectWorkflows failed: %v", err) } - if len(wfs) != 5 { - t.Fatalf("expected 5 default workflows, got %d", len(wfs)) + if len(wfs) != 6 { + t.Fatalf("expected 6 default workflows, got %d", len(wfs)) } // 4. 非创建人尝试切换工作流 -> 必须被拒绝 @@ -278,7 +278,7 @@ func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) { if !got["software_bootstrap"] || !got["software_delivery"] { t.Fatalf("expected software keys, got %v", got) } - if got["main"] || got["product_manager"] || got["test_manager"] { + if got["main"] || got["product_manager"] || got["test_manager"] || got["rdev"] { t.Fatalf("software project must not mount medical keys: %v", got) } @@ -296,8 +296,8 @@ func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) { for _, wf := range mwfs { mgot[wf.WorkflowKey] = true } - if !mgot["main"] || !mgot["product_manager"] || !mgot["test_manager"] { - t.Fatalf("medical three keys missing: %v", mgot) + if !mgot["main"] || !mgot["product_manager"] || !mgot["test_manager"] || !mgot["rdev"] { + t.Fatalf("medical project must mount four medical keys: %v", mgot) } _, err = svc.CreateProject(ctx, creator, CreateProjectInput{ diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 9e5cf422..4b909857 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -201,7 +201,7 @@ func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pro } list = append(list, p) } - return list, nil + return list, rows.Err() } func (s *pgStore) ListAllProjects(ctx context.Context) ([]*Project, error) { @@ -258,7 +258,7 @@ func (s *pgStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([] } list = append(list, pw) } - return list, nil + return list, rows.Err() } func (s *pgStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error { @@ -484,7 +484,7 @@ func (s *pgStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflo } list = append(list, run) } - return list, nil + return list, rows.Err() } func (s *pgStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error { @@ -532,7 +532,7 @@ func (s *pgStore) GetLineageForDownstream(ctx context.Context, docID string, ver } list = append(list, l) } - return list, nil + return list, rows.Err() } func (s *pgStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) { @@ -560,7 +560,7 @@ func (s *pgStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID stri } list = append(list, l) } - return list, nil + return list, rows.Err() } func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) { @@ -590,7 +590,7 @@ func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, } updated = append(updated, l) } - return updated, nil + return updated, rows.Err() } // ============================================================================= diff --git a/internal/server/admin_system_handlers.go b/internal/server/admin_system_handlers.go index 2d7327f3..73bfa6c1 100644 --- a/internal/server/admin_system_handlers.go +++ b/internal/server/admin_system_handlers.go @@ -70,13 +70,20 @@ func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Re } key := r.PathValue("key") var req struct { - Value json.RawMessage `json:"value"` + Value json.RawMessage `json:"value"` + ExpectedRevision *int64 `json:"expected_revision"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeSafeError(w, r, http.StatusBadRequest, "无效的请求体") return } - setting, err := s.sysadmin.UpdateSetting(r.Context(), key, req.Value, user.ID) + setting, err := s.sysadmin.UpdateSettingEx(r.Context(), sysadmin.SettingUpdate{ + Key: key, + Value: req.Value, + UpdatedBy: user.ID, + ExpectedRevision: req.ExpectedRevision, + RequestID: r.Header.Get("X-Request-ID"), + }) switch { case errors.Is(err, sysadmin.ErrUnknownSetting): writeSafeError(w, r, http.StatusBadRequest, "未知的设置键") @@ -84,6 +91,10 @@ func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Re writeSafeError(w, r, http.StatusForbidden, "该设置为只读") case errors.Is(err, sysadmin.ErrInvalidSettingValue): writeSafeError(w, r, http.StatusBadRequest, "设置值类型不匹配") + case errors.Is(err, sysadmin.ErrExpectedRevisionRequired): + writeSafeError(w, r, http.StatusBadRequest, "该设置需要 expected_revision") + case errors.Is(err, sysadmin.ErrSettingRevisionConflict): + writeSafeError(w, r, http.StatusConflict, `{"error":"SETTING_REVISION_CONFLICT","message":"设置版本冲突,请刷新后重试"}`) case errors.Is(err, sysadmin.ErrStoreUnavailable): writeSafeError(w, r, http.StatusServiceUnavailable, "设置存储不可用") case err != nil: diff --git a/internal/server/admin_system_handlers_test.go b/internal/server/admin_system_handlers_test.go index b0f0731c..fb2e0647 100644 --- a/internal/server/admin_system_handlers_test.go +++ b/internal/server/admin_system_handlers_test.go @@ -12,34 +12,68 @@ import ( "chat-agent-system/internal/auth" "chat-agent-system/internal/sysadmin" + + "github.com/jackc/pgx/v5" ) type fakeSettingsStore struct { mu sync.Mutex - items map[string]json.RawMessage + items map[string]struct { + value json.RawMessage + revision int64 + } } func newFakeSettingsStore() *fakeSettingsStore { - return &fakeSettingsStore{items: map[string]json.RawMessage{}} + return &fakeSettingsStore{items: map[string]struct { + value json.RawMessage + revision int64 + }{}} } -func (f *fakeSettingsStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, error) { +func (f *fakeSettingsStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, int64, error) { f.mu.Lock() defer f.mu.Unlock() - raw, ok := f.items[key] + entry, ok := f.items[key] if !ok { - return nil, nil, nil, sysadmin.ErrSettingNotFound + return nil, nil, nil, 0, sysadmin.ErrSettingNotFound } at := time.Now() by := int64(1) - return raw, &by, &at, nil + return append(json.RawMessage(nil), entry.value...), &by, &at, entry.revision, nil } -func (f *fakeSettingsStore) Upsert(_ context.Context, key string, value json.RawMessage, _ int64) (time.Time, error) { +func (f *fakeSettingsStore) Upsert(_ context.Context, key string, value json.RawMessage, _ int64) (time.Time, int64, error) { f.mu.Lock() defer f.mu.Unlock() - f.items[key] = append(json.RawMessage(nil), value...) - return time.Now(), nil + prev := f.items[key] + f.items[key] = struct { + value json.RawMessage + revision int64 + }{value: append(json.RawMessage(nil), value...), revision: prev.revision} + return time.Now(), prev.revision, nil +} + +func (f *fakeSettingsStore) UpsertCAS(_ context.Context, key string, value json.RawMessage, _, expectedRevision int64, _ string) (time.Time, int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + entry, ok := f.items[key] + if !ok { + return time.Time{}, 0, sysadmin.ErrSettingNotFound + } + if entry.revision != expectedRevision { + return time.Time{}, 0, sysadmin.ErrSettingRevisionConflict + } + newRev := entry.revision + 1 + f.items[key] = struct { + value json.RawMessage + revision int64 + }{value: append(json.RawMessage(nil), value...), revision: newRev} + return time.Now(), newRev, nil +} + +func (f *fakeSettingsStore) GetForUpdate(ctx context.Context, _ pgx.Tx, key string) (json.RawMessage, *int64, *time.Time, int64, error) { + return f.Get(ctx, key) } // registerTestUser 注册并登录一个普通用户,返回其 Bearer Token。 @@ -242,3 +276,50 @@ func TestAdminSystemSettings_UpdateWithoutStoreReturns503(t *testing.T) { t.Fatalf("expected 503 without settings store, got %d %s", w.Code, w.Body.String()) } } + +func TestAdminSystemSettings_AuthorOnlyPolicyCAS(t *testing.T) { + srv := newTestServer(t) + srv.cfg.AllowInMemory = false + store := newFakeSettingsStore() + // 模拟迁移种子行 + _, _, _ = store.Upsert(context.Background(), sysadmin.SettingKeyAllowAuthorOnlyApproval, json.RawMessage(`false`), 0) + srv.sysadmin = sysadmin.NewService(srv.cfg, sysadmin.Options{Store: store}) + token := loginTestAdmin(t, srv) + plainToken := registerTestUser(t, srv, "plain_author_only_probe") + + path := "/api/v1/admin/system/settings/" + sysadmin.SettingKeyAllowAuthorOnlyApproval + + // 非管理员禁止 + if w := doSysadminRequest(t, srv, http.MethodPut, path, plainToken, `{"value":true,"expected_revision":0}`); w.Code != http.StatusForbidden && w.Code != http.StatusUnauthorized { + t.Fatalf("plain user: expected 401/403, got %d %s", w.Code, w.Body.String()) + } + + // 缺少 expected_revision → 400 + if w := doSysadminRequest(t, srv, http.MethodPut, path, token, `{"value":true}`); w.Code != http.StatusBadRequest { + t.Fatalf("missing revision: expected 400, got %d %s", w.Code, w.Body.String()) + } + + // null / 字符串布尔 → 400 + for _, body := range []string{`{"value":null,"expected_revision":0}`, `{"value":"true","expected_revision":0}`} { + if w := doSysadminRequest(t, srv, http.MethodPut, path, token, body); w.Code != http.StatusBadRequest { + t.Fatalf("invalid bool %s: expected 400, got %d %s", body, w.Code, w.Body.String()) + } + } + + w := doSysadminRequest(t, srv, http.MethodPut, path, token, `{"value":true,"expected_revision":0}`) + if w.Code != http.StatusOK { + t.Fatalf("enable: expected 200, got %d %s", w.Code, w.Body.String()) + } + var enabled sysadmin.Setting + if err := json.Unmarshal(w.Body.Bytes(), &enabled); err != nil { + t.Fatalf("decode: %v", err) + } + if enabled.Value != true || enabled.Revision != 1 { + t.Fatalf("expected true@1, got %+v", enabled) + } + + // 过期 revision → 409 + if w := doSysadminRequest(t, srv, http.MethodPut, path, token, `{"value":false,"expected_revision":0}`); w.Code != http.StatusConflict { + t.Fatalf("stale revision: expected 409, got %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/chat_session_ownership_pg_test.go b/internal/server/chat_session_ownership_pg_test.go new file mode 100644 index 00000000..011aca25 --- /dev/null +++ b/internal/server/chat_session_ownership_pg_test.go @@ -0,0 +1,79 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestChatSessionOwnership_CrossUserHijackDenied_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + handler, _ := setupPGGovernanceServer(t, pool) + victimToken := loginTestUser(t, handler, "zhanggong", "123456") + attackerToken := loginTestUser(t, handler, "lizhuguan", "123456") + + sessionID := "sess-hijack-pg-" + time.Now().Format("150405.000") + + victimBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "message": "机密立项讨论"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(victimBody)) + req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, victimToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("victim chat: %d %s", rec.Code, rec.Body.String()) + } + + attackerBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "message": "尝试接管"}) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(attackerBody)) + req2.Header.Set("Content-Type", "application/json") + withBearerAuth(req2, attackerToken) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusForbidden { + t.Fatalf("attacker hijack expected 403, got %d: %s", rec2.Code, rec2.Body.String()) + } + if !strings.Contains(rec2.Body.String(), "SESSION_FORBIDDEN") { + t.Fatalf("expected SESSION_FORBIDDEN, got %s", rec2.Body.String()) + } + + req3 := httptest.NewRequest(http.MethodPost, "/api/v1/chat/stream", bytes.NewReader(attackerBody)) + req3.Header.Set("Content-Type", "application/json") + withBearerAuth(req3, attackerToken) + rec3 := httptest.NewRecorder() + handler.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusForbidden { + t.Fatalf("attacker stream hijack expected 403, got %d", rec3.Code) + } + if strings.Contains(rec3.Header().Get("Content-Type"), "text/event-stream") { + t.Fatal("must reject before SSE headers are set") + } + + upsertBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "user_id": 999999, "title": "hijack via upsert"}) + req4 := httptest.NewRequest(http.MethodPost, "/api/v1/chat/sessions", bytes.NewReader(upsertBody)) + req4.Header.Set("Content-Type", "application/json") + withBearerAuth(req4, attackerToken) + rec4 := httptest.NewRecorder() + handler.ServeHTTP(rec4, req4) + if rec4.Code != http.StatusForbidden { + t.Fatalf("attacker upsert hijack expected 403, got %d: %s", rec4.Code, rec4.Body.String()) + } + + req5 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(victimBody)) + req5.Header.Set("Content-Type", "application/json") + withBearerAuth(req5, victimToken) + rec5 := httptest.NewRecorder() + handler.ServeHTTP(rec5, req5) + if rec5.Code != http.StatusOK { + t.Fatalf("victim continued access broken: %d %s", rec5.Code, rec5.Body.String()) + } +} diff --git a/internal/server/chat_session_ownership_test.go b/internal/server/chat_session_ownership_test.go new file mode 100644 index 00000000..7b5cc149 --- /dev/null +++ b/internal/server/chat_session_ownership_test.go @@ -0,0 +1,116 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/server" +) + +func createAndLoginUser(t *testing.T, handler http.Handler, adminToken, username, password string) string { + t.Helper() + createBody, _ := json.Marshal(map[string]string{ + "username": username, + "name": username, + "initial_password": password, + }) + reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(createBody)) + reqCreate.Header.Set("Content-Type", "application/json") + withBearerAuth(reqCreate, adminToken) + recCreate := httptest.NewRecorder() + handler.ServeHTTP(recCreate, reqCreate) + if recCreate.Code != http.StatusOK { + t.Fatalf("admin create user %s: %d %s", username, recCreate.Code, recCreate.Body.String()) + } + + token := loginTestUser(t, handler, username, password) + // 管理员创建的用户可能要求改密;改完后再登录拿稳定 token。 + changeBody, _ := json.Marshal(map[string]string{ + "old_password": password, + "new_password": password + "_ok", + }) + reqChange := httptest.NewRequest(http.MethodPost, "/api/v1/auth/change-password", bytes.NewReader(changeBody)) + reqChange.Header.Set("Content-Type", "application/json") + withBearerAuth(reqChange, token) + recChange := httptest.NewRecorder() + handler.ServeHTTP(recChange, reqChange) + if recChange.Code == http.StatusOK { + return loginTestUser(t, handler, username, password+"_ok") + } + return token +} + +func TestChatSessionOwnership_CrossUserHijackDenied_Memory(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + adminToken := loginTestAdmin(t, handler, cfg) + + suffix := time.Now().Format("150405.000") + victimToken := createAndLoginUser(t, handler, adminToken, "hijack_victim_"+suffix, "pass12345") + attackerToken := createAndLoginUser(t, handler, adminToken, "hijack_attacker_"+suffix, "pass12345") + + sessionID := "sess-hijack-" + suffix + + victimBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "message": "机密立项讨论"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(victimBody)) + req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, victimToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("victim chat: %d %s", rec.Code, rec.Body.String()) + } + + attackerBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "message": "尝试接管"}) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(attackerBody)) + req2.Header.Set("Content-Type", "application/json") + withBearerAuth(req2, attackerToken) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusForbidden { + t.Fatalf("attacker hijack expected 403, got %d: %s", rec2.Code, rec2.Body.String()) + } + if !strings.Contains(rec2.Body.String(), "SESSION_FORBIDDEN") { + t.Fatalf("expected SESSION_FORBIDDEN, got %s", rec2.Body.String()) + } + + req3 := httptest.NewRequest(http.MethodPost, "/api/v1/chat/stream", bytes.NewReader(attackerBody)) + req3.Header.Set("Content-Type", "application/json") + withBearerAuth(req3, attackerToken) + rec3 := httptest.NewRecorder() + handler.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusForbidden { + t.Fatalf("attacker stream hijack expected 403, got %d", rec3.Code) + } + if strings.Contains(rec3.Header().Get("Content-Type"), "text/event-stream") { + t.Fatal("must reject before SSE headers are set") + } + + upsertBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "user_id": 999999, "title": "hijack via upsert"}) + req4 := httptest.NewRequest(http.MethodPost, "/api/v1/chat/sessions", bytes.NewReader(upsertBody)) + req4.Header.Set("Content-Type", "application/json") + withBearerAuth(req4, attackerToken) + rec4 := httptest.NewRecorder() + handler.ServeHTTP(rec4, req4) + if rec4.Code != http.StatusForbidden { + t.Fatalf("attacker upsert hijack expected 403, got %d: %s", rec4.Code, rec4.Body.String()) + } + + req5 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(victimBody)) + req5.Header.Set("Content-Type", "application/json") + withBearerAuth(req5, victimToken) + rec5 := httptest.NewRecorder() + handler.ServeHTTP(rec5, req5) + if rec5.Code != http.StatusOK { + t.Fatalf("victim continued access broken: %d %s", rec5.Code, rec5.Body.String()) + } +} diff --git a/internal/server/chat_window_api.go b/internal/server/chat_window_api.go index e2c67d25..f279a7da 100644 --- a/internal/server/chat_window_api.go +++ b/internal/server/chat_window_api.go @@ -167,8 +167,8 @@ func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request return } - // 安全门禁:生产环境默认禁止调用,仅在 dev 或显式开启时开放 - isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || s.cfg.Env == "" || os.Getenv("ENABLE_DEV_IMPORT") == "true" + // 安全门禁:生产环境默认禁止调用,仅在显式 dev 或 ENABLE_DEV_IMPORT 时开放 + isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || os.Getenv("ENABLE_DEV_IMPORT") == "true" if !isDev { writeSafeError(w, r, http.StatusForbidden, "chat messages import endpoint is only available in dev/demo environment") return diff --git a/internal/server/chat_window_api_test.go b/internal/server/chat_window_api_test.go index 42316542..954695f0 100644 --- a/internal/server/chat_window_api_test.go +++ b/internal/server/chat_window_api_test.go @@ -179,3 +179,30 @@ func TestServer_ChatWindow_ImportGuard(t *testing.T) { t.Fatalf("expected 403 Forbidden in production env, got %d", rrImport.Code) } } + +func TestServer_ChatWindow_ImportGuard_EmptyEnvFailsClosed(t *testing.T) { + // E-5:Env 为空不得视同 dev,导入端点默认拒绝 + cfg := &config.Config{ + Env: "test", + HTTPHost: "127.0.0.1", + HTTPPort: 8889, + DataDir: t.TempDir(), + AllowInMemory: true, + AgentBackend: "stub", + } + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + cfg.Env = "" + t.Setenv("ENABLE_DEV_IMPORT", "") + handler := srv.Routes() + + reqImport := httptest.NewRequest("POST", "/api/v1/chat/messages/import", bytes.NewReader([]byte("[]"))) + reqImport.Header.Set("Content-Type", "application/json") + withBearerAuth(reqImport, loginTestAdmin(t, handler, cfg)) + rrImport := httptest.NewRecorder() + handler.ServeHTTP(rrImport, reqImport) + + if rrImport.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden when Env is empty, got %d body=%s", rrImport.Code, rrImport.Body.String()) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 130846a6..54757d08 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "path/filepath" "strconv" @@ -68,6 +69,7 @@ type TaskRunRequestDTO struct { UserID int64 `json:"user_id,omitempty"` ProjectID int64 `json:"project_id,omitempty"` DocID string `json:"doc_id,omitempty"` + StageKey string `json:"stage_key,omitempty"` // 冻结实例阶段键(服务端 ResolveDocTask 注入) AuthorID int64 `json:"author_id,omitempty"` AuthorName string `json:"author_name,omitempty"` ReviewerID int64 `json:"reviewer_id,omitempty"` @@ -141,8 +143,8 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { return } - sess := s.getOrCreateSession(sessionID, "", "", "", "", "", "", "", "") - if sess == nil || sess.UserCtx == nil { + sess, err := s.getOrCreateSession(0, sessionID, "", "", "", "", "", "", "", "") + if err != nil || sess == nil || sess.UserCtx == nil { writeSafeError(w, r, http.StatusNotFound, "session or project context not found") return } @@ -167,7 +169,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } req.WorkDir = "" - sess := s.getOrCreateSession( + sess, err := s.getOrCreateSession( + user.ID, req.SessionID, req.ProjectName, req.CustomerName, @@ -178,7 +181,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - sess.UserID = user.ID + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) + return + } s.hydrateVisibleHistory(r.Context(), sess, user.ID) if req.ProjectID > 0 { sess.ProjectID = req.ProjectID @@ -734,6 +747,66 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req writeJSONResponse(w, http.StatusOK, doc) } +// ProposeModificationRequest 发起变更提案请求 +type ProposeModificationRequest struct { + DocID string `json:"doc_id"` + TriggeredBy string `json:"triggered_by"` + ProposedChanges string `json:"proposed_changes"` + ImpactAnalysis string `json:"impact_analysis"` + RiskAssessment string `json:"risk_assessment"` +} + +// handleDocGovProposeModification 对已认领文档发起变更提案(写库 + 审计) +func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + + var req ProposeModificationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + if strings.TrimSpace(req.DocID) == "" { + writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty") + return + } + if strings.TrimSpace(req.ProposedChanges) == "" { + writeSafeError(w, r, http.StatusBadRequest, "proposed_changes cannot be empty") + return + } + + if s.gov == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") + return + } + + triggeredBy := strings.TrimSpace(req.TriggeredBy) + if triggeredBy == "" { + triggeredBy = fmt.Sprintf("user:%d", user.ID) + } + + proposal, err := s.gov.ProposeModification( + r.Context(), + req.DocID, + triggeredBy, + req.ProposedChanges, + req.ImpactAnalysis, + req.RiskAssessment, + ) + if err != nil { + if errors.Is(err, docgov.ErrDocumentNotFound) { + writeSafeError(w, r, http.StatusNotFound, "document not found") + return + } + writeSafeError(w, r, http.StatusBadRequest, "propose modification failed: "+err.Error()) + return + } + + writeJSONResponse(w, http.StatusOK, proposal) +} + // DecideProposalRequest 拍板变更提案请求 type DecideProposalRequest struct { ProposalID string `json:"proposal_id"` @@ -754,6 +827,10 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) return } + if strings.TrimSpace(req.ProposalID) == "" { + writeSafeError(w, r, http.StatusBadRequest, "proposal_id cannot be empty") + return + } if s.gov == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") @@ -762,6 +839,14 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason) if err != nil { + if errors.Is(err, docgov.ErrProposalNotFound) { + writeSafeError(w, r, http.StatusNotFound, "proposal not found") + return + } + if errors.Is(err, docgov.ErrUnauthorizedActor) { + writeSafeError(w, r, http.StatusForbidden, "decide proposal failed: "+err.Error()) + return + } writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error()) return } @@ -908,6 +993,10 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } var req upsertChatSessionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -918,9 +1007,17 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusBadRequest, "missing session_id") return } + req.UserID = user.ID - summary, err := s.ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title) + summary, err := s.ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title) if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权操作该会话", + }) + return + } writeSafeError(w, r, http.StatusInternalServerError, "upsert chat session failed: "+err.Error()) return } diff --git a/internal/server/notification_reminder_loop.go b/internal/server/notification_reminder_loop.go index 7210756d..fef61fae 100644 --- a/internal/server/notification_reminder_loop.go +++ b/internal/server/notification_reminder_loop.go @@ -12,12 +12,18 @@ func (s *Server) startNotificationReminderLoop() { if s == nil || s.notifySvc == nil || !s.notifySvc.Available() { return } + s.reminderStop = make(chan struct{}) go func() { ticker := time.NewTicker(defaultReminderPollInterval) defer ticker.Stop() s.runStageAdvanceReminders(context.Background()) - for range ticker.C { - s.runStageAdvanceReminders(context.Background()) + for { + select { + case <-s.reminderStop: + return + case <-ticker.C: + s.runStageAdvanceReminders(context.Background()) + } } }() } diff --git a/internal/server/project_settings_author_only_error_test.go b/internal/server/project_settings_author_only_error_test.go new file mode 100644 index 00000000..34349efd --- /dev/null +++ b/internal/server/project_settings_author_only_error_test.go @@ -0,0 +1,50 @@ +package server + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/projectinstance" +) + +func TestWriteProjectInstanceError_AuthorOnlyCodes(t *testing.T) { + cases := []struct { + err error + code int + body string + }{ + {projectinstance.ErrAuthorOnlyPolicyDisabled, http.StatusForbidden, "AUTHOR_ONLY_POLICY_DISABLED"}, + {projectinstance.ErrAuthorOnlyPolicyChanged, http.StatusConflict, "AUTHOR_ONLY_POLICY_CHANGED"}, + {projectinstance.ErrAuthorOnlyPolicyUnavailable, http.StatusServiceUnavailable, "AUTHOR_ONLY_POLICY_UNAVAILABLE"}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + writeProjectInstanceError(w, tc.err) + if w.Code != tc.code { + t.Fatalf("%v: status %d want %d body=%s", tc.err, w.Code, tc.code, w.Body.String()) + } + if !contains(w.Body.String(), tc.body) { + t.Fatalf("%v: body %s missing %s", tc.err, w.Body.String(), tc.body) + } + } + // wrapped unavailable + w := httptest.NewRecorder() + writeProjectInstanceError(w, errors.Join(projectinstance.ErrAuthorOnlyPolicyUnavailable, errors.New("db down"))) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("wrapped unavailable: %d %s", w.Code, w.Body.String()) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || + (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })()) +} diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index e3dd9a99..b88c90c0 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -198,6 +198,12 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { writeSafeError(w, nil, http.StatusBadRequest, `{"error":"ERR_INVALID_STAGE","message":"无效的阶段"}`) case errors.Is(err, projectinstance.ErrInstanceRevisionConflict): writeSafeError(w, nil, http.StatusConflict, `{"error":"INSTANCE_REVISION_CONFLICT","message":"实例版本冲突,请刷新后重试"}`) + case errors.Is(err, projectinstance.ErrAuthorOnlyPolicyDisabled): + writeSafeError(w, nil, http.StatusForbidden, `{"error":"AUTHOR_ONLY_POLICY_DISABLED","message":"系统未授权编制人兼任审核批准"}`) + case errors.Is(err, projectinstance.ErrAuthorOnlyPolicyChanged): + writeSafeError(w, nil, http.StatusConflict, `{"error":"AUTHOR_ONLY_POLICY_CHANGED","message":"兼任总授权已变更,请重新预览并确认激活"}`) + case errors.Is(err, projectinstance.ErrAuthorOnlyPolicyUnavailable): + writeSafeError(w, nil, http.StatusServiceUnavailable, `{"error":"AUTHOR_ONLY_POLICY_UNAVAILABLE","message":"兼任总授权存储不可用"}`) default: writeSafeError(w, nil, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`) } diff --git a/internal/server/project_workflow_api_test.go b/internal/server/project_workflow_api_test.go index 5af9cd78..cd20c7aa 100644 --- a/internal/server/project_workflow_api_test.go +++ b/internal/server/project_workflow_api_test.go @@ -83,7 +83,7 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) { var wfList []*projectworkflow.ProjectWorkflow err = json.Unmarshal(recWFs.Body.Bytes(), &wfList) require.NoError(t, err) - assert.Equal(t, 5, len(wfList)) + assert.Equal(t, 6, len(wfList)) // 5. planning 项目禁止旧式切换 -> 409 Conflict bodySwitchBlocked, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{ @@ -119,7 +119,7 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) { require.Equal(t, http.StatusOK, recKeyedWFs.Code) var keyedWFs []*projectworkflow.ProjectWorkflow require.NoError(t, json.Unmarshal(recKeyedWFs.Body.Bytes(), &keyedWFs)) - assert.Equal(t, 5, len(keyedWFs)) + assert.Equal(t, 6, len(keyedWFs)) bodySwitchCreator, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{ WorkflowKey: "product_manager", @@ -141,7 +141,7 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) { var defs []*projectworkflow.Definition err = json.Unmarshal(recDefs.Body.Bytes(), &defs) require.NoError(t, err) - assert.Equal(t, 5, len(defs)) + assert.Equal(t, 6, len(defs)) // 9. 触发工作流运行 -> 200 OK bodyRun, _ := json.Marshal(map[string]any{ diff --git a/internal/server/server.go b/internal/server/server.go index 59d25d07..bf569257 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -77,6 +77,8 @@ type Server struct { vaultStore *artifactvault.FilesystemStore vaultPub *artifactvault.Publisher vaultPublisherStarted int32 + vaultStop chan struct{} + reminderStop chan struct{} } // NewFromRuntime attaches the HTTP adapter to an already-wired composition root. @@ -276,6 +278,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/docgov/submit-review", s.handleDocGovSubmitReview) mux.HandleFunc("POST /api/v1/docgov/review-decision", s.handleDocGovReviewDecision) mux.HandleFunc("POST /api/v1/docgov/approver-decision", s.handleDocGovApproverDecision) + mux.HandleFunc("POST /api/v1/docgov/proposal", s.handleDocGovProposeModification) mux.HandleFunc("POST /api/v1/docgov/proposal/decide", s.handleDocGovDecideProposal) mux.HandleFunc("GET /api/v1/docgov/comments", s.handleDocGovListComments) mux.HandleFunc("POST /api/v1/docgov/comments", s.handleDocGovAddComment) @@ -356,6 +359,14 @@ func (s *Server) Start() error { // Shutdown 优雅停机 func (s *Server) Shutdown(ctx context.Context) error { + if s.reminderStop != nil { + close(s.reminderStop) + s.reminderStop = nil + } + if s.vaultStop != nil { + close(s.vaultStop) + s.vaultStop = nil + } if s.taskRuns != nil { _ = s.taskRuns.WaitIdle(ctx) } @@ -390,8 +401,9 @@ func (s *Server) cleanStaleSessionsLocked(ttl time.Duration) int { return cleaned } -// getOrCreateSession 获取或动态构建会话 +// getOrCreateSession 获取或动态构建会话。callerUserID>0 时校验持久层归属,不匹配返回 ErrSessionOwnerMismatch。 func (s *Server) getOrCreateSession( + callerUserID int64, sessionID string, projectName string, customerName string, @@ -401,25 +413,38 @@ func (s *Server) getOrCreateSession( workDir string, userName string, userRole string, -) *chat.Session { +) (*chat.Session, error) { if sessionID == "" { sessionID = fmt.Sprintf("sess_%d", time.Now().UnixNano()) } + // 归属校验必须先于任何缓存命中/持久层灌入 + if callerUserID > 0 && s.ctxMgr != nil { + if owner, err := s.ctxMgr.GetSessionOwnerID(context.Background(), sessionID); err == nil && owner > 0 && owner != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } + } + s.sessionsMu.RLock() existing, exists := s.sessions[sessionID] s.sessionsMu.RUnlock() if exists && existing != nil { + if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } existing.LastAccessedAt = time.Now() - return existing + return existing, nil } s.sessionsMu.Lock() defer s.sessionsMu.Unlock() // 双重检查 if existing, exists := s.sessions[sessionID]; exists && existing != nil { + if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } existing.LastAccessedAt = time.Now() - return existing + return existing, nil } // 惰性内存防泄漏保护 (会话数超过 200 时清理空闲超 120 分钟会话) @@ -548,9 +573,19 @@ func (s *Server) getOrCreateSession( } } + // 新建会话时立即绑定调用者。NewSession 历史占位 UserID=1/ProjectID=1 不能当作已绑定归属。 + if restored == nil { + sess.ProjectID = 0 + if callerUserID > 0 { + sess.UserID = callerUserID + } + } else if restored.UserID <= 0 && callerUserID > 0 { + sess.UserID = callerUserID + } + sess.LastAccessedAt = time.Now() s.sessions[sessionID] = sess - return sess + return sess, nil } func (s *Server) hydrateVisibleHistory(ctx context.Context, sess *chat.Session, viewerUserID int64) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 730436ae..589a6faa 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -18,6 +18,7 @@ import ( "chat-agent-system/internal/bizdev" "chat-agent-system/internal/chat" "chat-agent-system/internal/config" + "chat-agent-system/internal/docgov" "chat-agent-system/internal/stream" ) @@ -461,3 +462,63 @@ func TestServer_ContextAndDocGovAPIs(t *testing.T) { t.Fatalf("expected 200 from audit logs, got %d", w.Code) } } + +func TestServer_DocGovProposeAndDecideHTTP(t *testing.T) { + srv := newTestServer(t) + token := loginTestAdmin(t, srv) + ctx := context.Background() + docID := "doc_proj1_proposal_http" + + if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "feasibility_reg", "法规评估", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil { + t.Fatalf("CreateOrGetDocument: %v", err) + } + if _, err := srv.gov.RecordDocumentViewed(ctx, docID, 1, docgov.RoleAuthor, "admin"); err != nil { + t.Fatalf("claim: %v", err) + } + + proposeBody, _ := json.Marshal(ProposeModificationRequest{ + DocID: docID, + TriggeredBy: "web_ui", + ProposedChanges: "补充 EMC 滤波指标", + ImpactAnalysis: "影响原理图选型", + RiskAssessment: "低风险", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal", bytes.NewReader(proposeBody)) + withBearerAuth(req, token) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("propose want 200 got %d %s", w.Code, w.Body.String()) + } + var prop map[string]any + if err := json.NewDecoder(w.Body).Decode(&prop); err != nil { + t.Fatalf("decode propose: %v", err) + } + proposalID, _ := prop["proposal_id"].(string) + if proposalID == "" { + t.Fatalf("missing proposal_id: %#v", prop) + } + if status, _ := prop["status"].(string); status != "pending_author_approval" { + t.Fatalf("want pending_author_approval got %v", prop["status"]) + } + + decideBody, _ := json.Marshal(DecideProposalRequest{ + ProposalID: proposalID, + Approve: true, + Reason: "同意补充", + }) + req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal/decide", bytes.NewReader(decideBody)) + withBearerAuth(req, token) + w = httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("decide want 200 got %d %s", w.Code, w.Body.String()) + } + var decided map[string]any + if err := json.NewDecoder(w.Body).Decode(&decided); err != nil { + t.Fatalf("decode decide: %v", err) + } + if status, _ := decided["status"].(string); status != "approved_by_author" { + t.Fatalf("want approved_by_author got %v", decided["status"]) + } +} diff --git a/internal/server/sse.go b/internal/server/sse.go index 16d5daa8..fbc03db0 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -173,7 +173,8 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { req.WorkDir = "" // 会话与项目绑定必须在 SSE headers/Flush 之前完成授权(与 tasks/run/stream 对齐) - sess := s.getOrCreateSession( + sess, err := s.getOrCreateSession( + user.ID, req.SessionID, req.ProjectName, req.CustomerName, @@ -184,7 +185,17 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - sess.UserID = req.UserID + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) + return + } s.hydrateVisibleHistory(r.Context(), sess, user.ID) if req.ProjectID > 0 { sess.ProjectID = req.ProjectID @@ -204,6 +215,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { s.writeCORS(w, r) w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲 flusher.Flush() + extendSSEWriteDeadline(w) ctx := stream.WithRequestID(r.Context(), requestID) @@ -228,14 +240,37 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { } } - // 3. 构建线程安全的 SSE 监听分发闭包 + // 3. 构建线程安全的 SSE 监听分发闭包;心跳与业务写共用 writeMu,避免与 WriteTimeout 竞态 var writeMu sync.Mutex listener := func(ev stream.StreamEvent) { writeMu.Lock() defer writeMu.Unlock() + extendSSEWriteDeadline(w) writeSSE(w, flusher, ev) } + heartbeatDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(sseHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-heartbeatDone: + return + case <-r.Context().Done(): + return + case <-ticker.C: + writeMu.Lock() + err := writeSSEHeartbeat(w, flusher) + writeMu.Unlock() + if err != nil { + return + } + } + } + }() + defer close(heartbeatDone) + clientTurnID := req.ClientTurnID if clientTurnID == "" { clientTurnID = r.Header.Get("Idempotency-Key") @@ -282,7 +317,6 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { } // 4. 同 session 串行执行后发起流式对话 - var err error execErr := sess.WithExecLock(func() error { var sendErr error _, sendErr = sess.SendMessageStreamTurn(ctx, req.Message, listener, req.IncludeDetails, turnOpts) @@ -312,7 +346,10 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { var req taskStreamRequest includeDetails := false if r.Method == http.MethodPost { - _ = json.NewDecoder(r.Body).Decode(&req) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } } else { q := r.URL.Query() req.TaskKey = q.Get("task_key") @@ -349,22 +386,15 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { } } - // 请求追踪 ID:与 chat stream 对齐,注入 ctx 并回写响应头 - requestID := fmt.Sprintf("req_%d", time.Now().UnixNano()) - w.Header().Set("X-Request-Id", requestID) - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - s.writeCORS(w, r) - w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲 - flusher.Flush() - if req.TaskKey == "" && req.Stage == "" { req.TaskKey = "intake" } req.UserID = user.ID req.WorkDir = "" + // 请求追踪 ID:与 chat stream 对齐;CreateAndStart/Subscribe 必须在 Flush 之前, + // 失败时返回普通 JSON 错误,避免污染已声明的 text/event-stream 连接。 + requestID := fmt.Sprintf("req_%d", time.Now().UnixNano()) runID := fmt.Sprintf("run_%d", time.Now().UnixNano()) record := &taskrun.RunRecord{ RunID: runID, @@ -394,9 +424,19 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { } defer s.taskRuns.Unsubscribe(created.RunID, ch) + w.Header().Set("X-Request-Id", requestID) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + s.writeCORS(w, r) + w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲 + flusher.Flush() + extendSSEWriteDeadline(w) + writeStored := func(stored taskrun.StoredEvent) { _ = (transport.Encoder{W: w}).WriteWithID(stored.Seq, stored.Event) flusher.Flush() + extendSSEWriteDeadline(w) } for _, stored := range replay { @@ -406,11 +446,18 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { } } + heartbeat := time.NewTicker(sseHeartbeatInterval) + defer heartbeat.Stop() + for { select { case <-r.Context().Done(): // SSE 断线仅停止推送,不取消后台 worker return + case <-heartbeat.C: + if err := writeSSEHeartbeat(w, flusher); err != nil { + return + } case stored, ok := <-ch: if !ok { return diff --git a/internal/server/sse_wave0_test.go b/internal/server/sse_wave0_test.go new file mode 100644 index 00000000..ff59e36b --- /dev/null +++ b/internal/server/sse_wave0_test.go @@ -0,0 +1,199 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/stream" + "chat-agent-system/internal/taskrun" +) + +// failInsertStore:Available=true 但 InsertRun 恒失败,用于验证 Flush 前 CreateAndStart 失败路径。 +type failInsertStore struct{} + +func (failInsertStore) Available() bool { return true } +func (failInsertStore) InsertRun(context.Context, *taskrun.RunRecord) error { + return errors.New("injected insert failure") +} +func (failInsertStore) UpdateRun(context.Context, *taskrun.RunRecord) error { return nil } +func (failInsertStore) GetByIdempotencyKey(context.Context, string) (*taskrun.RunRecord, error) { + return nil, nil +} +func (failInsertStore) AppendEvent(context.Context, string, int64, stream.StreamEvent) error { + return nil +} +func (failInsertStore) GetRun(context.Context, string) (*taskrun.RunRecord, error) { + return nil, errors.New("not found") +} +func (failInsertStore) ListEvents(context.Context, string, int64) ([]taskrun.StoredEvent, error) { + return nil, nil +} +func (failInsertStore) AcquireLease(context.Context, string, string, time.Duration) error { + return nil +} +func (failInsertStore) HeartbeatLease(context.Context, string, string, time.Duration) error { + return nil +} +func (failInsertStore) ReleaseLease(context.Context, string, string) error { return nil } +func (failInsertStore) ResetForResume(context.Context, string, int) error { return nil } +func (failInsertStore) MarkOrphanedRunning(context.Context) (int, error) { return 0, nil } + +func TestRunTaskStream_InvalidJSON_Returns400(t *testing.T) { + srv := newTestServer(t) + token := loginTestAdmin(t, srv) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run/stream", bytes.NewReader([]byte(`{not-json`))) + withBearerAuth(req, token) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400 for malformed body, got %d body=%s", w.Code, w.Body.String()) + } + ct := w.Header().Get("Content-Type") + if strings.Contains(ct, "text/event-stream") { + t.Fatalf("malformed body must not open SSE; Content-Type=%q", ct) + } +} + +func TestRunTaskStream_CreateAndStartFails_BeforeSSEFlush(t *testing.T) { + srv := newTestServer(t) + token := loginTestAdmin(t, srv) + + mgr := taskrun.NewManager(nil, nil) + mgr.SetStore(failInsertStore{}) + srv.taskRuns = mgr + + body, _ := json.Marshal(map[string]any{ + "task_key": "intake", + "project_name": "wave0-fail-start", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run/stream", bytes.NewReader(body)) + withBearerAuth(req, token) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + + if w.Code < 400 { + t.Fatalf("want error status before SSE flush, got %d body=%s", w.Code, w.Body.String()) + } + ct := w.Header().Get("Content-Type") + if strings.Contains(ct, "text/event-stream") { + t.Fatalf("CreateAndStart failure must not declare SSE; Content-Type=%q body=%s", ct, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "start task run failed") && !strings.Contains(w.Body.String(), "injected insert") { + t.Fatalf("expected start failure message, got %s", w.Body.String()) + } +} + +func TestSSEHeartbeat_HelpersSurviveShortWriteTimeout(t *testing.T) { + // 复用既有心跳机制:短 WriteTimeout 下流时长 > timeout 仍可读完(已有机制的回归钉)。 + mux := http.NewServeMux() + mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "no flush", 500) + return + } + w.Header().Set("Content-Type", "text/event-stream") + flusher.Flush() + extendSSEWriteDeadline(w) + tick := time.NewTicker(60 * time.Millisecond) + defer tick.Stop() + end := time.After(700 * time.Millisecond) + for { + select { + case <-end: + _, _ = fmt.Fprintf(w, "event: done\ndata: {\"type\":\"done\"}\n\n") + flusher.Flush() + return + case <-tick.C: + if err := writeSSEHeartbeat(w, flusher); err != nil { + return + } + case <-r.Context().Done(): + return + } + } + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + httpSrv := &http.Server{ + Handler: mux, + WriteTimeout: 200 * time.Millisecond, + } + go func() { _ = httpSrv.Serve(ln) }() + t.Cleanup(func() { _ = httpSrv.Close() }) + + resp, err := http.Get("http://" + ln.Addr().String() + "/sse") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if !strings.Contains(string(raw), "event: done") { + t.Fatalf("expected done event after heartbeats; body=%q", string(raw)) + } +} + +func TestRunTaskStream_ValidBody_OpensSSEWithHeartbeatPath(t *testing.T) { + cfg := &config.Config{ + Env: "test", + AgentBackend: "stub", + DataDir: t.TempDir(), + KbDbPort: 5432, + AdminUsername: "admin", + AdminPassword: "admin-test-pass", + } + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + }) + + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + token := testBearerToken(t, ts.URL, cfg) + + body, _ := json.Marshal(map[string]any{ + "task_key": "intake", + "project_name": "wave0-sse-ok", + "customer": "北京水木东方", + }) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run/stream", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("want 200 SSE, got %d %s", resp.StatusCode, string(raw)) + } + if !strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") { + t.Fatalf("Content-Type=%q", resp.Header.Get("Content-Type")) + } +} diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 190fbe54..edb08c84 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -106,7 +106,16 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { ctxMgr := s.ctxMgr if ctxMgr != nil { - _, _ = ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.ProjectName) + if _, err := ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.ProjectName); err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + // 其余错误维持原 best-effort 语义 + } } taskScope, scopeErr := s.resolveTaskMessageScope(r.Context(), req.ProjectID, req.TaskKey) @@ -201,6 +210,9 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } taskReq := s.buildBizdevTaskRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, claimed.RunID) + if taskScope != nil && strings.TrimSpace(taskScope.StageKey) != "" { + taskReq.StageKey = taskScope.StageKey + } resumeDir := req.WorkDir stageReq := s.buildBizdevStageRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, claimed.RunID) createdRun, err := s.taskRuns.StartClaimedRun(r.Context(), claimed, taskReq, stageReq, resumeDir) @@ -592,6 +604,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { UserID: rec.UserID, ProjectID: rec.ProjectID, TaskKey: rec.TaskKey, + StageKey: rec.WorkflowStageKey, }, rec.SessionID, rec.ClientTurnID, rec.RunID) stageReq := s.buildBizdevStageRequest(TaskRunRequestDTO{ UserID: rec.UserID, @@ -653,6 +666,7 @@ func (s *Server) buildBizdevTaskRequest(dto TaskRunRequestDTO, sessionID, client ClientTurnID: clientTurnID, RunID: runID, DocID: dto.DocID, + StageKey: dto.StageKey, AuthorID: dto.AuthorID, AuthorName: dto.AuthorName, ReviewerID: dto.ReviewerID, diff --git a/internal/server/task_run_request_build_test.go b/internal/server/task_run_request_build_test.go index 1caeb2d0..53042e56 100644 --- a/internal/server/task_run_request_build_test.go +++ b/internal/server/task_run_request_build_test.go @@ -45,3 +45,21 @@ func TestBuildBizdevStageRequest_PreservesIdentityAndRoles(t *testing.T) { t.Fatalf("stage=%q", got.Stage) } } + +func TestBuildBizdevTaskRequest_PassesStageKey(t *testing.T) { + s := &Server{} + dto := TaskRunRequestDTO{ + TaskKey: "rd_proposal", + Backend: "stub", + UserID: 1, + ProjectID: 100, + StageKey: "RD01_planning", + } + got := s.buildBizdevTaskRequest(dto, "sess", "turn", "run") + if got.StageKey != "RD01_planning" { + t.Fatalf("StageKey=%q, want RD01_planning", got.StageKey) + } + if got.TaskKey != "rd_proposal" { + t.Fatalf("TaskKey=%q", got.TaskKey) + } +} diff --git a/internal/server/taskstream_test.go b/internal/server/taskstream_test.go index 5d8772c2..e38a433b 100644 --- a/internal/server/taskstream_test.go +++ b/internal/server/taskstream_test.go @@ -182,59 +182,6 @@ func TestTaskStream_SoftFailure_ClassifiedResumable(t *testing.T) { } } -// TestTaskStream_ResumeInvalidWorkspace_ClassifiedResumable resume 模式: -// work_dir 非空走 ResumeOrModifyWorkspace;目标工作区不存在 → 失败仍归类 resumable -// 且 workspace_id 等于请求的 work_dir(工作区可被修复后继续) -func TestTaskStream_ResumeInvalidWorkspace_ClassifiedResumable(t *testing.T) { - t.Skip("P0 安全整改:客户端 work_dir 续跑入口已移除") - cfg := &config.Config{ - Env: "test", - AgentBackend: "stub", - DataDir: t.TempDir(), - KbDbPort: 5432, - AdminUsername: "admin", - AdminPassword: "admin-test-pass", - AgentTimeout: 10 * time.Second, - } - svc := bizdev.MustNewService(t, cfg) - srv := NewServer(cfg, svc) - - ts := httptest.NewServer(srv.Routes()) - defer ts.Close() - - body, _ := json.Marshal(map[string]any{ - "task_key": "intake", - "project_name": "resume-test", - "backend": "stub", - }) - req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run/stream", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+testBearerToken(t, ts.URL, cfg)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("post tasks run stream failed: %v", err) - } - defer resp.Body.Close() - - events := readSSEEvents(t, resp) - errEv := findEvent(events, stream.EventError) - if errEv == nil { - t.Fatalf("expected event:error in stream, got %d events", len(events)) - } - if errEv.ErrorInfo == nil { - t.Fatal("error_info is nil") - } - if errEv.ErrorInfo.Class != stream.ErrorClassResumable { - t.Fatalf("class=%q want resumable (upstream=%q)", errEv.ErrorInfo.Class, errEv.ErrorInfo.Upstream) - } - if errEv.ErrorInfo.WorkspaceID == "" { - t.Fatal("workspace_id should be populated for resumable errors") - } - if !hasAction(errEv.ErrorInfo.Actions, "continue") || !hasAction(errEv.ErrorInfo.Actions, "restart") { - t.Fatalf("actions=%v want [continue restart]", errEv.ErrorInfo.Actions) - } -} - // TestTaskStream_MaterialsInjection 验证前端传入的 materials(Base64 编码的附件内容) // 能被后端 HTTP handler 正确反序列化,透传给 ExecuteTask,并写入工作区的 materials_text/ 目录 func TestTaskStream_MaterialsInjection(t *testing.T) { diff --git a/internal/server/vault.go b/internal/server/vault.go index d9c1d3ee..8e4b643b 100644 --- a/internal/server/vault.go +++ b/internal/server/vault.go @@ -3,6 +3,7 @@ package server import ( "context" "fmt" + "log/slog" "net/http" "sync/atomic" "time" @@ -56,11 +57,19 @@ func (s *Server) startVaultPublisher() { if s == nil || s.vaultPub == nil || s.cfg == nil || !s.cfg.FAVPublisherEnabled { return } + s.vaultStop = make(chan struct{}) go func() { t := time.NewTicker(2 * time.Second) defer t.Stop() - for range t.C { - _ = s.vaultPub.Drain(context.Background()) + for { + select { + case <-s.vaultStop: + return + case <-t.C: + if err := s.vaultPub.Drain(context.Background()); err != nil { + slog.Warn("vault_drain_failed", "error", err) + } + } } }() atomic.StoreInt32(&s.vaultPublisherStarted, 1) diff --git a/internal/squad/rdev_catalog.go b/internal/squad/rdev_catalog.go new file mode 100644 index 00000000..410c3d77 --- /dev/null +++ b/internal/squad/rdev_catalog.go @@ -0,0 +1,367 @@ +package squad + +// RDevArtifactKind 研发文档产物类别(对齐参考项目 artifact_kind)。 +type RDevArtifactKind string + +const ( + RDevKindDocument RDevArtifactKind = "document" + RDevKindReviewMinutes RDevArtifactKind = "review_minutes" + RDevKindUploadIndex RDevArtifactKind = "upload_index" +) + +// RDevTaskMeta rdev 任务目录项。 +type RDevTaskMeta struct { + Key string + Title string + Phase string + PhaseTitle string + Kind RDevArtifactKind + DocCode string + Skippable bool + StageKey string +} + +// RDevCatalog 60 项研发设计控制任务(SSOT 来源:refs rdev.yaml → rd_ 前缀)。 +var RDevCatalog = []RDevTaskMeta{ + { + Key: "rd_proposal", Title: "项目建议书", Phase: "01", + PhaseTitle: "策划", Kind: RDevArtifactKind("document"), + DocCode: "RD-PROPOSAL", Skippable: false, StageKey: "RD01_planning", + }, + { + Key: "rd_design_plan", Title: "设计开发计划任务书", Phase: "01", + PhaseTitle: "策划", Kind: RDevArtifactKind("document"), + DocCode: "RD-DESIGN-PLAN", Skippable: false, StageKey: "RD01_planning", + }, + { + Key: "rd_planning_review", Title: "设计和开发策划评审记录", Phase: "02", + PhaseTitle: "策划评审", Kind: RDevArtifactKind("review_minutes"), + DocCode: "RD-PLAN-REV", Skippable: false, StageKey: "RD02_planning_review", + }, + { + Key: "rd_user_needs", Title: "用户需求规格书", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-UN", Skippable: false, StageKey: "RD03_design_input", + }, + { + Key: "rd_regs_stds", Title: "法规和标准清单", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-REGS", Skippable: false, StageKey: "RD03_design_input", + }, + { + Key: "rd_adverse_events", Title: "同类产品不良事件信息", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-AE", Skippable: false, StageKey: "RD03_design_input", + }, + { + Key: "rd_system_reqs", Title: "系统需求规格书", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-SR", Skippable: false, StageKey: "RD03_design_input", + }, + { + Key: "rd_risk_plan", Title: "风险管理计划", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-RISK-PLAN", Skippable: false, StageKey: "RD03_design_input", + }, + { + Key: "rd_hazard_analysis", Title: "安全性问题清单及可能危险分析表", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-HAZARD", Skippable: false, StageKey: "RD03_design_input", + }, + { + Key: "rd_sw_plan", Title: "软件开发计划", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-SW-PLAN", Skippable: true, StageKey: "RD03_design_input", + }, + { + Key: "rd_sw_reqs", Title: "软件需求规格书", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-SW-REQ", Skippable: true, StageKey: "RD03_design_input", + }, + { + Key: "rd_sw_config", Title: "软件配置及维护计划", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-SW-CFG", Skippable: true, StageKey: "RD03_design_input", + }, + { + Key: "rd_design_input_spec", Title: "设计输入规格书", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-DI-SPEC", Skippable: false, StageKey: "RD03_design_input", + }, + { + Key: "rd_design_input_list", Title: "设计输入清单", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-DI-LIST", Skippable: false, StageKey: "RD03_design_input", + }, + { + Key: "rd_system_design", Title: "系统设计方案", Phase: "03", + PhaseTitle: "设计输入", Kind: RDevArtifactKind("document"), + DocCode: "RD-SYS-DES", Skippable: false, StageKey: "RD03_design_input", + }, + { + Key: "rd_req_review", Title: "需求评审记录", Phase: "04", + PhaseTitle: "需求评审", Kind: RDevArtifactKind("review_minutes"), + DocCode: "RD-REQ-REV", Skippable: false, StageKey: "RD04_requirements_review", + }, + { + Key: "rd_input_phase_review", Title: "设计开发评审记录(阶段3和4)", Phase: "05", + PhaseTitle: "输入阶段评审", Kind: RDevArtifactKind("review_minutes"), + DocCode: "RD-IN-REV", Skippable: false, StageKey: "RD05_input_phase_review", + }, + { + Key: "rd_drawings_3d", Title: "2D图纸和3D模型", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("upload_index"), + DocCode: "RD-DWG", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_product_bom", Title: "产品BOM", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("upload_index"), + DocCode: "RD-BOM", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_ee_bom", Title: "电子电气BOM", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("upload_index"), + DocCode: "RD-EE-BOM", Skippable: true, StageKey: "RD06_design_output", + }, + { + Key: "rd_pcba", Title: "PCBA原理图及Layout", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("upload_index"), + DocCode: "RD-PCBA", Skippable: true, StageKey: "RD06_design_output", + }, + { + Key: "rd_electrical_sys", Title: "系统电气设计图", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("upload_index"), + DocCode: "RD-ELEC", Skippable: true, StageKey: "RD06_design_output", + }, + { + Key: "rd_sw_arch", Title: "软件概要设计方案", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-SW-ARCH", Skippable: true, StageKey: "RD06_design_output", + }, + { + Key: "rd_sw_ui", Title: "软件UI设计文档", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-SW-UI", Skippable: true, StageKey: "RD06_design_output", + }, + { + Key: "rd_purchase_spec", Title: "采购技术规范", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-PUR", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_process_flow", Title: "工艺流程图及作业指导", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-PROC", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_packaging", Title: "包装图纸和设计要求", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-PACK", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_ifu", Title: "产品使用说明书", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-IFU", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_incoming_iqc", Title: "原材料进货检验规范", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-IQC", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_ipqc", Title: "生产过程检验规范", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-IPQC", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_fqc", Title: "成品检验规范", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-FQC", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_tech_req", Title: "产品技术要求", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-TECH", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_risk_report", Title: "风险管理报告", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-RISK-RPT", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_design_output_list", Title: "设计输出清单", Phase: "06", + PhaseTitle: "设计输出", Kind: RDevArtifactKind("document"), + DocCode: "RD-DO-LIST", Skippable: false, StageKey: "RD06_design_output", + }, + { + Key: "rd_output_review", Title: "设计开发评审记录(阶段6)", Phase: "07", + PhaseTitle: "输出评审", Kind: RDevArtifactKind("review_minutes"), + DocCode: "RD-OUT-REV", Skippable: false, StageKey: "RD07_output_review", + }, + { + Key: "rd_prototype_photos", Title: "样机照片", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("upload_index"), + DocCode: "RD-PHOTO", Skippable: false, StageKey: "RD08_verification", + }, + { + Key: "rd_design_vv", Title: "设计验证方案", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-VV", Skippable: false, StageKey: "RD08_verification", + }, + { + Key: "rd_shelf_life", Title: "货架寿命方案", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-SHELF", Skippable: false, StageKey: "RD08_verification", + }, + { + Key: "rd_lifetime", Title: "产品使用期限研究方案", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-LIFE", Skippable: false, StageKey: "RD08_verification", + }, + { + Key: "rd_biocompatibility", Title: "生物学检测方案", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-BIO", Skippable: false, StageKey: "RD08_verification", + }, + { + Key: "rd_cleaning", Title: "清洁消毒验证方案", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-CLEAN", Skippable: false, StageKey: "RD08_verification", + }, + { + Key: "rd_emc_safety", Title: "EMC及安规摸底报告", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("upload_index"), + DocCode: "RD-EMC", Skippable: true, StageKey: "RD08_verification", + }, + { + Key: "rd_risk_report_v", Title: "风险管理报告(验证阶段)", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-RISK-V", Skippable: false, StageKey: "RD08_verification", + }, + { + Key: "rd_sw_release", Title: "软件发布清单", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-SW-REL", Skippable: true, StageKey: "RD08_verification", + }, + { + Key: "rd_sw_unit_test", Title: "软件单元测试报告", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-SW-UT", Skippable: true, StageKey: "RD08_verification", + }, + { + Key: "rd_sw_sys_test", Title: "软件系统测试报告", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-SW-ST", Skippable: true, StageKey: "RD08_verification", + }, + { + Key: "rd_qualified_suppliers", Title: "合格供方名录", Phase: "08", + PhaseTitle: "验证", Kind: RDevArtifactKind("document"), + DocCode: "RD-SUP", Skippable: false, StageKey: "RD08_verification", + }, + { + Key: "rd_verification_review", Title: "设计开发评审记录(验证阶段)", Phase: "09", + PhaseTitle: "验证评审", Kind: RDevArtifactKind("review_minutes"), + DocCode: "RD-VER-REV", Skippable: false, StageKey: "RD09_verification_review", + }, + { + Key: "rd_dmr_list", Title: "DMR文件清单", Phase: "10", + PhaseTitle: "转换", Kind: RDevArtifactKind("document"), + DocCode: "RD-DMR-LIST", Skippable: false, StageKey: "RD10_transfer", + }, + { + Key: "rd_pack_ship", Title: "包装运输方案", Phase: "10", + PhaseTitle: "转换", Kind: RDevArtifactKind("document"), + DocCode: "RD-SHIP", Skippable: false, StageKey: "RD10_transfer", + }, + { + Key: "rd_risk_report_t", Title: "风险管理报告(转换阶段)", Phase: "10", + PhaseTitle: "转换", Kind: RDevArtifactKind("document"), + DocCode: "RD-RISK-T", Skippable: false, StageKey: "RD10_transfer", + }, + { + Key: "rd_transfer_review", Title: "设计开发评审记录(转换阶段)", Phase: "11", + PhaseTitle: "转换评审", Kind: RDevArtifactKind("review_minutes"), + DocCode: "RD-XFER-REV", Skippable: false, StageKey: "RD11_transfer_review", + }, + { + Key: "rd_validation_plan", Title: "设计确认方案", Phase: "12", + PhaseTitle: "确认", Kind: RDevArtifactKind("document"), + DocCode: "RD-VAL-PLAN", Skippable: false, StageKey: "RD12_validation", + }, + { + Key: "rd_use_error", Title: "使用错误评估报告", Phase: "12", + PhaseTitle: "确认", Kind: RDevArtifactKind("document"), + DocCode: "RD-USE-ERR", Skippable: false, StageKey: "RD12_validation", + }, + { + Key: "rd_animal_study", Title: "动物试验方案", Phase: "12", + PhaseTitle: "确认", Kind: RDevArtifactKind("document"), + DocCode: "RD-ANIMAL", Skippable: true, StageKey: "RD12_validation", + }, + { + Key: "rd_sw_quality", Title: "软件质量测试报告", Phase: "12", + PhaseTitle: "确认", Kind: RDevArtifactKind("document"), + DocCode: "RD-SW-QA", Skippable: true, StageKey: "RD12_validation", + }, + { + Key: "rd_clinical", Title: "临床试验报告", Phase: "12", + PhaseTitle: "确认", Kind: RDevArtifactKind("document"), + DocCode: "RD-CLIN", Skippable: true, StageKey: "RD12_validation", + }, + { + Key: "rd_risk_report_val", Title: "风险管理报告(确认阶段)", Phase: "12", + PhaseTitle: "确认", Kind: RDevArtifactKind("document"), + DocCode: "RD-RISK-VAL", Skippable: false, StageKey: "RD12_validation", + }, + { + Key: "rd_user_test", Title: "用户测试报告", Phase: "12", + PhaseTitle: "确认", Kind: RDevArtifactKind("document"), + DocCode: "RD-USER-T", Skippable: false, StageKey: "RD12_validation", + }, + { + Key: "rd_design_validation_report", Title: "设计确认报告", Phase: "13", + PhaseTitle: "设计确认报告", Kind: RDevArtifactKind("document"), + DocCode: "RD-VAL-RPT", Skippable: false, StageKey: "RD13_validation_report", + }, +} + +var rdevCatalogByKey map[string]RDevTaskMeta + +func init() { + rdevCatalogByKey = make(map[string]RDevTaskMeta, len(RDevCatalog)) + for _, t := range RDevCatalog { + rdevCatalogByKey[t.Key] = t + } +} + +// LookupRDevTask 按任务键查找 rdev 目录项。 +func LookupRDevTask(taskKey string) (RDevTaskMeta, bool) { + t, ok := rdevCatalogByKey[taskKey] + return t, ok +} + +// RDevNeedsRegulatory 是否注入法规专员步骤。 +func RDevNeedsRegulatory(taskKey string) bool { + switch taskKey { + case "rd_sw_plan", "rd_sw_reqs", "rd_sw_config", "rd_sw_arch", "rd_sw_ui", + "rd_sw_release", "rd_sw_unit_test", "rd_sw_sys_test", "rd_sw_quality", + "rd_regs_stds", "rd_adverse_events", "rd_biocompatibility", "rd_clinical", "rd_animal_study", + "rd_risk_plan", "rd_hazard_analysis", "rd_risk_report", "rd_risk_report_v", + "rd_risk_report_t", "rd_risk_report_val", "rd_emc_safety": + return true + default: + return false + } +} + +// rdevSupportedTasks 将目录转为 SupportedTasks 片段。 +func rdevSupportedTasks() []TaskMeta { + out := make([]TaskMeta, 0, len(RDevCatalog)) + for _, t := range RDevCatalog { + out = append(out, TaskMeta{ + Key: t.Key, Title: t.Title, ArtifactTitle: t.Title, + Description: "研发设计控制文档:" + t.Title, + }) + } + return out +} diff --git a/internal/squad/rdev_catalog_test.go b/internal/squad/rdev_catalog_test.go new file mode 100644 index 00000000..32745c7b --- /dev/null +++ b/internal/squad/rdev_catalog_test.go @@ -0,0 +1,53 @@ +package squad + +import "testing" + +func TestRDevCatalogSizeAndKinds(t *testing.T) { + if len(RDevCatalog) != 60 { + t.Fatalf("want 60 catalog entries, got %d", len(RDevCatalog)) + } + var doc, rev, up int + for _, e := range RDevCatalog { + switch e.Kind { + case RDevKindDocument: + doc++ + case RDevKindReviewMinutes: + rev++ + case RDevKindUploadIndex: + up++ + default: + t.Fatalf("unknown kind %q for %s", e.Kind, e.Key) + } + } + if doc != 47 || rev != 6 || up != 7 { + t.Fatalf("kind counts document=%d review=%d upload=%d", doc, rev, up) + } + if len(SupportedTasks) < 69 { + t.Fatalf("SupportedTasks should include base+rdev, got %d", len(SupportedTasks)) + } +} + +func TestRDevWorkflowStepsByKind(t *testing.T) { + docSteps := DefaultWorkflowSteps("rd_proposal") + if len(docSteps) < 4 { + t.Fatalf("document steps want >=4, got %d", len(docSteps)) + } + revSteps := DefaultWorkflowSteps("rd_planning_review") + if len(revSteps) != 3 { + t.Fatalf("review_minutes want 3 steps, got %d", len(revSteps)) + } + upSteps := DefaultWorkflowSteps("rd_prototype_photos") + if len(upSteps) != 4 { + t.Fatalf("upload_index want 4 steps, got %d", len(upSteps)) + } + agents := DefaultSquadAgents("rd_proposal") + foundLeader := false + for _, a := range agents { + if a.Role == "leader" && a.Name == "研发设计控制负责人" { + foundLeader = true + } + } + if !foundLeader { + t.Fatal("leader display name missing") + } +} diff --git a/internal/squad/rdev_leader_prompt_test.go b/internal/squad/rdev_leader_prompt_test.go new file mode 100644 index 00000000..a7e01cbb --- /dev/null +++ b/internal/squad/rdev_leader_prompt_test.go @@ -0,0 +1,73 @@ +package squad_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "chat-agent-system/internal/squad" +) + +func TestRDevLeaderPrompt_UsesDesignControlNotBusiness(t *testing.T) { + tmpDir := t.TempDir() + engine := squad.NewWorkspaceEngine() + + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: filepath.Join(tmpDir, "rd_design_input_spec"), + TaskKey: "rd_design_input_spec", + StageTitle: "设计输入规格书", + ProjectName: "rdev-s0-prompt", + Backend: "stub", + }) + if err != nil { + t.Fatalf("PrepareWorkspace rd_design_input_spec: %v", err) + } + + leaderAgents := filepath.Join(ws.WorkDir, "tasks", "04_review", "AGENTS.md") + body, err := os.ReadFile(leaderAgents) + if err != nil { + t.Fatalf("read leader AGENTS.md: %v", err) + } + text := string(body) + if !strings.Contains(text, "设计控制") { + t.Fatalf("rdev leader AGENTS.md must mention 设计控制; got snippet:\n%s", truncate(text, 400)) + } + for _, banned := range []string{"商务对接负责人", "CDO"} { + if strings.Contains(text, banned) { + t.Fatalf("rdev leader AGENTS.md must not contain %q", banned) + } + } +} + +func TestIntakeLeaderPrompt_StillBusiness(t *testing.T) { + tmpDir := t.TempDir() + engine := squad.NewWorkspaceEngine() + + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: filepath.Join(tmpDir, "intake"), + TaskKey: "intake", + StageTitle: "用户初始需求收集表", + ProjectName: "intake-prompt-regression", + Backend: "stub", + }) + if err != nil { + t.Fatalf("PrepareWorkspace intake: %v", err) + } + + leaderAgents := filepath.Join(ws.WorkDir, "tasks", "03_review", "AGENTS.md") + body, err := os.ReadFile(leaderAgents) + if err != nil { + t.Fatalf("read intake leader AGENTS.md: %v", err) + } + if !strings.Contains(string(body), "商务对接负责人") { + t.Fatalf("intake leader AGENTS.md must retain 商务对接负责人") + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/squad/rdev_steps.go b/internal/squad/rdev_steps.go new file mode 100644 index 00000000..fdf14beb --- /dev/null +++ b/internal/squad/rdev_steps.go @@ -0,0 +1,215 @@ +package squad + +const rdevDHFAccept = "模板写满;结论先行;追溯表必填;缺项标「待确认」;不编造数据/签字/附件" + +func rdevSquadAgents(taskKey string) []AgentSpec { + meta, _ := LookupRDevTask(taskKey) + agents := []AgentSpec{ + { + Role: "organizer", + Name: "材料整理专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"材料事实抽取", "适用性与缺口识别", "字段回填"}, + ForbiddenActions: []string{"严禁编造试验数据", "严禁伪造签字"}, + }, + } + switch meta.Kind { + case RDevKindReviewMinutes: + agents = append(agents, + AgentSpec{ + Role: "writer", + Name: "文档撰写专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"按评审记录模板成文", "问题-决议-行动项闭环"}, + }, + rdevLeaderAgent(), + ) + case RDevKindUploadIndex: + agents = append(agents, + AgentSpec{ + Role: "engineer", + Name: "技术专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"附件索引完整性", "缺失影响分析"}, + }, + AgentSpec{ + Role: "writer", + Name: "文档撰写专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"按附件清单模板成文", "标明待上传项"}, + }, + rdevLeaderAgent(), + ) + default: + agents = append(agents, + AgentSpec{ + Role: "engineer", + Name: "技术专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"技术内容与追溯", "设计控制论证"}, + }, + ) + if RDevNeedsRegulatory(taskKey) { + agents = append(agents, AgentSpec{ + Role: "regulatory", + Name: "法规专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"法规/标准适用性", "软件与风险管理合规要点"}, + }) + } + agents = append(agents, + AgentSpec{ + Role: "writer", + Name: "文档撰写专员", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"按 RD 模板成文", "结论先行与待确认清单"}, + }, + rdevLeaderAgent(), + ) + } + return agents +} + +func rdevLeaderAgent() AgentSpec { + return AgentSpec{ + Role: "leader", + Name: "研发设计控制负责人", + Model: "claude-3-7-sonnet", + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + InstructionsFile: "configs/agents/rdev_leader.md", + Responsibilities: []string{"DHF 完整性终审", "不适用理由审核", "禁止编造检查"}, + ForbiddenActions: []string{"严禁代签", "严禁伪造附件内容"}, + } +} + +func rdevWorkflowSteps(taskKey string) []StepSpec { + meta, ok := LookupRDevTask(taskKey) + kind := RDevKindDocument + title := taskKey + if ok { + kind = meta.Kind + title = meta.Title + } + + switch kind { + case RDevKindReviewMinutes: + return []StepSpec{ + { + ID: "step_01_organize", Role: "organizer", Title: "整理评审输入与议题", + SubWorkspace: "tasks/01_organize", Mode: "execute", + Accept: []string{"汇总上游批准文档要点", "列出待评审议题与缺口", rdevDHFAccept}, + }, + { + ID: "step_02_draft", Role: "writer", Title: "撰写评审记录", + SubWorkspace: "tasks/02_draft", Mode: "execute", + Needs: []string{"tasks/01_organize/output.md"}, + Accept: []string{"按模板写满评审记录", "问题-决议-行动项可追溯", rdevDHFAccept}, + }, + { + ID: "step_03_review", Role: "leader", Title: "研发设计控制终审", + SubWorkspace: "tasks/03_review", Mode: "execute", + Needs: []string{"tasks/02_draft/output.md"}, + Accept: []string{"完整性与粒度达标", "无编造签字/数据", rdevDHFAccept}, + }, + } + case RDevKindUploadIndex: + return []StepSpec{ + { + ID: "step_01_organize", Role: "organizer", Title: "整理附件材料线索", + SubWorkspace: "tasks/01_organize", Mode: "execute", + Accept: []string{"列出已知附件与缺失项", rdevDHFAccept}, + }, + { + ID: "step_02_engineer", Role: "engineer", Title: "完善附件索引与影响分析", + SubWorkspace: "tasks/02_engineer", Mode: "execute", + Needs: []string{"tasks/01_organize/output.md"}, + Accept: []string{"索引表完整", "缺失影响已说明", rdevDHFAccept}, + }, + { + ID: "step_03_draft", Role: "writer", Title: "撰写附件清单", + SubWorkspace: "tasks/03_draft", Mode: "execute", + Needs: []string{"tasks/02_engineer/output.md"}, + Accept: []string{"按 upload_index 模板成文", "禁止伪造附件正文", rdevDHFAccept}, + }, + { + ID: "step_04_review", Role: "leader", Title: "研发设计控制终审", + SubWorkspace: "tasks/04_review", Mode: "execute", + Needs: []string{"tasks/03_draft/output.md"}, + Accept: []string{"索引与待上传结论一致", rdevDHFAccept}, + }, + } + default: + return rdevDocumentSteps(taskKey, title) + } +} + +func rdevDocumentSteps(taskKey, title string) []StepSpec { + steps := []StepSpec{ + { + ID: "step_01_organize", Role: "organizer", Title: "抽取材料事实并回填", + SubWorkspace: "tasks/01_organize", Mode: "execute", + Accept: []string{"facts/gaps/implications 入黑板", "适用性字段回填", rdevDHFAccept}, + }, + { + ID: "step_02_engineer", Role: "engineer", Title: "技术内容与追溯编制", + SubWorkspace: "tasks/02_engineer", Mode: "execute", + Needs: []string{"tasks/01_organize/output.md"}, + Accept: []string{"技术论证完整", "追溯表可填则填", rdevDHFAccept}, + }, + } + draftNeeds := "tasks/02_engineer/output.md" + if RDevNeedsRegulatory(taskKey) { + steps = append(steps, StepSpec{ + ID: "step_03_regulatory", Role: "regulatory", Title: "法规与风险合规要点", + SubWorkspace: "tasks/03_regulatory", Mode: "execute", + Needs: []string{"tasks/02_engineer/output.md"}, + Accept: []string{"法规适用性明确", "风险类引用可追溯", rdevDHFAccept}, + }) + draftNeeds = "tasks/03_regulatory/output.md" + steps = append(steps, + StepSpec{ + ID: "step_04_draft", Role: "writer", Title: "撰写《" + title + "》", + SubWorkspace: "tasks/04_draft", Mode: "execute", + Needs: []string{draftNeeds}, + Accept: []string{"严格按 rd_ 模板成文", "结论先行", rdevDHFAccept}, + }, + StepSpec{ + ID: "step_05_review", Role: "leader", Title: "研发设计控制终审", + SubWorkspace: "tasks/05_review", Mode: "execute", + Needs: []string{"tasks/04_draft/output.md"}, + Accept: []string{"DHF 粒度达标", "无编造", rdevDHFAccept}, + }, + ) + return steps + } + steps = append(steps, + StepSpec{ + ID: "step_03_draft", Role: "writer", Title: "撰写《" + title + "》", + SubWorkspace: "tasks/03_draft", Mode: "execute", + Needs: []string{draftNeeds}, + Accept: []string{"严格按 rd_ 模板成文", "结论先行", rdevDHFAccept}, + }, + StepSpec{ + ID: "step_04_review", Role: "leader", Title: "研发设计控制终审", + SubWorkspace: "tasks/04_review", Mode: "execute", + Needs: []string{"tasks/03_draft/output.md"}, + Accept: []string{"DHF 粒度达标", "无编造", rdevDHFAccept}, + }, + ) + return steps +} diff --git a/internal/squad/service.go b/internal/squad/service.go index 1a250c05..a90f42a6 100644 --- a/internal/squad/service.go +++ b/internal/squad/service.go @@ -125,7 +125,7 @@ func NewService(cfg *config.Config) *Service { } } -var SupportedTasks = []TaskMeta{ +var baseSupportedTasks = []TaskMeta{ {Key: "intake", Title: "用户初始需求收集", ArtifactTitle: "用户初始需求收集表", Description: "梳理客户原始需求、提炼事实清单与缺口"}, {Key: "research", Title: "调研", ArtifactTitle: "调研报告", Description: "市场竞品同维度对比、法规要点与技术方案初筛"}, {Key: "feasibility_dev", Title: "设计开发可行性评估", ArtifactTitle: "设计开发可行性评估报告", Description: "工程化差距、技术路线与系统架构可行性推导"}, @@ -137,6 +137,13 @@ var SupportedTasks = []TaskMeta{ {Key: "cdo_quote", Title: "CDO报价单", ArtifactTitle: "CDO报价单(含定价核算 Excel)", Description: "基于公司定价模型的工时拆解、成本核算与法定说明"}, } +// SupportedTasks 标准小队任务白名单(商务九键 + rdev 六十键)。 +var SupportedTasks []TaskMeta + +func init() { + SupportedTasks = append(append([]TaskMeta{}, baseSupportedTasks...), rdevSupportedTasks()...) +} + func (s *Service) ListTasks() []TaskMeta { return SupportedTasks } diff --git a/internal/squad/workspace.go b/internal/squad/workspace.go index fa3a9a60..37792803 100644 --- a/internal/squad/workspace.go +++ b/internal/squad/workspace.go @@ -468,13 +468,20 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq return err } - // 1. 生成 AGENTS.md + // 1. 生成 AGENTS.md:优先 AgentSpec.InstructionsFile,再按角色名回落,再 inline 渲染 var agentsBuilder strings.Builder - inst, err := template.GetAgentInstruction(step.Role) - if err == nil { + spec := agentMap[step.Role] + var inst string + var err error + if file := strings.TrimSpace(spec.InstructionsFile); file != "" { + inst, err = template.GetAgentInstructionFile(file) + } + if err != nil || strings.TrimSpace(inst) == "" { + inst, err = template.GetAgentInstruction(step.Role) + } + if err == nil && strings.TrimSpace(inst) != "" { agentsBuilder.WriteString(inst) } else { - spec := agentMap[step.Role] agentsBuilder.WriteString(fmt.Sprintf("# 角色: %s (%s)\n\n", spec.Name, spec.Role)) if len(spec.Responsibilities) > 0 { agentsBuilder.WriteString("## 核心职责\n") @@ -589,6 +596,9 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq // DefaultSquadAgents 根据任务类型返回默认小队成员编制 func DefaultSquadAgents(taskKey string) []AgentSpec { + if strings.HasPrefix(taskKey, "rd_") { + return rdevSquadAgents(taskKey) + } switch taskKey { case "intake": return []AgentSpec{ @@ -796,6 +806,9 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { // DefaultWorkflowSteps 根据任务类型返回最佳实践编排步骤 func DefaultWorkflowSteps(taskKey string) []StepSpec { + if strings.HasPrefix(taskKey, "rd_") { + return rdevWorkflowSteps(taskKey) + } switch taskKey { case "intake": return []StepSpec{ diff --git a/internal/storage/cas.go b/internal/storage/cas.go index 259a7e7d..106d543f 100644 --- a/internal/storage/cas.go +++ b/internal/storage/cas.go @@ -447,6 +447,9 @@ func (c *CAS) searchMemoryDB(ctx context.Context, opts MemorySearchOptions) ([]* FinalScore: finalScore, }) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan cas chunks failed: %w", err) + } // 按 FinalScore 降序排列 sortChunkHits(hits) diff --git a/internal/storage/cas_assets.go b/internal/storage/cas_assets.go index d08a1ee4..01f1b3d4 100644 --- a/internal/storage/cas_assets.go +++ b/internal/storage/cas_assets.go @@ -31,7 +31,7 @@ func (c *CAS) ListProjectRefs(ctx context.Context, projectID int64) ([]*FileRefe } refs = append(refs, ref) } - return refs, nil + return refs, rows.Err() } c.mu.RLock() diff --git a/internal/swboot/catalog.go b/internal/swboot/catalog.go index 508fc3d0..7f88fd6b 100644 --- a/internal/swboot/catalog.go +++ b/internal/swboot/catalog.go @@ -89,6 +89,10 @@ type CreationPreviewResult struct { } // ProjectTypesCatalog returns seeded software project types. +// Wave 5 X-3: goose 00048 tables project_types / project_type_workflows exist with +// seed rows, but HTTP catalog still serves this hard-coded list. DB-backed +// consumption is deferred to SWBOOT-10 / W5b. See +// .doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md#x-3. func ProjectTypesCatalog() []ProjectTypeCatalogItem { return []ProjectTypeCatalogItem{ { @@ -115,6 +119,9 @@ func ProjectTypesCatalog() []ProjectTypeCatalogItem { } // ComplianceProfilesCatalog returns seeded compliance profiles. +// Wave 5 X-3: goose 00048 table compliance_profiles is seeded but unused at +// runtime; this hard-coded catalog remains authoritative until SWBOOT-10 / W5b. +// See .doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md#x-3. func ComplianceProfilesCatalog() []ComplianceProfileCatalogItem { return []ComplianceProfileCatalogItem{ {Key: "none", Name: "无外部合规预设", Description: "不预设外部法规步骤;鉴权/审计/安全基线仍生效"}, diff --git a/internal/swboot/repo_allowlist.go b/internal/swboot/repo_allowlist.go index 998a6821..57c40d4b 100644 --- a/internal/swboot/repo_allowlist.go +++ b/internal/swboot/repo_allowlist.go @@ -15,6 +15,10 @@ var ( ) // RepoAllowlist is an in-memory / admin-seeded path prefix allowlist stub. +// Wave 5 X-3: goose 00048 table repo_path_allowlist (and project_source_bindings) +// have zero runtime readers; this type is unit-test-only until SWBOOT-10 wires +// DB-backed allowlist + real controlled patch execution. See +// .doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md#x-3. type RepoAllowlist struct { prefixes []string } diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index cd541ddd..aea369ae 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -37,7 +37,6 @@ var featureCatalog = []featureSpec{ {key: "INTENT_V2_DECISION_TIER", label: "意图 V2 决策档位", group: "intent", fallback: "none", description: "V2 灰度风险档位:none/query/generate/write_low/write_high/all"}, {key: "WORKFLOW_INTENTS_ENABLED", label: "工作流意图", group: "intent", fallback: "true", description: "识别工作流类意图"}, {key: "MULTI_INTENT_ENABLED", label: "多意图识别", group: "intent", fallback: "true", description: "单条消息多意图拆解"}, - {key: "SEMANTIC_FALLBACK_ENABLED", label: "语义兜底", group: "intent", fallback: "false", description: "规则意图未命中时的语义兜底"}, {key: "MULTI_INTENT_PLAN_TIER", label: "多意图计划档位", group: "intentflow", fallback: "legacy", description: "意图计划灰度:legacy/shadow/auto_read/confirm/full"}, {key: "MULTI_INTENT_U9_SEGMENT", label: "U9 LLM 分段", group: "intentflow", fallback: "false", description: "LLM 意图分段提议"}, {key: "MULTI_INTENT_U10_MUTATE", label: "U10 变更适配器", group: "intentflow", fallback: "false", description: "低/中风险 mutate 动作适配器"}, diff --git a/internal/sysadmin/policy.go b/internal/sysadmin/policy.go new file mode 100644 index 00000000..9a541e4a --- /dev/null +++ b/internal/sysadmin/policy.go @@ -0,0 +1,96 @@ +package sysadmin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// SettingKeyAllowAuthorOnlyApproval 系统级「允许编制人兼任审核、批准」总授权。 +// 默认关闭;无环境变量绕过;仅系统管理员可写。 +const SettingKeyAllowAuthorOnlyApproval = "workflow.allow_author_only_approval" + +// AuthorOnlyApprovalPolicy 供 projectinstance 消费的窄策略快照。 +type AuthorOnlyApprovalPolicy struct { + Key string `json:"key"` + Allowed bool `json:"allowed"` + Revision int64 `json:"revision"` +} + +// AuthorOnlyPolicySource 组合根注入给 projectinstance 的只读/事务锁接口。 +// sysadmin 是唯一写入 owner;调用方不得自行写 system_settings。 +type AuthorOnlyPolicySource interface { + GetAuthorOnlyApprovalPolicy(ctx context.Context) (AuthorOnlyApprovalPolicy, error) + LockAuthorOnlyApprovalPolicy(ctx context.Context, tx pgx.Tx) (AuthorOnlyApprovalPolicy, error) +} + +// GetAuthorOnlyApprovalPolicy 读取当前总授权。缺行按默认关闭(revision=0); +// 存储不可用或查询失败返回错误,不得伪装成默认关闭。 +func (s *Service) GetAuthorOnlyApprovalPolicy(ctx context.Context) (AuthorOnlyApprovalPolicy, error) { + out := AuthorOnlyApprovalPolicy{ + Key: SettingKeyAllowAuthorOnlyApproval, + Allowed: false, + Revision: 0, + } + if s.store == nil { + return out, ErrStoreUnavailable + } + raw, _, _, revision, err := s.store.Get(ctx, SettingKeyAllowAuthorOnlyApproval) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return out, nil + } + return out, err + } + allowed, err := decodeStrictBool(raw) + if err != nil { + return out, fmt.Errorf("sysadmin: decode %s: %w", SettingKeyAllowAuthorOnlyApproval, err) + } + out.Allowed = allowed + out.Revision = revision + return out, nil +} + +// LockAuthorOnlyApprovalPolicy 在调用方事务内锁定策略行,供激活与关闭串行化。 +func (s *Service) LockAuthorOnlyApprovalPolicy(ctx context.Context, tx pgx.Tx) (AuthorOnlyApprovalPolicy, error) { + out := AuthorOnlyApprovalPolicy{ + Key: SettingKeyAllowAuthorOnlyApproval, + Allowed: false, + Revision: 0, + } + if s.store == nil { + return out, ErrStoreUnavailable + } + raw, _, _, revision, err := s.store.GetForUpdate(ctx, tx, SettingKeyAllowAuthorOnlyApproval) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + // 迁移应已种子化;缺行按关闭处理但不伪装 DB 故障。 + return out, nil + } + return out, err + } + allowed, err := decodeStrictBool(raw) + if err != nil { + return out, fmt.Errorf("sysadmin: decode %s: %w", SettingKeyAllowAuthorOnlyApproval, err) + } + out.Allowed = allowed + out.Revision = revision + return out, nil +} + +func decodeStrictBool(raw json.RawMessage) (bool, error) { + if len(raw) == 0 { + return false, ErrInvalidSettingValue + } + var v bool + if err := json.Unmarshal(raw, &v); err != nil { + return false, ErrInvalidSettingValue + } + return v, nil +} + +// Ensure AuthorOnlyPolicySource 由 Service 实现。 +var _ AuthorOnlyPolicySource = (*Service)(nil) diff --git a/internal/sysadmin/policy_test.go b/internal/sysadmin/policy_test.go new file mode 100644 index 00000000..bd6debff --- /dev/null +++ b/internal/sysadmin/policy_test.go @@ -0,0 +1,176 @@ +package sysadmin + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" +) + +func seedAuthorOnlyPolicy(m *memStore, allowed bool, revision int64) { + raw := json.RawMessage(`false`) + if allowed { + raw = json.RawMessage(`true`) + } + uid := int64(0) + m.mu.Lock() + defer m.mu.Unlock() + m.items[SettingKeyAllowAuthorOnlyApproval] = memEntry{ + value: append(json.RawMessage(nil), raw...), + updatedBy: &uid, + updatedAt: m.items[SettingKeyAllowAuthorOnlyApproval].updatedAt, + revision: revision, + } +} + +func TestAuthorOnlyPolicy_DefaultClosedWhenMissing(t *testing.T) { + svc := NewService(nil, Options{Store: newMemStore()}) + pol, err := svc.GetAuthorOnlyApprovalPolicy(context.Background()) + if err != nil { + t.Fatalf("GetAuthorOnlyApprovalPolicy: %v", err) + } + if pol.Allowed || pol.Revision != 0 || pol.Key != SettingKeyAllowAuthorOnlyApproval { + t.Fatalf("expected closed default, got %+v", pol) + } +} + +func TestAuthorOnlyPolicy_StoreUnavailable(t *testing.T) { + svc := NewService(nil, Options{}) + _, err := svc.GetAuthorOnlyApprovalPolicy(context.Background()) + if !errors.Is(err, ErrStoreUnavailable) { + t.Fatalf("expected ErrStoreUnavailable, got %v", err) + } +} + +func TestUpdateAuthorOnly_RequiresExpectedRevision(t *testing.T) { + store := newMemStore() + seedAuthorOnlyPolicy(store, false, 0) + svc := NewService(nil, Options{Store: store}) + _, err := svc.UpdateSetting(context.Background(), SettingKeyAllowAuthorOnlyApproval, json.RawMessage(`true`), 1) + if !errors.Is(err, ErrExpectedRevisionRequired) { + t.Fatalf("expected ErrExpectedRevisionRequired, got %v", err) + } +} + +func TestUpdateAuthorOnly_RejectsNullAndStringBool(t *testing.T) { + store := newMemStore() + seedAuthorOnlyPolicy(store, false, 0) + svc := NewService(nil, Options{Store: store}) + zero := int64(0) + for _, raw := range []string{`null`, `"true"`, `1`, `yes`} { + _, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{ + Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(raw), + UpdatedBy: 1, ExpectedRevision: &zero, + }) + if !errors.Is(err, ErrInvalidSettingValue) { + t.Fatalf("%s: expected ErrInvalidSettingValue, got %v", raw, err) + } + } +} + +func TestUpdateAuthorOnly_CASSuccessAndConflict(t *testing.T) { + store := newMemStore() + seedAuthorOnlyPolicy(store, false, 0) + svc := NewService(nil, Options{Store: store}) + zero := int64(0) + setting, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{ + Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`true`), + UpdatedBy: 9, ExpectedRevision: &zero, RequestID: "req-1", + }) + if err != nil { + t.Fatalf("first CAS: %v", err) + } + if setting.Value != true || setting.Revision != 1 { + t.Fatalf("expected true@rev1, got value=%v rev=%d", setting.Value, setting.Revision) + } + if len(store.audits) != 1 || store.audits[0].afterRev != 1 || store.audits[0].requestID != "req-1" { + t.Fatalf("expected one audit with request id, got %+v", store.audits) + } + + pol, err := svc.GetAuthorOnlyApprovalPolicy(context.Background()) + if err != nil || !pol.Allowed || pol.Revision != 1 { + t.Fatalf("policy after enable: %+v err=%v", pol, err) + } + + _, err = svc.UpdateSettingEx(context.Background(), SettingUpdate{ + Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`false`), + UpdatedBy: 9, ExpectedRevision: &zero, + }) + if !errors.Is(err, ErrSettingRevisionConflict) { + t.Fatalf("expected conflict on stale revision, got %v", err) + } + + one := int64(1) + setting, err = svc.UpdateSettingEx(context.Background(), SettingUpdate{ + Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`false`), + UpdatedBy: 10, ExpectedRevision: &one, + }) + if err != nil { + t.Fatalf("second CAS: %v", err) + } + if setting.Value != false || setting.Revision != 2 { + t.Fatalf("expected false@rev2, got value=%v rev=%d", setting.Value, setting.Revision) + } +} + +func TestUpdateAuthorOnly_AuditFailureRollsBack(t *testing.T) { + store := newMemStore() + seedAuthorOnlyPolicy(store, false, 0) + store.failAudit = true + svc := NewService(nil, Options{Store: store}) + zero := int64(0) + _, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{ + Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`true`), + UpdatedBy: 1, ExpectedRevision: &zero, + }) + if err == nil { + t.Fatal("expected audit failure") + } + pol, err := svc.GetAuthorOnlyApprovalPolicy(context.Background()) + if err != nil || pol.Allowed || pol.Revision != 0 { + t.Fatalf("setting must remain closed after audit failure: %+v err=%v", pol, err) + } +} + +func TestUpdateAuthorOnly_ConcurrentCASOnlyOneWins(t *testing.T) { + store := newMemStore() + seedAuthorOnlyPolicy(store, false, 0) + svc := NewService(nil, Options{Store: store}) + zero := int64(0) + + var wg sync.WaitGroup + results := make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := svc.UpdateSettingEx(context.Background(), SettingUpdate{ + Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`true`), + UpdatedBy: 1, ExpectedRevision: &zero, + }) + results <- err + }() + } + wg.Wait() + close(results) + + var okCount, conflictCount int + for err := range results { + switch { + case err == nil: + okCount++ + case errors.Is(err, ErrSettingRevisionConflict): + conflictCount++ + default: + t.Fatalf("unexpected error: %v", err) + } + } + if okCount != 1 || conflictCount != 1 { + t.Fatalf("expected 1 success + 1 conflict, got ok=%d conflict=%d", okCount, conflictCount) + } + pol, err := svc.GetAuthorOnlyApprovalPolicy(context.Background()) + if err != nil || !pol.Allowed || pol.Revision != 1 { + t.Fatalf("expected single winner true@1, got %+v err=%v", pol, err) + } +} diff --git a/internal/sysadmin/settings.go b/internal/sysadmin/settings.go index cd22e093..89f6a2c5 100644 --- a/internal/sysadmin/settings.go +++ b/internal/sysadmin/settings.go @@ -1,6 +1,7 @@ package sysadmin import ( + "bytes" "context" "encoding/json" "errors" @@ -12,10 +13,11 @@ import ( // 设置读写错误;HTTP 适配层据此映射状态码。 var ( - ErrUnknownSetting = errors.New("sysadmin: unknown setting key") - ErrSettingReadOnly = errors.New("sysadmin: setting is read-only") - ErrInvalidSettingValue = errors.New("sysadmin: invalid setting value") - ErrStoreUnavailable = errors.New("sysadmin: settings store unavailable") + ErrUnknownSetting = errors.New("sysadmin: unknown setting key") + ErrSettingReadOnly = errors.New("sysadmin: setting is read-only") + ErrInvalidSettingValue = errors.New("sysadmin: invalid setting value") + ErrStoreUnavailable = errors.New("sysadmin: settings store unavailable") + ErrExpectedRevisionRequired = errors.New("sysadmin: expected_revision required") ) // 设置值类型。 @@ -49,11 +51,32 @@ type Setting struct { Writable bool `json:"writable"` RequiresRestart bool `json:"requiresRestart"` Description string `json:"description"` + Revision int64 `json:"revision"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` UpdatedBy *int64 `json:"updatedBy,omitempty"` } +// SettingUpdate 写入入参;仅版本化策略 key 要求 ExpectedRevision。 +type SettingUpdate struct { + Key string + Value json.RawMessage + UpdatedBy int64 + ExpectedRevision *int64 + RequestID string +} + // defaultSettingSpecs 一期白名单:先做机制,键空间可后续扩展。 +// +// Wave 5 X-4: 除 SettingKeyAllowAuthorOnlyApproval 已接线外,下列 6 键暂无运行时 +// 消费方;决策 = 保留白名单待系统管理台二期接线(FEATURE-MATRIX「白名单项接消费方」), +// 不从白名单删除。见 .doc/ops/short-term/2026-09-18-wave5-deadcode-decisions.md#x-4。 +// 预期消费方(二期): +// - ui.maintenance_notice → 站点顶部维护公告横幅 +// - ui.read_only_mode → 写操作中间件 / 前端写入口阻断 +// - chat.default_session_role → 会话创建默认职责 +// - tasks.default_auto_repair → 独立任务运行默认 auto_repair +// - kb.default_search_limit → 知识库检索默认 limit +// - notifications.stage_advance_reminder → 阶段就绪提醒 ticker 总开关 func defaultSettingSpecs() []SettingSpec { return []SettingSpec{ {Key: "ui.maintenance_notice", Label: "维护公告", Group: "ui", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, Description: "全站维护公告横幅文案;空字符串表示关闭"}, @@ -62,6 +85,11 @@ func defaultSettingSpecs() []SettingSpec { {Key: "tasks.default_auto_repair", Label: "任务默认自动修复", Group: "tasks", Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, Description: "独立任务运行未传 auto_repair 时的默认值"}, {Key: "kb.default_search_limit", Label: "KB 检索默认条数", Group: "kb", Type: SettingTypeNumber, Default: 8.0, Writable: true, RequiresRestart: false, Description: "知识库检索未指定 limit 时的默认返回条数"}, {Key: "notifications.stage_advance_reminder", Label: "阶段推进提醒总开关", Group: "notification", Type: SettingTypeBool, Default: true, Writable: true, RequiresRestart: false, Description: "阶段就绪 24h 提醒的总开关(一期先落机制)"}, + { + Key: SettingKeyAllowAuthorOnlyApproval, Label: "允许编制人兼任审核批准", Group: "workflow", + Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, + Description: "授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。", + }, } } @@ -88,22 +116,37 @@ func (s *Service) ListSettings(ctx context.Context) ([]Setting, error) { } // UpdateSetting 校验并写入白名单设置,返回更新后的视图。 +// 兼容旧调用:非版本化 key 可走此入口;版本化策略 key 必须带 ExpectedRevision,请用 UpdateSettingEx。 func (s *Service) UpdateSetting(ctx context.Context, key string, raw json.RawMessage, updatedBy int64) (Setting, error) { - spec, ok := s.findSpec(key) + return s.UpdateSettingEx(ctx, SettingUpdate{Key: key, Value: raw, UpdatedBy: updatedBy}) +} + +// UpdateSettingEx 校验并写入白名单设置;对 SettingKeyAllowAuthorOnlyApproval 强制 CAS + 同事务审计。 +func (s *Service) UpdateSettingEx(ctx context.Context, in SettingUpdate) (Setting, error) { + spec, ok := s.findSpec(in.Key) if !ok { return Setting{}, ErrUnknownSetting } if !spec.Writable { return Setting{}, ErrSettingReadOnly } - if err := validateSettingValue(spec.Type, raw); err != nil { + if err := validateSettingValue(spec.Type, in.Value); err != nil { return Setting{}, err } if s.store == nil { return Setting{}, ErrStoreUnavailable } - if _, err := s.store.Upsert(ctx, key, raw, updatedBy); err != nil { - return Setting{}, err + if in.Key == SettingKeyAllowAuthorOnlyApproval { + if in.ExpectedRevision == nil { + return Setting{}, ErrExpectedRevisionRequired + } + if _, _, err := s.store.UpsertCAS(ctx, in.Key, in.Value, in.UpdatedBy, *in.ExpectedRevision, in.RequestID); err != nil { + return Setting{}, err + } + } else { + if _, _, err := s.store.Upsert(ctx, in.Key, in.Value, in.UpdatedBy); err != nil { + return Setting{}, err + } } return s.resolve(ctx, spec) } @@ -131,7 +174,7 @@ func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error if s.store == nil { return setting, nil } - raw, updatedBy, updatedAt, err := s.store.Get(ctx, spec.Key) + raw, updatedBy, updatedAt, revision, err := s.store.Get(ctx, spec.Key) if err != nil { if errors.Is(err, ErrSettingNotFound) { return setting, nil @@ -143,6 +186,7 @@ func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error return Setting{}, fmt.Errorf("sysadmin: decode stored value for %s: %w", spec.Key, err) } setting.Value = value + setting.Revision = revision setting.UpdatedBy = updatedBy setting.UpdatedAt = updatedAt return setting, nil @@ -153,6 +197,10 @@ func validateSettingValue(valueType string, raw json.RawMessage) error { if len(raw) == 0 { return ErrInvalidSettingValue } + trimmed := bytes.TrimSpace(raw) + if string(trimmed) == "null" { + return ErrInvalidSettingValue + } switch valueType { case SettingTypeString: var v string @@ -161,7 +209,13 @@ func validateSettingValue(valueType string, raw json.RawMessage) error { } case SettingTypeBool: var v bool - if err := json.Unmarshal(raw, &v); err != nil { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&v); err != nil { + return ErrInvalidSettingValue + } + // 拒绝尾随垃圾(如 truefalse) + if dec.More() { return ErrInvalidSettingValue } case SettingTypeNumber: diff --git a/internal/sysadmin/settings_test.go b/internal/sysadmin/settings_test.go index e26a6a95..aa0265ee 100644 --- a/internal/sysadmin/settings_test.go +++ b/internal/sysadmin/settings_test.go @@ -7,41 +7,89 @@ import ( "sync" "testing" "time" + + "github.com/jackc/pgx/v5" ) // memStore 进程内 SettingsStore,用于读优先级与写路径测试。 type memStore struct { - mu sync.Mutex - items map[string]memEntry + mu sync.Mutex + items map[string]memEntry + audits []memAudit + failAudit bool } type memEntry struct { value json.RawMessage updatedBy *int64 updatedAt time.Time + revision int64 +} + +type memAudit struct { + key string + before, after json.RawMessage + beforeRev, afterRev int64 + actor int64 + requestID string } func newMemStore() *memStore { return &memStore{items: map[string]memEntry{}} } -func (m *memStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, error) { +func (m *memStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, int64, error) { m.mu.Lock() defer m.mu.Unlock() entry, ok := m.items[key] if !ok { - return nil, nil, nil, ErrSettingNotFound + return nil, nil, nil, 0, ErrSettingNotFound } at := entry.updatedAt - return entry.value, entry.updatedBy, &at, nil + return append(json.RawMessage(nil), entry.value...), entry.updatedBy, &at, entry.revision, nil } -func (m *memStore) Upsert(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error) { +func (m *memStore) Upsert(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) { m.mu.Lock() defer m.mu.Unlock() at := time.Now() - m.items[key] = memEntry{value: append(json.RawMessage(nil), value...), updatedBy: &updatedBy, updatedAt: at} - return at, nil + prev := m.items[key] + m.items[key] = memEntry{ + value: append(json.RawMessage(nil), value...), + updatedBy: &updatedBy, + updatedAt: at, + revision: prev.revision, + } + return at, prev.revision, nil +} + +func (m *memStore) UpsertCAS(_ context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (time.Time, int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + entry, ok := m.items[key] + if !ok { + return time.Time{}, 0, ErrSettingNotFound + } + if entry.revision != expectedRevision { + return time.Time{}, 0, ErrSettingRevisionConflict + } + if m.failAudit { + return time.Time{}, 0, errors.New("sysadmin: audit write failed") + } + at := time.Now() + newRev := entry.revision + 1 + before := append(json.RawMessage(nil), entry.value...) + after := append(json.RawMessage(nil), value...) + m.items[key] = memEntry{value: after, updatedBy: &updatedBy, updatedAt: at, revision: newRev} + m.audits = append(m.audits, memAudit{ + key: key, before: before, after: after, + beforeRev: entry.revision, afterRev: newRev, actor: updatedBy, requestID: requestID, + }) + return at, newRev, nil +} + +func (m *memStore) GetForUpdate(ctx context.Context, _ pgx.Tx, key string) (json.RawMessage, *int64, *time.Time, int64, error) { + return m.Get(ctx, key) } func findSetting(t *testing.T, settings []Setting, key string) Setting { @@ -79,7 +127,7 @@ func TestListSettings_FallsBackToDefaultWithoutStore(t *testing.T) { func TestListSettings_DBOverridesDefault(t *testing.T) { store := newMemStore() - if _, err := store.Upsert(context.Background(), "ui.maintenance_notice", json.RawMessage(`"停机维护中"`), 7); err != nil { + if _, _, err := store.Upsert(context.Background(), "ui.maintenance_notice", json.RawMessage(`"停机维护中"`), 7); err != nil { t.Fatalf("seed store: %v", err) } svc := NewService(nil, Options{Store: store}) @@ -120,7 +168,7 @@ func TestListSettings_EnvLayerBetweenDBAndDefault(t *testing.T) { // DB 优先于 env store := newMemStore() - if _, err := store.Upsert(context.Background(), "test.env_flag", json.RawMessage(`false`), 1); err != nil { + if _, _, err := store.Upsert(context.Background(), "test.env_flag", json.RawMessage(`false`), 1); err != nil { t.Fatalf("seed store: %v", err) } svc = &Service{specs: specs, store: store} diff --git a/internal/sysadmin/store.go b/internal/sysadmin/store.go index 9d30f6ce..475a0e33 100644 --- a/internal/sysadmin/store.go +++ b/internal/sysadmin/store.go @@ -13,10 +13,17 @@ import ( // ErrSettingNotFound DB 中无该 key 的行(回退 env/default)。 var ErrSettingNotFound = errors.New("sysadmin: setting not found") +// ErrSettingRevisionConflict CAS 期望 revision 与当前不一致。 +var ErrSettingRevisionConflict = errors.New("sysadmin: setting revision conflict") + // SettingsStore system_settings 表读写(表 owner 为 sysadmin)。 type SettingsStore interface { - Get(ctx context.Context, key string) (value json.RawMessage, updatedBy *int64, updatedAt *time.Time, err error) - Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error) + Get(ctx context.Context, key string) (value json.RawMessage, updatedBy *int64, updatedAt *time.Time, revision int64, err error) + Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (updatedAt time.Time, revision int64, err error) + // UpsertCAS 在同一事务内校验 expectedRevision、写入新值并追加策略审计;冲突返回 ErrSettingRevisionConflict。 + UpsertCAS(ctx context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (updatedAt time.Time, revision int64, err error) + // GetForUpdate 在调用方事务内锁定目标行(FOR UPDATE),供激活路径与关闭串行化。 + GetForUpdate(ctx context.Context, tx pgx.Tx, key string) (value json.RawMessage, updatedBy *int64, updatedAt *time.Time, revision int64, err error) } type pgSettingsStore struct { @@ -27,30 +34,108 @@ func newPGSettingsStore(pool *pgxpool.Pool) SettingsStore { return &pgSettingsStore{pool: pool} } -func (s *pgSettingsStore) Get(ctx context.Context, key string) (json.RawMessage, *int64, *time.Time, error) { +func (s *pgSettingsStore) Get(ctx context.Context, key string) (json.RawMessage, *int64, *time.Time, int64, error) { var raw []byte var updatedBy *int64 var updatedAt time.Time + var revision int64 err := s.pool.QueryRow(ctx, ` - SELECT value, updated_by, updated_at FROM system_settings WHERE key = $1 - `, key).Scan(&raw, &updatedBy, &updatedAt) + SELECT value, updated_by, updated_at, revision FROM system_settings WHERE key = $1 + `, key).Scan(&raw, &updatedBy, &updatedAt, &revision) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return nil, nil, nil, ErrSettingNotFound + return nil, nil, nil, 0, ErrSettingNotFound } - return nil, nil, nil, err + return nil, nil, nil, 0, err } - return json.RawMessage(raw), updatedBy, &updatedAt, nil + return json.RawMessage(raw), updatedBy, &updatedAt, revision, nil } -func (s *pgSettingsStore) Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error) { +func (s *pgSettingsStore) Upsert(ctx context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) { var updatedAt time.Time + var revision int64 err := s.pool.QueryRow(ctx, ` - INSERT INTO system_settings (key, value, updated_by, updated_at) - VALUES ($1, $2, $3, now()) + INSERT INTO system_settings (key, value, updated_by, updated_at, revision) + VALUES ($1, $2, $3, now(), 0) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_by = EXCLUDED.updated_by, updated_at = now() - RETURNING updated_at - `, key, []byte(value), updatedBy).Scan(&updatedAt) - return updatedAt, err + RETURNING updated_at, revision + `, key, []byte(value), updatedBy).Scan(&updatedAt, &revision) + return updatedAt, revision, err +} + +func (s *pgSettingsStore) UpsertCAS(ctx context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (time.Time, int64, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return time.Time{}, 0, err + } + defer tx.Rollback(ctx) + + var beforeRaw []byte + var beforeRev int64 + err = tx.QueryRow(ctx, ` + SELECT value, revision FROM system_settings WHERE key = $1 FOR UPDATE + `, key).Scan(&beforeRaw, &beforeRev) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return time.Time{}, 0, ErrSettingNotFound + } + return time.Time{}, 0, err + } + if beforeRev != expectedRevision { + return time.Time{}, 0, ErrSettingRevisionConflict + } + + var updatedAt time.Time + var newRev int64 + err = tx.QueryRow(ctx, ` + UPDATE system_settings + SET value = $2, updated_by = $3, updated_at = now(), revision = revision + 1 + WHERE key = $1 AND revision = $4 + RETURNING updated_at, revision + `, key, []byte(value), updatedBy, expectedRevision).Scan(&updatedAt, &newRev) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return time.Time{}, 0, ErrSettingRevisionConflict + } + return time.Time{}, 0, err + } + + var reqID any + if requestID != "" { + reqID = requestID + } + _, err = tx.Exec(ctx, ` + INSERT INTO system_setting_policy_audits ( + setting_key, before_value, after_value, before_revision, after_revision, actor_user_id, request_id + ) VALUES ($1, $2::jsonb, $3::jsonb, $4, $5, $6, $7) + `, key, beforeRaw, []byte(value), beforeRev, newRev, updatedBy, reqID) + if err != nil { + return time.Time{}, 0, err + } + + if err := tx.Commit(ctx); err != nil { + return time.Time{}, 0, err + } + return updatedAt, newRev, nil +} + +func (s *pgSettingsStore) GetForUpdate(ctx context.Context, tx pgx.Tx, key string) (json.RawMessage, *int64, *time.Time, int64, error) { + if tx == nil { + return nil, nil, nil, 0, errors.New("sysadmin: GetForUpdate requires transaction") + } + var raw []byte + var updatedBy *int64 + var updatedAt time.Time + var revision int64 + err := tx.QueryRow(ctx, ` + SELECT value, updated_by, updated_at, revision FROM system_settings WHERE key = $1 FOR UPDATE + `, key).Scan(&raw, &updatedBy, &updatedAt, &revision) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, nil, 0, ErrSettingNotFound + } + return nil, nil, nil, 0, err + } + return json.RawMessage(raw), updatedBy, &updatedAt, revision, nil } diff --git a/internal/sysadmin/store_pg_test.go b/internal/sysadmin/store_pg_test.go index e4c02df5..c37b4ae7 100644 --- a/internal/sysadmin/store_pg_test.go +++ b/internal/sysadmin/store_pg_test.go @@ -3,6 +3,7 @@ package sysadmin import ( "context" "encoding/json" + "errors" "testing" "chat-agent-system/internal/db" @@ -25,13 +26,13 @@ func TestPGSettingsStore_RoundTrip(t *testing.T) { store := newPGSettingsStore(pool) key := "ui.maintenance_notice" - if _, _, _, err := store.Get(ctx, key); err != ErrSettingNotFound { + if _, _, _, _, err := store.Get(ctx, key); err != ErrSettingNotFound { t.Fatalf("expected ErrSettingNotFound, got %v", err) } - if _, err := store.Upsert(ctx, key, json.RawMessage(`"维护中"`), 1); err != nil { + if _, _, err := store.Upsert(ctx, key, json.RawMessage(`"维护中"`), 1); err != nil { t.Fatalf("upsert: %v", err) } - raw, updatedBy, updatedAt, err := store.Get(ctx, key) + raw, updatedBy, updatedAt, revision, err := store.Get(ctx, key) if err != nil { t.Fatalf("get: %v", err) } @@ -44,10 +45,13 @@ func TestPGSettingsStore_RoundTrip(t *testing.T) { if updatedAt == nil { t.Fatal("expected updatedAt") } - if _, err := store.Upsert(ctx, key, json.RawMessage(`"恢复"`), 2); err != nil { + if revision != 0 { + t.Fatalf("plain upsert must not bump revision, got %d", revision) + } + if _, _, err := store.Upsert(ctx, key, json.RawMessage(`"恢复"`), 2); err != nil { t.Fatalf("second upsert: %v", err) } - raw, updatedBy, _, err = store.Get(ctx, key) + raw, updatedBy, _, _, err = store.Get(ctx, key) if err != nil { t.Fatalf("get after overwrite: %v", err) } @@ -55,3 +59,84 @@ func TestPGSettingsStore_RoundTrip(t *testing.T) { t.Fatalf("expected overwrite, got %s by %+v", raw, updatedBy) } } + +func TestPGSettingsStore_AuthorOnlyCASAndAudit(t *testing.T) { + dsn := db.SkipIfNoTestDatabaseURL(t) + db.RefuseSharedEnvDSN(t, dsn) + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + + store := newPGSettingsStore(pool) + svc := NewService(nil, Options{Store: store}) + + pol, err := svc.GetAuthorOnlyApprovalPolicy(ctx) + if err != nil { + t.Fatalf("get policy: %v", err) + } + if pol.Allowed { + t.Fatalf("seeded policy must default closed, got %+v", pol) + } + + rev := pol.Revision + setting, err := svc.UpdateSettingEx(ctx, SettingUpdate{ + Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`true`), + UpdatedBy: 11, ExpectedRevision: &rev, RequestID: "pg-cas-1", + }) + if err != nil { + t.Fatalf("enable: %v", err) + } + if setting.Value != true || setting.Revision != rev+1 { + t.Fatalf("expected true@%d, got value=%v rev=%d", rev+1, setting.Value, setting.Revision) + } + + _, err = svc.UpdateSettingEx(ctx, SettingUpdate{ + Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`false`), + UpdatedBy: 11, ExpectedRevision: &rev, + }) + if !errors.Is(err, ErrSettingRevisionConflict) { + t.Fatalf("expected conflict, got %v", err) + } + + var auditCount int + if err := pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM system_setting_policy_audits + WHERE setting_key = $1 AND request_id = 'pg-cas-1' + `, SettingKeyAllowAuthorOnlyApproval).Scan(&auditCount); err != nil { + t.Fatalf("count audits: %v", err) + } + if auditCount != 1 { + t.Fatalf("expected 1 audit row, got %d", auditCount) + } + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback(ctx) + locked, err := svc.LockAuthorOnlyApprovalPolicy(ctx, tx) + if err != nil { + t.Fatalf("lock: %v", err) + } + if !locked.Allowed || locked.Revision != rev+1 { + t.Fatalf("locked snapshot mismatch: %+v", locked) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit: %v", err) + } + + // 复位为关闭,避免污染同库其它测试对默认态的假设 + next := setting.Revision + if _, err := svc.UpdateSettingEx(ctx, SettingUpdate{ + Key: SettingKeyAllowAuthorOnlyApproval, Value: json.RawMessage(`false`), + UpdatedBy: 11, ExpectedRevision: &next, + }); err != nil { + t.Fatalf("reset closed: %v", err) + } +} diff --git a/internal/template/loader.go b/internal/template/loader.go index 1b93e5d6..f68f8292 100644 --- a/internal/template/loader.go +++ b/internal/template/loader.go @@ -56,11 +56,27 @@ func GetDocTemplate(name string) (string, error) { func GetAgentInstruction(role string) (string, error) { role = strings.TrimSuffix(role, ".md") + if role == "" || strings.Contains(role, "..") || strings.ContainsAny(role, `/\`) { + return "", fmt.Errorf("read agent instruction: invalid role %q", role) + } + return GetAgentInstructionFile(filepath.Join("configs", "agents", role+".md")) +} + +// GetAgentInstructionFile 读取 configs/agents/ 下指定相对路径的指令文件, +// 并自动追加 configs/shared/writing.md。rel 必须落在 configs/agents/ 内,拒绝 ".."。 +func GetAgentInstructionFile(rel string) (string, error) { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if rel == "" || strings.Contains(rel, "..") { + return "", fmt.Errorf("read agent instruction file: invalid path %q", rel) + } + if !strings.HasPrefix(rel, "configs/agents/") { + return "", fmt.Errorf("read agent instruction file: must be under configs/agents/: %q", rel) + } root := findProjectRoot() - path := filepath.Join(root, "configs", "agents", role+".md") + path := filepath.Join(root, filepath.FromSlash(rel)) b, err := os.ReadFile(path) if err != nil { - return "", fmt.Errorf("read agent instruction %s: %w", role, err) + return "", fmt.Errorf("read agent instruction file %s: %w", rel, err) } inst := string(b) diff --git a/internal/workflow/registry.go b/internal/workflow/registry.go index 3ddba2ed..d06c4aa4 100644 --- a/internal/workflow/registry.go +++ b/internal/workflow/registry.go @@ -7,6 +7,7 @@ var ExecutorRegistry = map[string]struct{}{ "bizdev.stage.v1": {}, "squad.system_reqs.v1": {}, "squad.test_manager.v1": {}, + "squad.rdev_doc.v1": {}, "bizdev.intake.v1": {}, "bizdev.research.v1": {}, "bizdev.feasibility.v1": {}, diff --git a/internal/workflowdef/bootstrap.go b/internal/workflowdef/bootstrap.go index e283131c..1d0ab329 100644 --- a/internal/workflowdef/bootstrap.go +++ b/internal/workflowdef/bootstrap.go @@ -31,6 +31,11 @@ var officialSeedMeta = map[string]struct { Description: "基于项目上下文独立生成系统验证方案及计划", Domain: "medical_device", }, + OfficialKeyRDev: { + Name: "研发设计控制流程", + Description: "ISO 13485 设计控制:13 阶段 / 60 受控文档(DHF/DMR)", + Domain: "medical_device", + }, OfficialKeySoftwareBootstrap: { Name: "软件项目自举流程", Description: "S0–S4:章程、现状、需求、架构、风险、路线与就绪评审(无医疗语义)", diff --git a/internal/workflowdef/catalog_policy.go b/internal/workflowdef/catalog_policy.go index d6a41c31..838e1fea 100644 --- a/internal/workflowdef/catalog_policy.go +++ b/internal/workflowdef/catalog_policy.go @@ -1,11 +1,12 @@ package workflowdef // 正式项目可选工作流(唯一对外目录)。 -// 包含医疗三工作流 + 纯软件 bootstrap/delivery;不得把医疗语义硬塞进软件 key。 +// 包含医疗四工作流 + 纯软件 bootstrap/delivery;不得把医疗语义硬塞进软件 key。 const ( OfficialKeyMain = "main" OfficialKeyProductManager = "product_manager" OfficialKeyTestManager = "test_manager" + OfficialKeyRDev = "rdev" OfficialKeySoftwareBootstrap = "software_bootstrap" OfficialKeySoftwareDelivery = "software_delivery" ) @@ -14,6 +15,7 @@ var officialWorkflowKeys = map[string]struct{}{ OfficialKeyMain: {}, OfficialKeyProductManager: {}, OfficialKeyTestManager: {}, + OfficialKeyRDev: {}, OfficialKeySoftwareBootstrap: {}, OfficialKeySoftwareDelivery: {}, } @@ -22,12 +24,14 @@ var medicalWorkflowKeys = map[string]struct{}{ OfficialKeyMain: {}, OfficialKeyProductManager: {}, OfficialKeyTestManager: {}, + OfficialKeyRDev: {}, } var officialWorkflowNames = map[string]string{ OfficialKeyMain: "商务对接流程", OfficialKeyProductManager: "智能产品经理流程", OfficialKeyTestManager: "智能测试工程师流程", + OfficialKeyRDev: "研发设计控制流程", OfficialKeySoftwareBootstrap: "软件项目自举流程", OfficialKeySoftwareDelivery: "软件交付迭代流程", } @@ -36,25 +40,27 @@ var officialCatalogOrder = map[string]int{ OfficialKeyMain: 10, OfficialKeyProductManager: 20, OfficialKeyTestManager: 30, - OfficialKeySoftwareBootstrap: 40, - OfficialKeySoftwareDelivery: 50, + OfficialKeyRDev: 40, + OfficialKeySoftwareBootstrap: 50, + OfficialKeySoftwareDelivery: 60, } var officialProductDomains = map[string][]string{ OfficialKeyMain: {"medical_device", "non_medical_software"}, OfficialKeyProductManager: {"medical_device", "non_medical_software"}, OfficialKeyTestManager: {"medical_device", "non_medical_software"}, + OfficialKeyRDev: {"medical_device"}, OfficialKeySoftwareBootstrap: {"non_medical_software"}, OfficialKeySoftwareDelivery: {"non_medical_software"}, } -// IsOfficialWorkflowKey 是否为正式可选工作流 key(医疗三 + 软件两)。 +// IsOfficialWorkflowKey 是否为正式可选工作流 key(医疗四 + 软件两)。 func IsOfficialWorkflowKey(key string) bool { _, ok := officialWorkflowKeys[key] return ok } -// IsMedicalWorkflowKey 是否为医疗既有三工作流(固定文档数等门禁仍仅约束它们)。 +// IsMedicalWorkflowKey 是否为医疗正式工作流(固定文档数等门禁约束)。 func IsMedicalWorkflowKey(key string) bool { _, ok := medicalWorkflowKeys[key] return ok @@ -87,14 +93,15 @@ func OfficialWorkflowKeys() []string { OfficialKeyMain, OfficialKeyProductManager, OfficialKeyTestManager, + OfficialKeyRDev, OfficialKeySoftwareBootstrap, OfficialKeySoftwareDelivery, } } -// MedicalWorkflowKeys 返回医疗三 key(稳定顺序)。 +// MedicalWorkflowKeys 返回医疗四 key(稳定顺序)。 func MedicalWorkflowKeys() []string { - return []string{OfficialKeyMain, OfficialKeyProductManager, OfficialKeyTestManager} + return []string{OfficialKeyMain, OfficialKeyProductManager, OfficialKeyTestManager, OfficialKeyRDev} } // SoftwareWorkflowKeys 返回软件两 key(稳定顺序)。 diff --git a/internal/workflowdef/catalog_policy_test.go b/internal/workflowdef/catalog_policy_test.go index 9919a775..7fc20641 100644 --- a/internal/workflowdef/catalog_policy_test.go +++ b/internal/workflowdef/catalog_policy_test.go @@ -7,20 +7,32 @@ import ( func TestOfficialWorkflowKeys(t *testing.T) { keys := OfficialWorkflowKeys() - if len(keys) != 5 { - t.Fatalf("expected 5 official keys, got %d", len(keys)) + if len(keys) != 6 { + t.Fatalf("expected 6 official keys, got %d", len(keys)) } for _, k := range keys { if !IsOfficialWorkflowKey(k) { t.Fatalf("key %q not official", k) } } - if len(MedicalWorkflowKeys()) != 3 { - t.Fatalf("expected 3 medical keys") + if len(MedicalWorkflowKeys()) != 4 { + t.Fatalf("expected 4 medical keys") } if len(SoftwareWorkflowKeys()) != 2 { t.Fatalf("expected 2 software keys") } + if OfficialCatalogOrder(OfficialKeyRDev) != 40 { + t.Fatalf("rdev catalog_order want 40") + } + if OfficialCatalogOrder(OfficialKeySoftwareBootstrap) != 50 { + t.Fatalf("software_bootstrap catalog_order want 50") + } + if !CompatibleWithProductDomain(OfficialKeyRDev, "medical_device") { + t.Fatal("rdev should accept medical_device") + } + if CompatibleWithProductDomain(OfficialKeyRDev, "non_medical_software") { + t.Fatal("rdev must reject non_medical_software") + } } func TestCompatibleWithProductDomain(t *testing.T) { diff --git a/internal/workflowdef/catalog_store.go b/internal/workflowdef/catalog_store.go index 9d8f0b78..35054da7 100644 --- a/internal/workflowdef/catalog_store.go +++ b/internal/workflowdef/catalog_store.go @@ -59,8 +59,9 @@ func (s *Store) RetireNonOfficialDefinitions(ctx context.Context) error { WHEN 'main' THEN 10 WHEN 'product_manager' THEN 20 WHEN 'test_manager' THEN 30 - WHEN 'software_bootstrap' THEN 40 - WHEN 'software_delivery' THEN 50 + WHEN 'rdev' THEN 40 + WHEN 'software_bootstrap' THEN 50 + WHEN 'software_delivery' THEN 60 ELSE catalog_order END, updated_at = now() diff --git a/internal/workflowdef/manifest_loader.go b/internal/workflowdef/manifest_loader.go index 420ba14f..d30f4974 100644 --- a/internal/workflowdef/manifest_loader.go +++ b/internal/workflowdef/manifest_loader.go @@ -25,11 +25,12 @@ func LoadManifestDefinition(filename string) (VersionDefinition, error) { return def, nil } -// V2ManifestFiles 正式工作流 v2 manifest 文件名(医疗三 + 软件两)。 +// V2ManifestFiles 正式工作流 v2 manifest 文件名(医疗四 + 软件两)。 var V2ManifestFiles = map[string]string{ "main": "main.v2.json", "product_manager": "product_manager.v2.json", "test_manager": "test_manager.v2.json", + "rdev": "rdev.v2.json", "software_bootstrap": "software_bootstrap.v2.json", "software_delivery": "software_delivery.v2.json", } diff --git a/internal/workflowdef/manifests/rdev.v2.json b/internal/workflowdef/manifests/rdev.v2.json new file mode 100644 index 00000000..ca486eee --- /dev/null +++ b/internal/workflowdef/manifests/rdev.v2.json @@ -0,0 +1,833 @@ +{ + "schema_version": 2, + "workflow_key": "rdev", + "product_domains": [ + "medical_device" + ], + "stages": [ + { + "key": "RD01_planning", + "name": "01 设计和开发策划", + "position": 10, + "optional": false, + "doc_tasks": [ + { + "key": "rd_proposal", + "title": "项目建议书", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_proposal", + "output_doc_type": "rd_proposal", + "output_doc_title": "项目建议书" + }, + { + "key": "rd_design_plan", + "title": "设计开发计划任务书", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_design_plan", + "output_doc_type": "rd_design_plan", + "output_doc_title": "设计开发计划任务书" + } + ] + }, + { + "key": "RD02_planning_review", + "name": "02 策划评审", + "position": 20, + "optional": false, + "doc_tasks": [ + { + "key": "rd_planning_review", + "title": "设计和开发策划评审记录", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_planning_review", + "output_doc_type": "rd_planning_review", + "output_doc_title": "设计和开发策划评审记录" + } + ] + }, + { + "key": "RD03_design_input", + "name": "03 设计输入", + "position": 30, + "optional": false, + "doc_tasks": [ + { + "key": "rd_user_needs", + "title": "用户需求规格书", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_user_needs", + "output_doc_type": "rd_user_needs", + "output_doc_title": "用户需求规格书" + }, + { + "key": "rd_regs_stds", + "title": "法规和标准清单", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_regs_stds", + "output_doc_type": "rd_regs_stds", + "output_doc_title": "法规和标准清单" + }, + { + "key": "rd_adverse_events", + "title": "同类产品不良事件信息", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_adverse_events", + "output_doc_type": "rd_adverse_events", + "output_doc_title": "同类产品不良事件信息" + }, + { + "key": "rd_system_reqs", + "title": "系统需求规格书", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_system_reqs", + "output_doc_type": "rd_system_reqs", + "output_doc_title": "系统需求规格书" + }, + { + "key": "rd_risk_plan", + "title": "风险管理计划", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_risk_plan", + "output_doc_type": "rd_risk_plan", + "output_doc_title": "风险管理计划" + }, + { + "key": "rd_hazard_analysis", + "title": "安全性问题清单及可能危险分析表", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_hazard_analysis", + "output_doc_type": "rd_hazard_analysis", + "output_doc_title": "安全性问题清单及可能危险分析表" + }, + { + "key": "rd_sw_plan", + "title": "软件开发计划", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_sw_plan", + "output_doc_type": "rd_sw_plan", + "output_doc_title": "软件开发计划" + }, + { + "key": "rd_sw_reqs", + "title": "软件需求规格书", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_sw_reqs", + "output_doc_type": "rd_sw_reqs", + "output_doc_title": "软件需求规格书" + }, + { + "key": "rd_sw_config", + "title": "软件配置及维护计划", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_sw_config", + "output_doc_type": "rd_sw_config", + "output_doc_title": "软件配置及维护计划" + }, + { + "key": "rd_design_input_spec", + "title": "设计输入规格书", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_design_input_spec", + "output_doc_type": "rd_design_input_spec", + "output_doc_title": "设计输入规格书" + }, + { + "key": "rd_design_input_list", + "title": "设计输入清单", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_design_input_list", + "output_doc_type": "rd_design_input_list", + "output_doc_title": "设计输入清单" + }, + { + "key": "rd_system_design", + "title": "系统设计方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_system_design", + "output_doc_type": "rd_system_design", + "output_doc_title": "系统设计方案" + } + ] + }, + { + "key": "RD04_requirements_review", + "name": "04 需求评审", + "position": 40, + "optional": false, + "doc_tasks": [ + { + "key": "rd_req_review", + "title": "需求评审记录", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_req_review", + "output_doc_type": "rd_req_review", + "output_doc_title": "需求评审记录" + } + ] + }, + { + "key": "RD05_input_phase_review", + "name": "05 输入阶段评审", + "position": 50, + "optional": false, + "doc_tasks": [ + { + "key": "rd_input_phase_review", + "title": "设计开发评审记录(阶段3和4)", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_input_phase_review", + "output_doc_type": "rd_input_phase_review", + "output_doc_title": "设计开发评审记录(阶段3和4)" + } + ] + }, + { + "key": "RD06_design_output", + "name": "06 设计输出", + "position": 60, + "optional": false, + "doc_tasks": [ + { + "key": "rd_drawings_3d", + "title": "2D图纸和3D模型", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_drawings_3d", + "output_doc_type": "rd_drawings_3d", + "output_doc_title": "2D图纸和3D模型" + }, + { + "key": "rd_product_bom", + "title": "产品BOM", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_product_bom", + "output_doc_type": "rd_product_bom", + "output_doc_title": "产品BOM" + }, + { + "key": "rd_ee_bom", + "title": "电子电气BOM", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_ee_bom", + "output_doc_type": "rd_ee_bom", + "output_doc_title": "电子电气BOM" + }, + { + "key": "rd_pcba", + "title": "PCBA原理图及Layout", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_pcba", + "output_doc_type": "rd_pcba", + "output_doc_title": "PCBA原理图及Layout" + }, + { + "key": "rd_electrical_sys", + "title": "系统电气设计图", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_electrical_sys", + "output_doc_type": "rd_electrical_sys", + "output_doc_title": "系统电气设计图" + }, + { + "key": "rd_sw_arch", + "title": "软件概要设计方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_sw_arch", + "output_doc_type": "rd_sw_arch", + "output_doc_title": "软件概要设计方案" + }, + { + "key": "rd_sw_ui", + "title": "软件UI设计文档", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_sw_ui", + "output_doc_type": "rd_sw_ui", + "output_doc_title": "软件UI设计文档" + }, + { + "key": "rd_purchase_spec", + "title": "采购技术规范", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_purchase_spec", + "output_doc_type": "rd_purchase_spec", + "output_doc_title": "采购技术规范" + }, + { + "key": "rd_process_flow", + "title": "工艺流程图及作业指导", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_process_flow", + "output_doc_type": "rd_process_flow", + "output_doc_title": "工艺流程图及作业指导" + }, + { + "key": "rd_packaging", + "title": "包装图纸和设计要求", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_packaging", + "output_doc_type": "rd_packaging", + "output_doc_title": "包装图纸和设计要求" + }, + { + "key": "rd_ifu", + "title": "产品使用说明书", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_ifu", + "output_doc_type": "rd_ifu", + "output_doc_title": "产品使用说明书" + }, + { + "key": "rd_incoming_iqc", + "title": "原材料进货检验规范", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_incoming_iqc", + "output_doc_type": "rd_incoming_iqc", + "output_doc_title": "原材料进货检验规范" + }, + { + "key": "rd_ipqc", + "title": "生产过程检验规范", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_ipqc", + "output_doc_type": "rd_ipqc", + "output_doc_title": "生产过程检验规范" + }, + { + "key": "rd_fqc", + "title": "成品检验规范", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_fqc", + "output_doc_type": "rd_fqc", + "output_doc_title": "成品检验规范" + }, + { + "key": "rd_tech_req", + "title": "产品技术要求", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_tech_req", + "output_doc_type": "rd_tech_req", + "output_doc_title": "产品技术要求" + }, + { + "key": "rd_risk_report", + "title": "风险管理报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_risk_report", + "output_doc_type": "rd_risk_report", + "output_doc_title": "风险管理报告" + }, + { + "key": "rd_design_output_list", + "title": "设计输出清单", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_design_output_list", + "output_doc_type": "rd_design_output_list", + "output_doc_title": "设计输出清单" + } + ] + }, + { + "key": "RD07_output_review", + "name": "07 输出评审", + "position": 70, + "optional": false, + "doc_tasks": [ + { + "key": "rd_output_review", + "title": "设计开发评审记录(阶段6)", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_output_review", + "output_doc_type": "rd_output_review", + "output_doc_title": "设计开发评审记录(阶段6)" + } + ] + }, + { + "key": "RD08_verification", + "name": "08 验证", + "position": 80, + "optional": false, + "doc_tasks": [ + { + "key": "rd_prototype_photos", + "title": "样机照片", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_prototype_photos", + "output_doc_type": "rd_prototype_photos", + "output_doc_title": "样机照片" + }, + { + "key": "rd_design_vv", + "title": "设计验证方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_design_vv", + "output_doc_type": "rd_design_vv", + "output_doc_title": "设计验证方案" + }, + { + "key": "rd_shelf_life", + "title": "货架寿命方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_shelf_life", + "output_doc_type": "rd_shelf_life", + "output_doc_title": "货架寿命方案" + }, + { + "key": "rd_lifetime", + "title": "产品使用期限研究方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_lifetime", + "output_doc_type": "rd_lifetime", + "output_doc_title": "产品使用期限研究方案" + }, + { + "key": "rd_biocompatibility", + "title": "生物学检测方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_biocompatibility", + "output_doc_type": "rd_biocompatibility", + "output_doc_title": "生物学检测方案" + }, + { + "key": "rd_cleaning", + "title": "清洁消毒验证方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_cleaning", + "output_doc_type": "rd_cleaning", + "output_doc_title": "清洁消毒验证方案" + }, + { + "key": "rd_emc_safety", + "title": "EMC及安规摸底报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_emc_safety", + "output_doc_type": "rd_emc_safety", + "output_doc_title": "EMC及安规摸底报告" + }, + { + "key": "rd_risk_report_v", + "title": "风险管理报告(验证阶段)", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_risk_report_v", + "output_doc_type": "rd_risk_report_v", + "output_doc_title": "风险管理报告(验证阶段)" + }, + { + "key": "rd_sw_release", + "title": "软件发布清单", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_sw_release", + "output_doc_type": "rd_sw_release", + "output_doc_title": "软件发布清单" + }, + { + "key": "rd_sw_unit_test", + "title": "软件单元测试报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_sw_unit_test", + "output_doc_type": "rd_sw_unit_test", + "output_doc_title": "软件单元测试报告" + }, + { + "key": "rd_sw_sys_test", + "title": "软件系统测试报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_sw_sys_test", + "output_doc_type": "rd_sw_sys_test", + "output_doc_title": "软件系统测试报告" + }, + { + "key": "rd_qualified_suppliers", + "title": "合格供方名录", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_qualified_suppliers", + "output_doc_type": "rd_qualified_suppliers", + "output_doc_title": "合格供方名录" + } + ] + }, + { + "key": "RD09_verification_review", + "name": "09 验证评审", + "position": 90, + "optional": false, + "doc_tasks": [ + { + "key": "rd_verification_review", + "title": "设计开发评审记录(验证阶段)", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_verification_review", + "output_doc_type": "rd_verification_review", + "output_doc_title": "设计开发评审记录(验证阶段)" + } + ] + }, + { + "key": "RD10_transfer", + "name": "10 转换", + "position": 100, + "optional": false, + "doc_tasks": [ + { + "key": "rd_dmr_list", + "title": "DMR文件清单", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_dmr_list", + "output_doc_type": "rd_dmr_list", + "output_doc_title": "DMR文件清单" + }, + { + "key": "rd_pack_ship", + "title": "包装运输方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_pack_ship", + "output_doc_type": "rd_pack_ship", + "output_doc_title": "包装运输方案" + }, + { + "key": "rd_risk_report_t", + "title": "风险管理报告(转换阶段)", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_risk_report_t", + "output_doc_type": "rd_risk_report_t", + "output_doc_title": "风险管理报告(转换阶段)" + } + ] + }, + { + "key": "RD11_transfer_review", + "name": "11 转换评审", + "position": 110, + "optional": false, + "doc_tasks": [ + { + "key": "rd_transfer_review", + "title": "设计开发评审记录(转换阶段)", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_transfer_review", + "output_doc_type": "rd_transfer_review", + "output_doc_title": "设计开发评审记录(转换阶段)" + } + ] + }, + { + "key": "RD12_validation", + "name": "12 确认", + "position": 120, + "optional": false, + "doc_tasks": [ + { + "key": "rd_validation_plan", + "title": "设计确认方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_validation_plan", + "output_doc_type": "rd_validation_plan", + "output_doc_title": "设计确认方案" + }, + { + "key": "rd_use_error", + "title": "使用错误评估报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_use_error", + "output_doc_type": "rd_use_error", + "output_doc_title": "使用错误评估报告" + }, + { + "key": "rd_animal_study", + "title": "动物试验方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_animal_study", + "output_doc_type": "rd_animal_study", + "output_doc_title": "动物试验方案" + }, + { + "key": "rd_sw_quality", + "title": "软件质量测试报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_sw_quality", + "output_doc_type": "rd_sw_quality", + "output_doc_title": "软件质量测试报告" + }, + { + "key": "rd_clinical", + "title": "临床试验报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_clinical", + "output_doc_type": "rd_clinical", + "output_doc_title": "临床试验报告" + }, + { + "key": "rd_risk_report_val", + "title": "风险管理报告(确认阶段)", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_risk_report_val", + "output_doc_type": "rd_risk_report_val", + "output_doc_title": "风险管理报告(确认阶段)" + }, + { + "key": "rd_user_test", + "title": "用户测试报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_user_test", + "output_doc_type": "rd_user_test", + "output_doc_title": "用户测试报告" + } + ] + }, + { + "key": "RD13_validation_report", + "name": "13 设计确认报告", + "position": 130, + "optional": false, + "doc_tasks": [ + { + "key": "rd_design_validation_report", + "title": "设计确认报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.rdev_doc.v1", + "quality_gate_key": "quality.generic.v1", + "template_asset_key": "rd_design_validation_report", + "output_doc_type": "rd_design_validation_report", + "output_doc_title": "设计确认报告" + } + ] + } + ] +} diff --git a/internal/workflowdef/rdev_completeness_test.go b/internal/workflowdef/rdev_completeness_test.go new file mode 100644 index 00000000..b93b4b64 --- /dev/null +++ b/internal/workflowdef/rdev_completeness_test.go @@ -0,0 +1,68 @@ +package workflowdef + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestRDevManifestCompleteness(t *testing.T) { + def, err := LoadManifestDefinition("rdev.v2.json") + if err != nil { + t.Fatal(err) + } + normalizeManifestDefinition(&def) + res := ValidateDefinitionV2(def) + if !res.Valid { + t.Fatalf("rdev v2 invalid: %+v", res.Issues) + } + + keys := make([]string, 0, 60) + for _, st := range def.Stages { + for _, dt := range st.DocTasks { + keys = append(keys, dt.Key) + } + } + if len(keys) != 60 { + t.Fatalf("want 60 doc tasks, got %d", len(keys)) + } + if len(def.Stages) != 13 { + t.Fatalf("want 13 stages, got %d", len(def.Stages)) + } + + existing := map[string]struct{}{ + "intake": {}, "research": {}, "feasibility_dev": {}, "feasibility_reg": {}, + "user_requirements": {}, "system_reqs": {}, "test_manager": {}, "prelim_design": {}, "cdo_quote": {}, + } + _, thisFile, _, _ := runtime.Caller(0) + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "../..")) + templatesDir := filepath.Join(repoRoot, "templates", "docs") + + for _, key := range keys { + if !strings.HasPrefix(key, "rd_") { + t.Fatalf("rdev task key must use rd_ prefix: %s", key) + } + if _, clash := existing[key]; clash { + t.Fatalf("rdev key %s intersects existing task keys", key) + } + path := filepath.Join(templatesDir, key+".md") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("template missing for %s: %v", key, err) + } + if len(strings.TrimSpace(string(b))) == 0 { + t.Fatalf("template empty: %s", key) + } + } + + last := filepath.Join(templatesDir, "rd_design_validation_report.md") + b, err := os.ReadFile(last) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "签署") { + t.Fatal("rd_design_validation_report.md must contain 签署") + } +} diff --git a/internal/workflowdef/rdev_template_lint_test.go b/internal/workflowdef/rdev_template_lint_test.go new file mode 100644 index 00000000..3cfaebab --- /dev/null +++ b/internal/workflowdef/rdev_template_lint_test.go @@ -0,0 +1,109 @@ +package workflowdef + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// rdevTemplateDepthAllowlist 已补强模板;随 S0/S1 批次增长。 +var rdevTemplateDepthAllowlist = []string{ + "rd_design_input_spec", + "rd_proposal", + "rd_design_input_list", + "rd_system_design", + "rd_regs_stds", + "rd_adverse_events", + "rd_sw_plan", + "rd_sw_reqs", + "rd_sw_config", + "rd_risk_plan", + "rd_design_plan", + "rd_user_needs", + "rd_hazard_analysis", + "rd_system_reqs", + "rd_planning_review", + "rd_req_review", + "rd_input_phase_review", + "rd_output_review", + "rd_verification_review", + "rd_transfer_review", + "rd_drawings_3d", + "rd_product_bom", + "rd_ee_bom", + "rd_pcba", + "rd_electrical_sys", + "rd_process_flow", + "rd_packaging", + "rd_purchase_spec", + "rd_sw_arch", + "rd_sw_ui", + "rd_ifu", + "rd_incoming_iqc", + "rd_ipqc", + "rd_fqc", + "rd_tech_req", + "rd_risk_report", + "rd_design_output_list", + "rd_prototype_photos", + "rd_design_vv", + "rd_shelf_life", + "rd_lifetime", + "rd_biocompatibility", + "rd_cleaning", + "rd_emc_safety", + "rd_risk_report_v", + "rd_sw_release", + "rd_sw_unit_test", + "rd_sw_sys_test", + "rd_qualified_suppliers", + "rd_dmr_list", + "rd_pack_ship", + "rd_risk_report_t", + "rd_validation_plan", + "rd_use_error", + "rd_animal_study", + "rd_sw_quality", + "rd_clinical", + "rd_risk_report_val", + "rd_user_test", + "rd_design_validation_report", +} + +func TestRDevTemplateDepthLint(t *testing.T) { + _, thisFile, _, _ := runtime.Caller(0) + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "../..")) + templatesDir := filepath.Join(repoRoot, "templates", "docs") + + for _, key := range rdevTemplateDepthAllowlist { + path := filepath.Join(templatesDir, key+".md") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: read: %v", key, err) + } + body := string(b) + if strings.Contains(body, "按本阶段专业结构写") { + t.Errorf("%s: still contains generic placeholder 「按本阶段专业结构写」", key) + } + headingCount := 0 + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, "## ") { + headingCount++ + } + } + if headingCount < 4 { + t.Errorf("%s: want >=4 ## headings, got %d", key, headingCount) + } + if !strings.Contains(body, "| RD-") { + t.Errorf("%s: missing traceability row with | RD-", key) + } + if !strings.Contains(body, "## 待确认清单") { + t.Errorf("%s: missing ## 待确认清单", key) + } + if !strings.Contains(body, "## 签署") { + t.Errorf("%s: missing ## 签署", key) + } + } +} diff --git a/internal/workflowdef/validator_v2.go b/internal/workflowdef/validator_v2.go index 4dec223b..c65cf27b 100644 --- a/internal/workflowdef/validator_v2.go +++ b/internal/workflowdef/validator_v2.go @@ -10,6 +10,7 @@ var officialDocCounts = map[string]int{ OfficialKeyMain: 7, OfficialKeyProductManager: 1, OfficialKeyTestManager: 1, + OfficialKeyRDev: 60, } // ValidateDefinitionV2 schema v2 发布校验。 @@ -69,7 +70,7 @@ func ValidateDefinitionV2(def VersionDefinition) ValidationResult { if len(def.Stages) == 0 { issues = append(issues, ValidationIssue{Code: "NO_STAGES", Message: "at least one stage required"}) } - // 固定文档数仅约束医疗三工作流;软件工作流按自身 manifest 完整性校验。 + // 固定文档数约束医疗正式工作流;软件工作流按自身 manifest 完整性校验。 if def.WorkflowKey != "" && IsMedicalWorkflowKey(def.WorkflowKey) { if want, ok := officialDocCounts[def.WorkflowKey]; ok && docCount != want { issues = append(issues, ValidationIssue{ diff --git a/templates/docs/rd_adverse_events.md b/templates/docs/rd_adverse_events.md new file mode 100644 index 00000000..d04867da --- /dev/null +++ b/templates/docs/rd_adverse_events.md @@ -0,0 +1,74 @@ +# 同类产品不良事件信息 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-AE +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:同类产品主要不良事件模式及对本项目设计输入的含义。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 检索范围与方法 + +(写满四段式。数据库/时间窗/关键词/产品对照范围;未检索到须说明已尝试路径。禁止编造 URL 与事件编号。) + +| 项 | 内容 | +| --- | --- | +| 数据源 | 待确认 | +| 时间窗 | 待确认 | +| 关键词/产品范围 | 待确认 | + +## 不良事件摘要表 + +(写满四段式。按模式归类;无公开信息写「未检索到公开信息」。) + +| 事件模式 | 典型表现 | 频次/量级(若可知) | 出处 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 对本项目的设计含义 + +(写满四段式。每条事件模式对应设计输入/风险控制/标签或培训含义。) + +| 事件模式 | 设计输入含义 | 风险控制线索 | 是否已纳入 DI/风险 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 信息局限与后续监视 + +(写满四段式。数据局限、需窗口核实项、上市后监视建议。) + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-AE | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_animal_study.md b/templates/docs/rd_animal_study.md new file mode 100644 index 00000000..40338918 --- /dev/null +++ b/templates/docs/rd_animal_study.md @@ -0,0 +1,80 @@ +# 动物试验方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-ANIMAL +- 记录归属:DHF +- 版本:A +- 阶段:12 确认 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清动物试验的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-ANIMAL-S-001 | 目的 | 待确认 | +| RD-ANIMAL-S-002 | 范围 | 待确认 | +| RD-ANIMAL-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-ANIMAL-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-ANIMAL-N-001 | 样本量 | 待确认 | +| RD-ANIMAL-N-002 | 接受准则 | 待确认 | +| RD-ANIMAL-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-ANIMAL-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-ANIMAL | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_biocompatibility.md b/templates/docs/rd_biocompatibility.md new file mode 100644 index 00000000..5bdb2184 --- /dev/null +++ b/templates/docs/rd_biocompatibility.md @@ -0,0 +1,80 @@ +# 生物学检测方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-BIO +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清生物学评价/检测的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-BIO-S-001 | 目的 | 待确认 | +| RD-BIO-S-002 | 范围 | 待确认 | +| RD-BIO-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-BIO-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-BIO-N-001 | 样本量 | 待确认 | +| RD-BIO-N-002 | 接受准则 | 待确认 | +| RD-BIO-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-BIO-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-BIO | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_cleaning.md b/templates/docs/rd_cleaning.md new file mode 100644 index 00000000..2961861a --- /dev/null +++ b/templates/docs/rd_cleaning.md @@ -0,0 +1,80 @@ +# 清洁消毒验证方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-CLEAN +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清清洁消毒验证的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-CLEAN-S-001 | 目的 | 待确认 | +| RD-CLEAN-S-002 | 范围 | 待确认 | +| RD-CLEAN-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-CLEAN-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-CLEAN-N-001 | 样本量 | 待确认 | +| RD-CLEAN-N-002 | 接受准则 | 待确认 | +| RD-CLEAN-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-CLEAN-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-CLEAN | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_clinical.md b/templates/docs/rd_clinical.md new file mode 100644 index 00000000..273c6026 --- /dev/null +++ b/templates/docs/rd_clinical.md @@ -0,0 +1,80 @@ +# 临床试验报告 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-CLIN +- 记录归属:DHF +- 版本:A +- 阶段:12 确认 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清临床试验/评价的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-CLIN-S-001 | 目的 | 待确认 | +| RD-CLIN-S-002 | 范围 | 待确认 | +| RD-CLIN-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-CLIN-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-CLIN-N-001 | 样本量 | 待确认 | +| RD-CLIN-N-002 | 接受准则 | 待确认 | +| RD-CLIN-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-CLIN-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-CLIN | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_design_input_list.md b/templates/docs/rd_design_input_list.md new file mode 100644 index 00000000..4974fdac --- /dev/null +++ b/templates/docs/rd_design_input_list.md @@ -0,0 +1,80 @@ +# 设计输入清单 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-DI-LIST +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:设计输入条目是否齐套、缺口是否已识别。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 输入来源与分类 + +(写满四段式。说明输入来自用户需求、法规标准、风险、既往产品经验等;分类口径写清。) + +| 分类 | 说明 | 主要来源文档 | +| --- | --- | --- | +| 功能/性能 | 待确认 | 待确认 | +| 法规/标准 | 待确认 | 待确认 | +| 安全/风险 | 待确认 | 待确认 | + +## 设计输入条目表 + +(写满四段式。每条原子化;状态用「已收录/待确认/不适用」。禁止空表。) + +| 输入 ID | 陈述摘要 | 分类 | 来源 | 状态 | 关联规格书条款 | +| --- | --- | --- | --- | --- | --- | +| DI-001 | 待确认 | 待确认 | 待确认 | 待确认 | RD-DI-SPEC | + +## 缺口与冲突 + +(写满四段式。列出缺失输入、相互冲突输入及处理建议;无冲突写「当前未识别冲突」。) + +| 缺口/冲突 | 影响阶段 | 建议动作 | 责任方 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 齐套性检查 + +(写满四段式。对照设计输入规格书、用户需求、法规清单做齐套结论。) + +| 检查项 | 结果 | 说明 | +| --- | --- | --- | +| 与 RD-DI-SPEC 对齐 | 待确认 | 待确认 | +| 与 RD-UN / RD-SR 对齐 | 待确认 | 待确认 | +| 法规输入已覆盖 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-DI-LIST | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_design_input_spec.md b/templates/docs/rd_design_input_spec.md new file mode 100644 index 00000000..e1a14df7 --- /dev/null +++ b/templates/docs/rd_design_input_spec.md @@ -0,0 +1,84 @@ +# 设计输入规格书 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-DI-SPEC +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话概括本规格书覆盖的功能/性能/接口/安全/法规输入是否齐备。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 功能要求 + +(写满:结论句 → 表格或列表 → 解读 → 出处。覆盖预期用途相关的核心功能、操作流程与用户角色。缺项写「待确认」。不做疗效宣称。禁止用省略套话跳过本节。) + +| ID | 功能陈述 | 优先级 | 来源 | 验证方式 | +| --- | --- | --- | --- | --- | +| DI-F-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 性能要求 + +(写满四段式。覆盖关键性能指标、量程、精度、响应时间、可靠性指标及测量条件。未知写「待确认」并说明口径。) + +| ID | 性能指标 | 目标值/范围 | 测量条件 | 来源 | +| --- | --- | --- | --- | --- | +| DI-P-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 接口与环境要求 + +(写满四段式。覆盖人机接口、电气/机械/数据接口、使用环境(温湿度/海拔/电磁)、供电与安装约束。) + +| ID | 接口/环境项 | 要求陈述 | 适用边界 | 来源 | +| --- | --- | --- | --- | --- | +| DI-I-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 安全与风险输入 + +(写满四段式。覆盖基本安全、剩余风险可接受性输入、告警/防护、已知危害线索。引用风险管理计划/危害分析时标出处;无则「待确认」。不做疗效宣称。) + +| ID | 安全/风险输入 | 关联危害/控制 | 验证/确认线索 | 来源 | +| --- | --- | --- | --- | --- | +| DI-S-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 法规与标准符合性 + +(写满四段式。列出本设计输入须符合的法规、强制性标准与指导原则;指导原则标注「非强制」。条款粒度拆到可验证;禁止合并多条款为一行空话。) + +| ID | 法规/标准 | 适用条款/要点 | 对本输入的含义 | 符合性证据计划 | +| --- | --- | --- | --- | --- | +| DI-R-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-DI-SPEC | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_design_output_list.md b/templates/docs/rd_design_output_list.md new file mode 100644 index 00000000..9fe8a2a0 --- /dev/null +++ b/templates/docs/rd_design_output_list.md @@ -0,0 +1,68 @@ +# 设计输出清单 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-DO-LIST +- 记录归属:DHF +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 清单范围与纳入准则 + +(写满四段式。说明本清单覆盖范围、纳入/排除准则及与 DHF/DMR 的关系;缺项写「待确认」。) + +| ID | 准则项 | 说明 | +| --- | --- | --- | +| RD-DO-LIST-CRIT-001 | 待确认 | 待确认 | + +## 清单明细 + +(写满四段式。逐项列出文件/记录;列出本阶段设计输出文件及其 DHF/DMR 归属。禁止空表头;至少一行「待确认」示例。) + +| ID | 文件/记录 | 版本 | 归属(DHF/DMR) | 状态 | 备注 | +| --- | --- | --- | --- | --- | --- | +| RD-DO-LIST-ITEM-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 缺口与风险 + +(写满四段式。列出缺失项、对后续阶段的阻塞影响与补齐计划。) + +| ID | 缺口 | 影响 | 计划补齐方式 | 责任人 | +| --- | --- | --- | --- | --- | +| RD-DO-LIST-GAP-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-DO-LIST | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_design_plan.md b/templates/docs/rd_design_plan.md new file mode 100644 index 00000000..6e62ea9f --- /dev/null +++ b/templates/docs/rd_design_plan.md @@ -0,0 +1,74 @@ +# 设计开发计划任务书 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-DESIGN-PLAN +- 记录归属:DHF +- 版本:A +- 阶段:01 策划 + +## 结论 + +(一句话:DHF 文件计划与阶段节奏是否足以支撑设计控制。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 设计控制策略与阶段划分 + +(写满四段式。策划→输入→输出→验证→确认→转化的阶段目标与门禁。禁止省略套话。) + +| 阶段 | 目标 | 门禁/评审 | 主要输出 | +| --- | --- | --- | --- | +| 01 策划 | 待确认 | 待确认 | 待确认 | +| 03 设计输入 | 待确认 | 待确认 | 待确认 | + +## DHF 文件计划清单 + +(写满:每行给出计划完成时点与责任角色;未知写「待确认」。) + +| 阶段 | 文件 | 记录归属 | 计划完成 | 责任角色 | +| --- | --- | --- | --- | --- | +| 策划 | 项目建议书、设计开发计划任务书 | DHF | 待确认 | 待确认 | +| 设计输入 | 设计输入清单/规格书、UN/SR、风险相关 | DHF | 待确认 | 待确认 | + +## 资源、接口与依赖 + +(写满四段式。内外部接口、关键依赖、外包边界。) + +| 依赖/接口 | 提供方 | 对本计划影响 | +| --- | --- | --- | +| 待确认 | 待确认 | 待确认 | + +## 进度与变更控制入口 + +(写满四段式。基线计划、变更如何回流到本任务书与 DHF 清单。) + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-DESIGN-PLAN | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_design_validation_report.md b/templates/docs/rd_design_validation_report.md new file mode 100644 index 00000000..4574eaed --- /dev/null +++ b/templates/docs/rd_design_validation_report.md @@ -0,0 +1,78 @@ +# 设计确认报告 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-VAL-RPT +- 记录归属:DHF +- 版本:A +- 阶段:13 设计确认报告 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 确认活动汇总 + +(写满四段式。汇总本项目设计确认活动、覆盖需求与结论;缺项写「待确认」。不做疗效宣称。) + +| ID | 确认活动 | 关联方案/报告 | 结论 | +| --- | --- | --- | --- | +| RD-VAL-RPT-VA-001 | 待确认 | 待确认 | 待确认 | + +## 与设计输入的符合性 + +(写满四段式。关键设计输入符合性判定及证据索引。) + +| ID | 设计输入 ID | 符合性结论 | 证据 | +| --- | --- | --- | --- | +| RD-VAL-RPT-DI-001 | 待确认 | 待确认 | 待确认 | + +## 残留问题与限制条件 + +(写满四段式。未关闭问题、使用限制、告知用户信息。) + +| ID | 问题/限制 | 影响 | 处置计划 | +| --- | --- | --- | --- | +| RD-VAL-RPT-LIM-001 | 待确认 | 待确认 | 待确认 | + +## 总体确认结论 + +(写满四段式。是否可进入上市/生产转换的总体结论与前提条件。) + +| 结论项 | 内容 | +| --- | --- | +| 总体结论 | 待确认 | +| 前提条件 | 待确认 | +| 后续监视要点 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-VAL-RPT | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_design_vv.md b/templates/docs/rd_design_vv.md new file mode 100644 index 00000000..a98fb004 --- /dev/null +++ b/templates/docs/rd_design_vv.md @@ -0,0 +1,80 @@ +# 设计验证方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-VV +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清设计验证的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-VV-S-001 | 目的 | 待确认 | +| RD-VV-S-002 | 范围 | 待确认 | +| RD-VV-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-VV-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-VV-N-001 | 样本量 | 待确认 | +| RD-VV-N-002 | 接受准则 | 待确认 | +| RD-VV-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-VV-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-VV | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_dmr_list.md b/templates/docs/rd_dmr_list.md new file mode 100644 index 00000000..06957ee5 --- /dev/null +++ b/templates/docs/rd_dmr_list.md @@ -0,0 +1,68 @@ +# DMR文件清单 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-DMR-LIST +- 记录归属:DHF / DMR +- 版本:A +- 阶段:10 转换 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 清单范围与纳入准则 + +(写满四段式。说明本清单覆盖范围、纳入/排除准则及与 DHF/DMR 的关系;缺项写「待确认」。) + +| ID | 准则项 | 说明 | +| --- | --- | --- | +| RD-DMR-LIST-CRIT-001 | 待确认 | 待确认 | + +## 清单明细 + +(写满四段式。逐项列出文件/记录;列出进入 DMR 的受控生产文件/记录。禁止空表头;至少一行「待确认」示例。) + +| ID | 文件/记录 | 版本 | 归属(DHF/DMR) | 状态 | 备注 | +| --- | --- | --- | --- | --- | --- | +| RD-DMR-LIST-ITEM-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 缺口与风险 + +(写满四段式。列出缺失项、对后续阶段的阻塞影响与补齐计划。) + +| ID | 缺口 | 影响 | 计划补齐方式 | 责任人 | +| --- | --- | --- | --- | --- | +| RD-DMR-LIST-GAP-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-DMR-LIST | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_drawings_3d.md b/templates/docs/rd_drawings_3d.md new file mode 100644 index 00000000..242b78a8 --- /dev/null +++ b/templates/docs/rd_drawings_3d.md @@ -0,0 +1,68 @@ +# 2D图纸和3D模型(附件清单) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-DWG +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 附件齐套性 + +(写满四段式。结论写2D/3D 图纸模型是否齐套。无附件则标「待确认」。**不要编造图纸、BOM、Gerber、照片或专有文件内容**;仅索引已提供材料。) + +| 序号 | 图号/物料号/标识 | 文件名 | 版本 | 说明 | 齐套 | +| --- | --- | --- | --- | --- | --- | +| RD-DWG-ATT-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 技术说明摘要 + +(写满四段式。概括本批2D/3D 图纸模型覆盖的设计意图、关键接口/约束与适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 说明项 | 内容陈述 | 来源/依据 | +| --- | --- | --- | --- | +| RD-DWG-TECH-001 | 待确认 | 待确认 | 待确认 | + +## 变更与影响 + +(写满四段式。相对上一批准版本或基线的变更点、对上下游文档/验证的影响;无变更写「无变更」并说明基线。) + +| ID | 变更点 | 影响范围 | 需同步的下游 | 状态 | +| --- | --- | --- | --- | --- | +| RD-DWG-CHG-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-DWG | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_ee_bom.md b/templates/docs/rd_ee_bom.md new file mode 100644 index 00000000..06dced0e --- /dev/null +++ b/templates/docs/rd_ee_bom.md @@ -0,0 +1,68 @@ +# 电子电气BOM(附件清单) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-EE-BOM +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 附件齐套性 + +(写满四段式。结论写电子电气BOM是否齐套。无附件则标「待确认」。**不要编造图纸、BOM、Gerber、照片或专有文件内容**;仅索引已提供材料。) + +| 序号 | 图号/物料号/标识 | 文件名 | 版本 | 说明 | 齐套 | +| --- | --- | --- | --- | --- | --- | +| RD-EE-BOM-ATT-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 技术说明摘要 + +(写满四段式。概括本批电子电气BOM覆盖的设计意图、关键接口/约束与适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 说明项 | 内容陈述 | 来源/依据 | +| --- | --- | --- | --- | +| RD-EE-BOM-TECH-001 | 待确认 | 待确认 | 待确认 | + +## 变更与影响 + +(写满四段式。相对上一批准版本或基线的变更点、对上下游文档/验证的影响;无变更写「无变更」并说明基线。) + +| ID | 变更点 | 影响范围 | 需同步的下游 | 状态 | +| --- | --- | --- | --- | --- | +| RD-EE-BOM-CHG-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-EE-BOM | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_electrical_sys.md b/templates/docs/rd_electrical_sys.md new file mode 100644 index 00000000..a8676419 --- /dev/null +++ b/templates/docs/rd_electrical_sys.md @@ -0,0 +1,68 @@ +# 系统电气设计图(附件清单) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-ELEC +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 附件齐套性 + +(写满四段式。结论写系统电气设计图是否齐套。无附件则标「待确认」。**不要编造图纸、BOM、Gerber、照片或专有文件内容**;仅索引已提供材料。) + +| 序号 | 图号/物料号/标识 | 文件名 | 版本 | 说明 | 齐套 | +| --- | --- | --- | --- | --- | --- | +| RD-ELEC-ATT-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 技术说明摘要 + +(写满四段式。概括本批系统电气设计图覆盖的设计意图、关键接口/约束与适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 说明项 | 内容陈述 | 来源/依据 | +| --- | --- | --- | --- | +| RD-ELEC-TECH-001 | 待确认 | 待确认 | 待确认 | + +## 变更与影响 + +(写满四段式。相对上一批准版本或基线的变更点、对上下游文档/验证的影响;无变更写「无变更」并说明基线。) + +| ID | 变更点 | 影响范围 | 需同步的下游 | 状态 | +| --- | --- | --- | --- | --- | +| RD-ELEC-CHG-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-ELEC | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_emc_safety.md b/templates/docs/rd_emc_safety.md new file mode 100644 index 00000000..09f3a8ce --- /dev/null +++ b/templates/docs/rd_emc_safety.md @@ -0,0 +1,80 @@ +# EMC及安规摸底报告(附件清单) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-EMC +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清EMC及安规摸底的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-EMC-S-001 | 目的 | 待确认 | +| RD-EMC-S-002 | 范围 | 待确认 | +| RD-EMC-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-EMC-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-EMC-N-001 | 样本量 | 待确认 | +| RD-EMC-N-002 | 接受准则 | 待确认 | +| RD-EMC-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-EMC-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-EMC | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_fqc.md b/templates/docs/rd_fqc.md new file mode 100644 index 00000000..e6a22004 --- /dev/null +++ b/templates/docs/rd_fqc.md @@ -0,0 +1,76 @@ +# 成品检验规范 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-FQC +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 检验范围与对象 + +(写满四段式。写清成品检验覆盖的物料/工序/成品范围与抽样前提;缺项写「待确认」。) + +| ID | 对象 | 范围说明 | 抽样前提 | +| --- | --- | --- | --- | +| RD-FQC-SCP-001 | 待确认 | 待确认 | 待确认 | + +## 检验项目与方法 + +(写满四段式。检验项目、方法、设备/量具;禁止空表。) + +| ID | 检验项目 | 方法 | 设备/量具 | 记录要求 | +| --- | --- | --- | --- | --- | +| RD-FQC-IT-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 抽样方案与判定标准 + +(写满四段式。AQL/抽样方案、合格判定、放行条件。) + +| ID | 抽样方案 | 合格判定 | 放行条件 | +| --- | --- | --- | --- | +| RD-FQC-ACC-001 | 待确认 | 待确认 | 待确认 | + +## 不合格品处理 + +(写满四段式。隔离、评审、返工/报废路径与责任角色。) + +| ID | 情形 | 处理路径 | 责任角色 | 记录 | +| --- | --- | --- | --- | --- | +| RD-FQC-NC-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-FQC | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_hazard_analysis.md b/templates/docs/rd_hazard_analysis.md new file mode 100644 index 00000000..187d1754 --- /dev/null +++ b/templates/docs/rd_hazard_analysis.md @@ -0,0 +1,78 @@ +# 安全性问题清单及可能危险分析表 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-HAZARD +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:主要危害是否已识别、控制与验证线索是否齐全。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 分析范围与方法 + +(写满四段式。分析对象、生命周期阶段、所用方法(如初步危害分析)、参考标准。禁止编造。) + +| 项 | 内容 | +| --- | --- | +| 分析对象 | 待确认 | +| 方法 | 待确认 | +| 参考 | 待确认 | + +## 危害-原因-控制-验证 + +(写满:至少一行「待确认」示例;剩余风险不得空白无说明。) + +| 危害 | 原因 | 控制措施 | 验证 | 剩余风险 | +| --- | --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 风险评价摘要 + +(写满四段式。按可接受准则汇总不可接受项与需进一步控制项。) + +| 危害 ID | 初始风险 | 控制后风险 | 可接受? | 备注 | +| --- | --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 与设计输入的接口 + +(写满四段式。哪些危害驱动了设计输入/软件需求/标签警告。) + +| 危害 | 对应设计输入/需求 | 标签/培训线索 | +| --- | --- | --- | +| 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-HAZARD | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_ifu.md b/templates/docs/rd_ifu.md new file mode 100644 index 00000000..821c0a05 --- /dev/null +++ b/templates/docs/rd_ifu.md @@ -0,0 +1,78 @@ +# 产品使用说明书 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-IFU +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 预期用途与使用人群 + +(写满四段式。预期用途、适用人群、使用环境;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-IFU-USE-001 | 预期用途 | 待确认 | +| RD-IFU-USE-002 | 使用人群 | 待确认 | +| RD-IFU-USE-003 | 使用环境 | 待确认 | + +## 操作步骤与注意事项 + +(写满四段式。关键操作步骤、禁忌/警告/注意;禁止空步骤列表。) + +| ID | 步骤/警示 | 内容 | 风险关联 | +| --- | --- | --- | --- | +| RD-IFU-OP-001 | 待确认 | 待确认 | 待确认 | + +## 维护、清洁与贮存 + +(写满四段式。维护周期、清洁消毒要求、贮存运输条件。) + +| ID | 项 | 要求 | 来源 | +| --- | --- | --- | --- | +| RD-IFU-MNT-001 | 待确认 | 待确认 | 待确认 | + +## 故障提示与用户行动 + +(写满四段式。常见故障提示、用户应采取的行动、何时联系服务。) + +| ID | 提示 | 用户行动 | 升级条件 | +| --- | --- | --- | --- | +| RD-IFU-FLT-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-IFU | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_incoming_iqc.md b/templates/docs/rd_incoming_iqc.md new file mode 100644 index 00000000..dad7dc1d --- /dev/null +++ b/templates/docs/rd_incoming_iqc.md @@ -0,0 +1,76 @@ +# 原材料进货检验规范 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-IQC +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 检验范围与对象 + +(写满四段式。写清原材料进货检验覆盖的物料/工序/成品范围与抽样前提;缺项写「待确认」。) + +| ID | 对象 | 范围说明 | 抽样前提 | +| --- | --- | --- | --- | +| RD-IQC-SCP-001 | 待确认 | 待确认 | 待确认 | + +## 检验项目与方法 + +(写满四段式。检验项目、方法、设备/量具;禁止空表。) + +| ID | 检验项目 | 方法 | 设备/量具 | 记录要求 | +| --- | --- | --- | --- | --- | +| RD-IQC-IT-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 抽样方案与判定标准 + +(写满四段式。AQL/抽样方案、合格判定、放行条件。) + +| ID | 抽样方案 | 合格判定 | 放行条件 | +| --- | --- | --- | --- | +| RD-IQC-ACC-001 | 待确认 | 待确认 | 待确认 | + +## 不合格品处理 + +(写满四段式。隔离、评审、返工/报废路径与责任角色。) + +| ID | 情形 | 处理路径 | 责任角色 | 记录 | +| --- | --- | --- | --- | --- | +| RD-IQC-NC-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-IQC | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_input_phase_review.md b/templates/docs/rd_input_phase_review.md new file mode 100644 index 00000000..50c4c6a7 --- /dev/null +++ b/templates/docs/rd_input_phase_review.md @@ -0,0 +1,86 @@ +# 设计开发评审记录(阶段3和4) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-IN-REV +- 记录归属:DHF +- 版本:A +- 阶段:05 输入阶段评审 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 评审范围与对象 + +(写满四段式。说明本阶段评审要确认什么、覆盖哪些输入/输出文件;信息不足标「待确认」。引用已批准上游文档时写出版本与批准状态。阶段语境:05 输入阶段评审(阶段3和4)。) + +| 文件 | 版本 | 批准状态 | 与本评审关系 | +| --- | --- | --- | --- | +| REV-DOC-001 | 待确认 | 待确认 | 待确认 | + +## 评审人员与职责 + +(写满四段式。列出参评角色、姓名、职责边界;缺项写「待确认」。禁止编造人员资质。) + +| 角色 | 姓名 | 职责 | +| --- | --- | --- | +| REV-P-001 | 待确认 | 待确认 | + +## 问题与决议 + +(写满四段式。每个问题须有决议、责任人与期限;空洞「同意通过」且无实质讨论时须在解读段说明依据。缺项写「待确认」。) + +| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 | +| --- | --- | --- | --- | --- | --- | +| REV-I-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 遗留行动项跟踪 + +(写满四段式。汇总未关闭行动项、对后续阶段的阻塞影响与跟进方式。) + +| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 | +| --- | --- | --- | --- | --- | --- | +| REV-A-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 评审结论 + +(写满四段式。给出通过 / 有条件通过 / 不通过及判定依据;有条件通过须列条件与验证方式。信息不足标「待确认」。) + +| 结论项 | 内容 | +| --- | --- | +| 总体结论 | 待确认 | +| 条件/限制 | 待确认 | +| 进入下一阶段前提 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-IN-REV | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_ipqc.md b/templates/docs/rd_ipqc.md new file mode 100644 index 00000000..c81f59f8 --- /dev/null +++ b/templates/docs/rd_ipqc.md @@ -0,0 +1,76 @@ +# 生产过程检验规范 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-IPQC +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 检验范围与对象 + +(写满四段式。写清生产过程检验覆盖的物料/工序/成品范围与抽样前提;缺项写「待确认」。) + +| ID | 对象 | 范围说明 | 抽样前提 | +| --- | --- | --- | --- | +| RD-IPQC-SCP-001 | 待确认 | 待确认 | 待确认 | + +## 检验项目与方法 + +(写满四段式。检验项目、方法、设备/量具;禁止空表。) + +| ID | 检验项目 | 方法 | 设备/量具 | 记录要求 | +| --- | --- | --- | --- | --- | +| RD-IPQC-IT-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 抽样方案与判定标准 + +(写满四段式。AQL/抽样方案、合格判定、放行条件。) + +| ID | 抽样方案 | 合格判定 | 放行条件 | +| --- | --- | --- | --- | +| RD-IPQC-ACC-001 | 待确认 | 待确认 | 待确认 | + +## 不合格品处理 + +(写满四段式。隔离、评审、返工/报废路径与责任角色。) + +| ID | 情形 | 处理路径 | 责任角色 | 记录 | +| --- | --- | --- | --- | --- | +| RD-IPQC-NC-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-IPQC | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_lifetime.md b/templates/docs/rd_lifetime.md new file mode 100644 index 00000000..715cade2 --- /dev/null +++ b/templates/docs/rd_lifetime.md @@ -0,0 +1,80 @@ +# 产品使用期限研究方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-LIFE +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清产品使用期限研究的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-LIFE-S-001 | 目的 | 待确认 | +| RD-LIFE-S-002 | 范围 | 待确认 | +| RD-LIFE-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-LIFE-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-LIFE-N-001 | 样本量 | 待确认 | +| RD-LIFE-N-002 | 接受准则 | 待确认 | +| RD-LIFE-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-LIFE-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-LIFE | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_output_review.md b/templates/docs/rd_output_review.md new file mode 100644 index 00000000..77675d01 --- /dev/null +++ b/templates/docs/rd_output_review.md @@ -0,0 +1,86 @@ +# 设计开发评审记录(阶段6) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-OUT-REV +- 记录归属:DHF +- 版本:A +- 阶段:07 输出评审 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 评审范围与对象 + +(写满四段式。说明本阶段评审要确认什么、覆盖哪些输入/输出文件;信息不足标「待确认」。引用已批准上游文档时写出版本与批准状态。阶段语境:07 输出评审(阶段6)。) + +| 文件 | 版本 | 批准状态 | 与本评审关系 | +| --- | --- | --- | --- | +| REV-DOC-001 | 待确认 | 待确认 | 待确认 | + +## 评审人员与职责 + +(写满四段式。列出参评角色、姓名、职责边界;缺项写「待确认」。禁止编造人员资质。) + +| 角色 | 姓名 | 职责 | +| --- | --- | --- | +| REV-P-001 | 待确认 | 待确认 | + +## 问题与决议 + +(写满四段式。每个问题须有决议、责任人与期限;空洞「同意通过」且无实质讨论时须在解读段说明依据。缺项写「待确认」。) + +| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 | +| --- | --- | --- | --- | --- | --- | +| REV-I-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 遗留行动项跟踪 + +(写满四段式。汇总未关闭行动项、对后续阶段的阻塞影响与跟进方式。) + +| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 | +| --- | --- | --- | --- | --- | --- | +| REV-A-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 评审结论 + +(写满四段式。给出通过 / 有条件通过 / 不通过及判定依据;有条件通过须列条件与验证方式。信息不足标「待确认」。) + +| 结论项 | 内容 | +| --- | --- | +| 总体结论 | 待确认 | +| 条件/限制 | 待确认 | +| 进入下一阶段前提 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-OUT-REV | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_pack_ship.md b/templates/docs/rd_pack_ship.md new file mode 100644 index 00000000..c05be1af --- /dev/null +++ b/templates/docs/rd_pack_ship.md @@ -0,0 +1,80 @@ +# 包装运输方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SHIP +- 记录归属:DHF / DMR +- 版本:A +- 阶段:10 转换 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清包装运输确认/方案的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SHIP-S-001 | 目的 | 待确认 | +| RD-SHIP-S-002 | 范围 | 待确认 | +| RD-SHIP-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-SHIP-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SHIP-N-001 | 样本量 | 待确认 | +| RD-SHIP-N-002 | 接受准则 | 待确认 | +| RD-SHIP-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-SHIP-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SHIP | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_packaging.md b/templates/docs/rd_packaging.md new file mode 100644 index 00000000..99078cb0 --- /dev/null +++ b/templates/docs/rd_packaging.md @@ -0,0 +1,76 @@ +# 包装图纸和设计要求 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-PACK +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 包装设计要求 + +(写满四段式。初级/次级包装、防护与标识要求;缺项写「待确认」。) + +| ID | 包装层级 | 要求 | 来源 | +| --- | --- | --- | --- | +| RD-PACK-PK-001 | 待确认 | 待确认 | 待确认 | + +## 图纸与规格索引 + +(写满四段式。包装图纸/规格文件索引;不编造图纸内容。) + +| ID | 图号/文件 | 版本 | 说明 | 齐套 | +| --- | --- | --- | --- | --- | +| RD-PACK-DWG-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 标签与说明书放置 + +(写满四段式。标签位置、IFU 随附要求、UDI 呈现。) + +| ID | 项 | 要求 | +| --- | --- | --- | +| RD-PACK-LB-001 | 待确认 | 待确认 | + +## 贮存运输约束 + +(写满四段式。温湿度、堆码、运输振动等约束。) + +| ID | 约束 | 限值/方法 | 验证线索 | +| --- | --- | --- | --- | +| RD-PACK-ST-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-PACK | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_pcba.md b/templates/docs/rd_pcba.md new file mode 100644 index 00000000..fb51725a --- /dev/null +++ b/templates/docs/rd_pcba.md @@ -0,0 +1,68 @@ +# PCBA原理图及Layout(附件清单) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-PCBA +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 附件齐套性 + +(写满四段式。结论写PCBA原理图及Layout是否齐套。无附件则标「待确认」。**不要编造图纸、BOM、Gerber、照片或专有文件内容**;仅索引已提供材料。) + +| 序号 | 图号/物料号/标识 | 文件名 | 版本 | 说明 | 齐套 | +| --- | --- | --- | --- | --- | --- | +| RD-PCBA-ATT-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 技术说明摘要 + +(写满四段式。概括本批PCBA原理图及Layout覆盖的设计意图、关键接口/约束与适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 说明项 | 内容陈述 | 来源/依据 | +| --- | --- | --- | --- | +| RD-PCBA-TECH-001 | 待确认 | 待确认 | 待确认 | + +## 变更与影响 + +(写满四段式。相对上一批准版本或基线的变更点、对上下游文档/验证的影响;无变更写「无变更」并说明基线。) + +| ID | 变更点 | 影响范围 | 需同步的下游 | 状态 | +| --- | --- | --- | --- | --- | +| RD-PCBA-CHG-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-PCBA | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_planning_review.md b/templates/docs/rd_planning_review.md new file mode 100644 index 00000000..51c6a689 --- /dev/null +++ b/templates/docs/rd_planning_review.md @@ -0,0 +1,86 @@ +# 设计和开发策划评审记录 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-PLAN-REV +- 记录归属:DHF +- 版本:A +- 阶段:02 策划评审 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 评审范围与对象 + +(写满四段式。说明本阶段评审要确认什么、覆盖哪些输入/输出文件;信息不足标「待确认」。引用已批准上游文档时写出版本与批准状态。阶段语境:02 策划评审。) + +| 文件 | 版本 | 批准状态 | 与本评审关系 | +| --- | --- | --- | --- | +| REV-DOC-001 | 待确认 | 待确认 | 待确认 | + +## 评审人员与职责 + +(写满四段式。列出参评角色、姓名、职责边界;缺项写「待确认」。禁止编造人员资质。) + +| 角色 | 姓名 | 职责 | +| --- | --- | --- | +| REV-P-001 | 待确认 | 待确认 | + +## 问题与决议 + +(写满四段式。每个问题须有决议、责任人与期限;空洞「同意通过」且无实质讨论时须在解读段说明依据。缺项写「待确认」。) + +| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 | +| --- | --- | --- | --- | --- | --- | +| REV-I-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 遗留行动项跟踪 + +(写满四段式。汇总未关闭行动项、对后续阶段的阻塞影响与跟进方式。) + +| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 | +| --- | --- | --- | --- | --- | --- | +| REV-A-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 评审结论 + +(写满四段式。给出通过 / 有条件通过 / 不通过及判定依据;有条件通过须列条件与验证方式。信息不足标「待确认」。) + +| 结论项 | 内容 | +| --- | --- | +| 总体结论 | 待确认 | +| 条件/限制 | 待确认 | +| 进入下一阶段前提 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-PLAN-REV | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_process_flow.md b/templates/docs/rd_process_flow.md new file mode 100644 index 00000000..d157159a --- /dev/null +++ b/templates/docs/rd_process_flow.md @@ -0,0 +1,76 @@ +# 工艺流程图及作业指导 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-PROC +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 工艺流程概述 + +(写满四段式。关键工序顺序、控制点;缺项写「待确认」。) + +| ID | 工序 | 输入 | 输出 | 关键控制点 | +| --- | --- | --- | --- | --- | +| RD-PROC-PF-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 作业指导要点 + +(写满四段式。关键作业参数、工具/设备、人员技能要求。) + +| ID | 作业项 | 参数/方法 | 工具/设备 | 技能要求 | +| --- | --- | --- | --- | --- | +| RD-PROC-WI-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 过程检验与放行 + +(写满四段式。过程检验点、放行准则、不合格回流。) + +| ID | 检验点 | 准则 | 放行角色 | +| --- | --- | --- | --- | +| RD-PROC-IP-001 | 待确认 | 待确认 | 待确认 | + +## 变更与验证需求 + +(写满四段式。工艺变更触发条件及再验证需求。) + +| ID | 变更类型 | 触发条件 | 再验证要求 | +| --- | --- | --- | --- | +| RD-PROC-CH-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-PROC | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_product_bom.md b/templates/docs/rd_product_bom.md new file mode 100644 index 00000000..ddf43773 --- /dev/null +++ b/templates/docs/rd_product_bom.md @@ -0,0 +1,68 @@ +# 产品BOM(附件清单) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-BOM +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 附件齐套性 + +(写满四段式。结论写产品BOM是否齐套。无附件则标「待确认」。**不要编造图纸、BOM、Gerber、照片或专有文件内容**;仅索引已提供材料。) + +| 序号 | 图号/物料号/标识 | 文件名 | 版本 | 说明 | 齐套 | +| --- | --- | --- | --- | --- | --- | +| RD-BOM-ATT-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 技术说明摘要 + +(写满四段式。概括本批产品BOM覆盖的设计意图、关键接口/约束与适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 说明项 | 内容陈述 | 来源/依据 | +| --- | --- | --- | --- | +| RD-BOM-TECH-001 | 待确认 | 待确认 | 待确认 | + +## 变更与影响 + +(写满四段式。相对上一批准版本或基线的变更点、对上下游文档/验证的影响;无变更写「无变更」并说明基线。) + +| ID | 变更点 | 影响范围 | 需同步的下游 | 状态 | +| --- | --- | --- | --- | --- | +| RD-BOM-CHG-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-BOM | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_proposal.md b/templates/docs/rd_proposal.md new file mode 100644 index 00000000..cc5803f2 --- /dev/null +++ b/templates/docs/rd_proposal.md @@ -0,0 +1,75 @@ +# 项目建议书 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-PROPOSAL +- 记录归属:DHF +- 版本:A +- 阶段:01 策划 + +## 结论 + +(一句话:是否建议立项、产品定位与目标市场是否清晰、主要风险是否可接受。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 项目背景与立项依据 + +(写满:结论句 → 要点列表 → 解读 → 出处。覆盖客户/市场痛点、法规与商业驱动、与公司能力匹配。缺项「待确认」。不做疗效宣称。) + +## 产品定义与范围 + +(写满四段式。覆盖产品名称/类型、预期用途、目标用户与使用场景、明确不在范围内的项。) + +| 项 | 内容 | +| --- | --- | +| 产品类型 | 待确认 | +| 预期用途 | {{intended_use}} | +| 目标市场 | {{target_market}} | +| 范围外 | 待确认 | + +## 目标、里程碑与资源假设 + +(写满四段式。覆盖阶段目标、关键里程碑、人力/外包/关键设备假设;预算与商务报价不在本文展开。) + +| 里程碑 | 计划时点 | 交付物 | 依赖 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 主要风险与对策概览 + +(写满四段式。技术/法规/供应链/进度风险各至少一条;无则说明「材料未提供」并列入待确认。) + +| 风险 | 影响 | 对策/缓解 | 残余 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-PROPOSAL | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_prototype_photos.md b/templates/docs/rd_prototype_photos.md new file mode 100644 index 00000000..8cf2cdfb --- /dev/null +++ b/templates/docs/rd_prototype_photos.md @@ -0,0 +1,68 @@ +# 样机照片(附件清单) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-PHOTO +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 附件齐套性 + +(写满四段式。结论写样机照片是否齐套。无附件则标「待确认」。**不要编造图纸、BOM、Gerber、照片或专有文件内容**;仅索引已提供材料。) + +| 序号 | 图号/物料号/标识 | 文件名 | 版本 | 说明 | 齐套 | +| --- | --- | --- | --- | --- | --- | +| RD-PHOTO-ATT-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 技术说明摘要 + +(写满四段式。概括本批样机照片覆盖的设计意图、关键接口/约束与适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 说明项 | 内容陈述 | 来源/依据 | +| --- | --- | --- | --- | +| RD-PHOTO-TECH-001 | 待确认 | 待确认 | 待确认 | + +## 变更与影响 + +(写满四段式。相对上一批准版本或基线的变更点、对上下游文档/验证的影响;无变更写「无变更」并说明基线。) + +| ID | 变更点 | 影响范围 | 需同步的下游 | 状态 | +| --- | --- | --- | --- | --- | +| RD-PHOTO-CHG-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-PHOTO | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_purchase_spec.md b/templates/docs/rd_purchase_spec.md new file mode 100644 index 00000000..763bfab3 --- /dev/null +++ b/templates/docs/rd_purchase_spec.md @@ -0,0 +1,77 @@ +# 采购技术规范 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-PUR +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 采购对象与技术要求 + +(写满四段式。外购件/外协件范围与关键技术要求;缺项写「待确认」。) + +| ID | 物料/服务 | 技术要求 | 验收准则 | +| --- | --- | --- | --- | +| RD-PUR-PO-001 | 待确认 | 待确认 | 待确认 | + +## 供方资质与评价要求 + +(写满四段式。供方能力/资质要求、评价方式。) + +| ID | 要求项 | 内容 | 评价方式 | +| --- | --- | --- | --- | +| RD-PUR-SUP-001 | 待确认 | 待确认 | 待确认 | + +## 进货检验与放行 + +(写满四段式。进货检验项目、抽样、放行。) + +| ID | 检验项目 | 抽样 | 放行准则 | +| --- | --- | --- | --- | +| RD-PUR-IQC-001 | 待确认 | 待确认 | 待确认 | + +## 变更通知与追溯 + +(写满四段式。供方变更通知要求、批次追溯要求。) + +| ID | 项 | 要求 | +| --- | --- | --- | +| RD-PUR-TR-001 | 变更通知 | 待确认 | +| RD-PUR-TR-002 | 批次追溯 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-PUR | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_qualified_suppliers.md b/templates/docs/rd_qualified_suppliers.md new file mode 100644 index 00000000..47ac1d1c --- /dev/null +++ b/templates/docs/rd_qualified_suppliers.md @@ -0,0 +1,76 @@ +# 合格供方名录 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SUP +- 记录归属:DHF / DMR +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 名录范围与纳入准则 + +(写满四段式。合格供方名录覆盖范围与纳入/剔除准则;缺项写「待确认」。) + +| ID | 准则 | 说明 | +| --- | --- | --- | +| RD-SUP-CRIT-001 | 待确认 | 待确认 | + +## 合格供方明细 + +(写满四段式。供方名称、供货范围、资质状态、评价结论。) + +| ID | 供方 | 供货范围 | 资质/证书 | 评价结论 | 有效期 | +| --- | --- | --- | --- | --- | --- | +| RD-SUP-SUP-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 评价与再评价计划 + +(写满四段式。初评/再评周期、触发条件、责任角色。) + +| ID | 活动 | 周期/触发 | 责任角色 | +| --- | --- | --- | --- | +| RD-SUP-EV-001 | 待确认 | 待确认 | 待确认 | + +## 不合格供方与变更控制 + +(写满四段式。不合格处置、供方变更控制。) + +| ID | 情形 | 处置 | 记录 | +| --- | --- | --- | --- | +| RD-SUP-NC-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SUP | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_regs_stds.md b/templates/docs/rd_regs_stds.md new file mode 100644 index 00000000..aee26576 --- /dev/null +++ b/templates/docs/rd_regs_stds.md @@ -0,0 +1,78 @@ +# 法规和标准清单 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-REGS +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:适用法规/标准是否识别完毕、强制与非强制是否区分。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 监管路径与目标市场 + +(写满四段式。目标市场/注册路径、器械分类假设、是否含软件/无菌/有源等。字段不足标「待确认」。) + +| 项 | 内容 | +| --- | --- | +| 目标市场 | {{target_market}} | +| 器械分类假设 | 待确认 | +| 监管路径 | 待确认 | + +## 适用法规清单 + +(写满四段式。每条写清效力、对本项目含义;禁止编造标准号。) + +| 法规 | 效力范围 | 对本项目含义 | 证据/出处 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 适用标准与指导原则 + +(写满四段式。强制标准与指导原则分列;指导原则标注「非强制」。) + +| 标准/指导原则 | 强制/非强制 | 适用条款要点 | 符合性活动 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 不适用项与理由 + +(写满四段式。明确声明不适用的法规/标准及完整理由;无则写「当前无额外不适用项」。) + +| 法规/标准 | 不适用理由 | +| --- | --- | +| 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-REGS | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_req_review.md b/templates/docs/rd_req_review.md new file mode 100644 index 00000000..94f384d2 --- /dev/null +++ b/templates/docs/rd_req_review.md @@ -0,0 +1,86 @@ +# 需求评审记录 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-REQ-REV +- 记录归属:DHF +- 版本:A +- 阶段:04 需求评审 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 评审范围与对象 + +(写满四段式。说明本阶段评审要确认什么、覆盖哪些输入/输出文件;信息不足标「待确认」。引用已批准上游文档时写出版本与批准状态。阶段语境:04 需求评审。) + +| 文件 | 版本 | 批准状态 | 与本评审关系 | +| --- | --- | --- | --- | +| REV-DOC-001 | 待确认 | 待确认 | 待确认 | + +## 评审人员与职责 + +(写满四段式。列出参评角色、姓名、职责边界;缺项写「待确认」。禁止编造人员资质。) + +| 角色 | 姓名 | 职责 | +| --- | --- | --- | +| REV-P-001 | 待确认 | 待确认 | + +## 问题与决议 + +(写满四段式。每个问题须有决议、责任人与期限;空洞「同意通过」且无实质讨论时须在解读段说明依据。缺项写「待确认」。) + +| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 | +| --- | --- | --- | --- | --- | --- | +| REV-I-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 遗留行动项跟踪 + +(写满四段式。汇总未关闭行动项、对后续阶段的阻塞影响与跟进方式。) + +| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 | +| --- | --- | --- | --- | --- | --- | +| REV-A-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 评审结论 + +(写满四段式。给出通过 / 有条件通过 / 不通过及判定依据;有条件通过须列条件与验证方式。信息不足标「待确认」。) + +| 结论项 | 内容 | +| --- | --- | +| 总体结论 | 待确认 | +| 条件/限制 | 待确认 | +| 进入下一阶段前提 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-REQ-REV | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_risk_plan.md b/templates/docs/rd_risk_plan.md new file mode 100644 index 00000000..edee8e65 --- /dev/null +++ b/templates/docs/rd_risk_plan.md @@ -0,0 +1,82 @@ +# 风险管理计划 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-RISK-PLAN +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:风险管理范围、准则与职责是否明确。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 风险管理范围与职责 + +(写满四段式。产品范围、生命周期覆盖、角色与职责矩阵。) + +| 角色 | 职责 | 权限边界 | +| --- | --- | --- | +| 风险管理负责人 | 待确认 | 待确认 | +| 设计代表 | 待确认 | 待确认 | +| 法规/质量代表 | 待确认 | 待确认 | + +## 风险可接受准则 + +(写满四段式。严重度/概率等级定义、可接受区域、需受益-风险分析的条件。禁止空壳。) + +| 等级 | 严重度定义 | 概率定义 | 可接受性 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 活动与交付物 + +(写满四段式。危害分析、风险控制、剩余风险评价、风险报告及与设计评审的衔接。) + +| 活动 | 输入 | 输出 | 责任方 | +| --- | --- | --- | --- | +| 危害分析 | 待确认 | RD-HAZARD 等 | 待确认 | +| 风险控制验证 | 待确认 | 待确认 | 待确认 | +| 风险报告 | 待确认 | 待确认 | 待确认 | + +## 评审节点表 + +(写满四段式。策划/输入/输出/验证/确认各节点的风险管理评审要求。) + +| 节点 | 评审内容 | 通过准则 | 计划时点 | +| --- | --- | --- | --- | +| 设计输入 | 待确认 | 待确认 | 待确认 | +| 设计输出 | 待确认 | 待确认 | 待确认 | +| 验证/确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-RISK-PLAN | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_risk_report.md b/templates/docs/rd_risk_report.md new file mode 100644 index 00000000..76c30a28 --- /dev/null +++ b/templates/docs/rd_risk_report.md @@ -0,0 +1,77 @@ +# 风险管理报告 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-RISK-RPT +- 记录归属:DHF +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 风险评价摘要 + +(写满四段式。给出设计输出阶段风险可接受性总体结论;缺项写「待确认」。不做疗效宣称。) + +| ID | 摘要项 | 内容 | +| --- | --- | --- | +| RD-RISK-RPT-SUM-001 | 总体可接受性 | 待确认 | +| RD-RISK-RPT-SUM-002 | 关键残留风险 | 待确认 | + +## 风险清单与控制措施 + +(写满四段式。危害、初始风险、控制措施、残余风险;禁止空表。) + +| ID | 危害 | 初始风险 | 控制措施 | 残余风险 | 验证/确认线索 | +| --- | --- | --- | --- | --- | --- | +| RD-RISK-RPT-HZ-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 剩余风险可接受性判定 + +(写满四段式。判定准则、受益-风险权衡要点、需告知用户的信息;信息不足标「待确认」。) + +| ID | 判定项 | 结论 | 依据 | +| --- | --- | --- | --- | +| RD-RISK-RPT-ACC-001 | 待确认 | 待确认 | 待确认 | + +## 与本阶段输出的关联 + +(写满四段式。本报告所依据的设计输出/验证/确认活动及版本。) + +| ID | 关联文件 | 版本 | 关系说明 | +| --- | --- | --- | --- | +| RD-RISK-RPT-LNK-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-RISK-RPT | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_risk_report_t.md b/templates/docs/rd_risk_report_t.md new file mode 100644 index 00000000..b94edbfa --- /dev/null +++ b/templates/docs/rd_risk_report_t.md @@ -0,0 +1,77 @@ +# 风险管理报告(转换阶段) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-RISK-T +- 记录归属:DHF +- 版本:A +- 阶段:10 转换 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 风险评价摘要 + +(写满四段式。给出转换阶段风险可接受性总体结论;缺项写「待确认」。不做疗效宣称。) + +| ID | 摘要项 | 内容 | +| --- | --- | --- | +| RD-RISK-T-SUM-001 | 总体可接受性 | 待确认 | +| RD-RISK-T-SUM-002 | 关键残留风险 | 待确认 | + +## 风险清单与控制措施 + +(写满四段式。危害、初始风险、控制措施、残余风险;禁止空表。) + +| ID | 危害 | 初始风险 | 控制措施 | 残余风险 | 验证/确认线索 | +| --- | --- | --- | --- | --- | --- | +| RD-RISK-T-HZ-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 剩余风险可接受性判定 + +(写满四段式。判定准则、受益-风险权衡要点、需告知用户的信息;信息不足标「待确认」。) + +| ID | 判定项 | 结论 | 依据 | +| --- | --- | --- | --- | +| RD-RISK-T-ACC-001 | 待确认 | 待确认 | 待确认 | + +## 与本阶段输出的关联 + +(写满四段式。本报告所依据的设计输出/验证/确认活动及版本。) + +| ID | 关联文件 | 版本 | 关系说明 | +| --- | --- | --- | --- | +| RD-RISK-T-LNK-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-RISK-T | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_risk_report_v.md b/templates/docs/rd_risk_report_v.md new file mode 100644 index 00000000..1f1505a1 --- /dev/null +++ b/templates/docs/rd_risk_report_v.md @@ -0,0 +1,77 @@ +# 风险管理报告(验证阶段) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-RISK-V +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 风险评价摘要 + +(写满四段式。给出验证阶段风险可接受性总体结论;缺项写「待确认」。不做疗效宣称。) + +| ID | 摘要项 | 内容 | +| --- | --- | --- | +| RD-RISK-V-SUM-001 | 总体可接受性 | 待确认 | +| RD-RISK-V-SUM-002 | 关键残留风险 | 待确认 | + +## 风险清单与控制措施 + +(写满四段式。危害、初始风险、控制措施、残余风险;禁止空表。) + +| ID | 危害 | 初始风险 | 控制措施 | 残余风险 | 验证/确认线索 | +| --- | --- | --- | --- | --- | --- | +| RD-RISK-V-HZ-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 剩余风险可接受性判定 + +(写满四段式。判定准则、受益-风险权衡要点、需告知用户的信息;信息不足标「待确认」。) + +| ID | 判定项 | 结论 | 依据 | +| --- | --- | --- | --- | +| RD-RISK-V-ACC-001 | 待确认 | 待确认 | 待确认 | + +## 与本阶段输出的关联 + +(写满四段式。本报告所依据的设计输出/验证/确认活动及版本。) + +| ID | 关联文件 | 版本 | 关系说明 | +| --- | --- | --- | --- | +| RD-RISK-V-LNK-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-RISK-V | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_risk_report_val.md b/templates/docs/rd_risk_report_val.md new file mode 100644 index 00000000..53871622 --- /dev/null +++ b/templates/docs/rd_risk_report_val.md @@ -0,0 +1,77 @@ +# 风险管理报告(确认阶段) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-RISK-VAL +- 记录归属:DHF +- 版本:A +- 阶段:12 确认 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 风险评价摘要 + +(写满四段式。给出确认阶段风险可接受性总体结论;缺项写「待确认」。不做疗效宣称。) + +| ID | 摘要项 | 内容 | +| --- | --- | --- | +| RD-RISK-VAL-SUM-001 | 总体可接受性 | 待确认 | +| RD-RISK-VAL-SUM-002 | 关键残留风险 | 待确认 | + +## 风险清单与控制措施 + +(写满四段式。危害、初始风险、控制措施、残余风险;禁止空表。) + +| ID | 危害 | 初始风险 | 控制措施 | 残余风险 | 验证/确认线索 | +| --- | --- | --- | --- | --- | --- | +| RD-RISK-VAL-HZ-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 剩余风险可接受性判定 + +(写满四段式。判定准则、受益-风险权衡要点、需告知用户的信息;信息不足标「待确认」。) + +| ID | 判定项 | 结论 | 依据 | +| --- | --- | --- | --- | +| RD-RISK-VAL-ACC-001 | 待确认 | 待确认 | 待确认 | + +## 与本阶段输出的关联 + +(写满四段式。本报告所依据的设计输出/验证/确认活动及版本。) + +| ID | 关联文件 | 版本 | 关系说明 | +| --- | --- | --- | --- | +| RD-RISK-VAL-LNK-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-RISK-VAL | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_shelf_life.md b/templates/docs/rd_shelf_life.md new file mode 100644 index 00000000..99630e1f --- /dev/null +++ b/templates/docs/rd_shelf_life.md @@ -0,0 +1,80 @@ +# 货架寿命方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SHELF +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清货架寿命研究的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SHELF-S-001 | 目的 | 待确认 | +| RD-SHELF-S-002 | 范围 | 待确认 | +| RD-SHELF-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-SHELF-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SHELF-N-001 | 样本量 | 待确认 | +| RD-SHELF-N-002 | 接受准则 | 待确认 | +| RD-SHELF-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-SHELF-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SHELF | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_sw_arch.md b/templates/docs/rd_sw_arch.md new file mode 100644 index 00000000..7bab03b5 --- /dev/null +++ b/templates/docs/rd_sw_arch.md @@ -0,0 +1,76 @@ +# 软件概要设计方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SW-ARCH +- 记录归属:DHF +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 架构/设计概述 + +(写满四段式。软件概要设计:分层、模块划分与关键算法边界。无软件成分可声明不适用。禁止编造接口协议细节。) + +| ID | 组件/模块 | 职责 | 关键接口 | 安全分级线索 | +| --- | --- | --- | --- | --- | +| RD-SW-ARCH-ARCH-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 需求追溯与设计决策 + +(写满四段式。关键设计决策、与软件需求的追溯;缺项写「待确认」。) + +| ID | 需求 ID | 设计决策 | 理由 | 验证线索 | +| --- | --- | --- | --- | --- | +| RD-SW-ARCH-DEC-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 接口与数据流 + +(写满四段式。内外部接口、数据流、错误处理边界。) + +| ID | 接口 | 方向 | 数据/协议要点 | 失败模式 | +| --- | --- | --- | --- | --- | +| RD-SW-ARCH-IF-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 配置与可维护性约束 + +(写满四段式。配置项、版本策略、维护约束;交叉引用配置及维护计划。) + +| ID | 约束项 | 要求 | 来源 | +| --- | --- | --- | --- | +| RD-SW-ARCH-CFG-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SW-ARCH | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_sw_config.md b/templates/docs/rd_sw_config.md new file mode 100644 index 00000000..62ea10b1 --- /dev/null +++ b/templates/docs/rd_sw_config.md @@ -0,0 +1,80 @@ +# 软件配置及维护计划 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SW-CFG +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:配置项识别与维护策略是否足以支撑发布与变更。无软件可声明不适用。信息不足标「待确认」。) + +本阶段可声明不适用:无软件成分时可声明不适用。 + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 配置项识别 + +(写满四段式。源代码、构建脚本、工具链、文档、第三方组件等。) + +| CI 标识 | 描述 | 责任人角色 | 基线节点 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 版本、基线与发布控制 + +(写满四段式。版本号规则、基线建立时机、发布审批入口。) + +| 活动 | 规则/时机 | 审批 | +| --- | --- | --- | +| 版本号 | 待确认 | 待确认 | +| 基线 | 待确认 | 待确认 | +| 发布 | 待确认 | 待确认 | + +## 变更与维护流程 + +(写满四段式。变更请求、影响分析、回归范围、紧急修复。) + +| 变更类型 | 流程要点 | 回归要求 | +| --- | --- | --- | +| 待确认 | 待确认 | 待确认 | + +## 第三方与工具维护 + +(写满四段式。开源/外购组件清单入口、漏洞响应、工具升级策略。) + +| 组件/工具 | 来源 | 维护策略 | +| --- | --- | --- | +| 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SW-CFG | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_sw_plan.md b/templates/docs/rd_sw_plan.md new file mode 100644 index 00000000..7b521fca --- /dev/null +++ b/templates/docs/rd_sw_plan.md @@ -0,0 +1,84 @@ +# 软件开发计划 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SW-PLAN +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:软件生命周期模型与计划是否适用。无软件成分可声明不适用并写理由。信息不足标「待确认」。) + +本阶段可声明不适用:无软件成分时可声明不适用。 + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 开发模型与软件安全分级 + +(写满四段式。选用的开发生命周期模型、软件安全分类/分级假设及依据。禁止编造标准条款号。) + +| 项 | 内容 | +| --- | --- | +| 开发模型 | 待确认 | +| 软件安全分级假设 | 待确认 | +| 依据 | 待确认 | + +## 生命周期阶段与活动 + +(写满四段式。各阶段活动、输入输出、退出准则。) + +| 阶段 | 主要活动 | 输出 | 退出准则 | +| --- | --- | --- | --- | +| 需求 | 待确认 | 待确认 | 待确认 | +| 设计 | 待确认 | 待确认 | 待确认 | +| 实现 | 待确认 | 待确认 | 待确认 | +| 验证 | 待确认 | 待确认 | 待确认 | +| 发布/维护 | 待确认 | 待确认 | 待确认 | + +## 工具、环境与配置管理 + +(写满四段式。IDE/编译器/CI、配置项范围、基线策略;详见配置及维护计划的交叉引用。) + +| 工具/配置项 | 用途 | 版本/基线策略 | +| --- | --- | --- | +| 待确认 | 待确认 | 待确认 | + +## 验证准则与问题管理 + +(写满四段式。单元/集成/系统测试准入准出、缺陷分级与关闭准则、变更控制入口。) + +| 活动 | 准入 | 准出 | 问题关闭准则 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SW-PLAN | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_sw_quality.md b/templates/docs/rd_sw_quality.md new file mode 100644 index 00000000..2ce5cc86 --- /dev/null +++ b/templates/docs/rd_sw_quality.md @@ -0,0 +1,81 @@ +# 软件质量测试报告 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SW-QA +- 记录归属:DHF +- 版本:A +- 阶段:12 确认 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 测试目的与范围 + +(写满四段式。软件质量测试目的、覆盖范围、不含项;无软件可声明不适用。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SW-QA-S-001 | 目的 | 待确认 | +| RD-SW-QA-S-002 | 范围 | 待确认 | +| RD-SW-QA-S-003 | 不含项 | 待确认 | + +## 用例与判定标准 + +(写满四段式。用例 ID、前置条件、步骤摘要、期望结果。) + +| ID | 用例 | 前置条件 | 期望结果 | 判定 | +| --- | --- | --- | --- | --- | +| RD-SW-QA-TC-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 环境、工具与配置基线 + +(写满四段式。测试环境、工具链、被测软件版本/配置基线。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SW-QA-ENV-001 | 环境 | 待确认 | +| RD-SW-QA-ENV-002 | 工具 | 待确认 | +| RD-SW-QA-ENV-003 | 被测版本 | 待确认 | + +## 缺陷与结论 + +(写满四段式。缺陷汇总、未关闭项影响、总体通过结论。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SW-QA-DEF-001 | 缺陷摘要 | 待确认 | +| RD-SW-QA-DEF-002 | 总体结论 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SW-QA | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_sw_release.md b/templates/docs/rd_sw_release.md new file mode 100644 index 00000000..0c218fbd --- /dev/null +++ b/templates/docs/rd_sw_release.md @@ -0,0 +1,68 @@ +# 软件发布清单 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SW-REL +- 记录归属:DHF / DMR +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 清单范围与纳入准则 + +(写满四段式。说明本清单覆盖范围、纳入/排除准则及与 DHF/DMR 的关系;缺项写「待确认」。) + +| ID | 准则项 | 说明 | +| --- | --- | --- | +| RD-SW-REL-CRIT-001 | 待确认 | 待确认 | + +## 清单明细 + +(写满四段式。逐项列出文件/记录;列出拟发布软件配置项、版本与介质/交付物。禁止空表头;至少一行「待确认」示例。) + +| ID | 文件/记录 | 版本 | 归属(DHF/DMR) | 状态 | 备注 | +| --- | --- | --- | --- | --- | --- | +| RD-SW-REL-ITEM-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 缺口与风险 + +(写满四段式。列出缺失项、对后续阶段的阻塞影响与补齐计划。) + +| ID | 缺口 | 影响 | 计划补齐方式 | 责任人 | +| --- | --- | --- | --- | --- | +| RD-SW-REL-GAP-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SW-REL | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_sw_reqs.md b/templates/docs/rd_sw_reqs.md new file mode 100644 index 00000000..35fe57e4 --- /dev/null +++ b/templates/docs/rd_sw_reqs.md @@ -0,0 +1,75 @@ +# 软件需求规格书 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SW-REQS +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:软件需求是否可验证且可追溯到系统/用户需求。无软件可声明不适用。信息不足标「待确认」。) + +本阶段可声明不适用:无软件成分时可声明不适用。 + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 软件范围与上下文 + +(写满四段式。软件边界、运行平台、与硬件/用户/外部系统的关系。不做疗效宣称。) + +## 功能需求 + +(写满四段式。原子需求;每条可验证。) + +| ID | 需求陈述 | 优先级 | 追溯(SR/UN) | 验证方式 | +| --- | --- | --- | --- | --- | +| SWR-F-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 性能、接口与数据需求 + +(写满四段式。性能指标、外部接口、数据保留/精度。) + +| ID | 类型 | 需求陈述 | 目标值/协议 | 验证方式 | +| --- | --- | --- | --- | --- | +| SWR-P-001 | 性能 | 待确认 | 待确认 | 待确认 | +| SWR-I-001 | 接口 | 待确认 | 待确认 | 待确认 | + +## 安全、告警与网络安全需求 + +(写满四段式。安全相关行为、告警、访问控制/网络安全;无则说明并标「待确认」或「不适用理由」。) + +| ID | 需求陈述 | 关联风险/控制 | 验证方式 | +| --- | --- | --- | --- | +| SWR-S-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SW-REQS | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_sw_sys_test.md b/templates/docs/rd_sw_sys_test.md new file mode 100644 index 00000000..0873ec63 --- /dev/null +++ b/templates/docs/rd_sw_sys_test.md @@ -0,0 +1,81 @@ +# 软件系统测试报告 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SW-ST +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 测试目的与范围 + +(写满四段式。系统测试目的、覆盖范围、不含项;无软件可声明不适用。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SW-ST-S-001 | 目的 | 待确认 | +| RD-SW-ST-S-002 | 范围 | 待确认 | +| RD-SW-ST-S-003 | 不含项 | 待确认 | + +## 用例与判定标准 + +(写满四段式。用例 ID、前置条件、步骤摘要、期望结果。) + +| ID | 用例 | 前置条件 | 期望结果 | 判定 | +| --- | --- | --- | --- | --- | +| RD-SW-ST-TC-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 环境、工具与配置基线 + +(写满四段式。测试环境、工具链、被测软件版本/配置基线。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SW-ST-ENV-001 | 环境 | 待确认 | +| RD-SW-ST-ENV-002 | 工具 | 待确认 | +| RD-SW-ST-ENV-003 | 被测版本 | 待确认 | + +## 缺陷与结论 + +(写满四段式。缺陷汇总、未关闭项影响、总体通过结论。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SW-ST-DEF-001 | 缺陷摘要 | 待确认 | +| RD-SW-ST-DEF-002 | 总体结论 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SW-ST | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_sw_ui.md b/templates/docs/rd_sw_ui.md new file mode 100644 index 00000000..15752a40 --- /dev/null +++ b/templates/docs/rd_sw_ui.md @@ -0,0 +1,76 @@ +# 软件UI设计文档 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SW-UI +- 记录归属:DHF +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 架构/设计概述 + +(写满四段式。软件UI:界面结构、交互流程与告警呈现。无软件成分可声明不适用。禁止编造接口协议细节。) + +| ID | 组件/模块 | 职责 | 关键接口 | 安全分级线索 | +| --- | --- | --- | --- | --- | +| RD-SW-UI-ARCH-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 需求追溯与设计决策 + +(写满四段式。关键设计决策、与软件需求的追溯;缺项写「待确认」。) + +| ID | 需求 ID | 设计决策 | 理由 | 验证线索 | +| --- | --- | --- | --- | --- | +| RD-SW-UI-DEC-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 接口与数据流 + +(写满四段式。内外部接口、数据流、错误处理边界。) + +| ID | 接口 | 方向 | 数据/协议要点 | 失败模式 | +| --- | --- | --- | --- | --- | +| RD-SW-UI-IF-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 配置与可维护性约束 + +(写满四段式。配置项、版本策略、维护约束;交叉引用配置及维护计划。) + +| ID | 约束项 | 要求 | 来源 | +| --- | --- | --- | --- | +| RD-SW-UI-CFG-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SW-UI | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_sw_unit_test.md b/templates/docs/rd_sw_unit_test.md new file mode 100644 index 00000000..d95e5bdf --- /dev/null +++ b/templates/docs/rd_sw_unit_test.md @@ -0,0 +1,81 @@ +# 软件单元测试报告 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SW-UT +- 记录归属:DHF +- 版本:A +- 阶段:08 验证 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 测试目的与范围 + +(写满四段式。单元测试目的、覆盖范围、不含项;无软件可声明不适用。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SW-UT-S-001 | 目的 | 待确认 | +| RD-SW-UT-S-002 | 范围 | 待确认 | +| RD-SW-UT-S-003 | 不含项 | 待确认 | + +## 用例与判定标准 + +(写满四段式。用例 ID、前置条件、步骤摘要、期望结果。) + +| ID | 用例 | 前置条件 | 期望结果 | 判定 | +| --- | --- | --- | --- | --- | +| RD-SW-UT-TC-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 环境、工具与配置基线 + +(写满四段式。测试环境、工具链、被测软件版本/配置基线。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SW-UT-ENV-001 | 环境 | 待确认 | +| RD-SW-UT-ENV-002 | 工具 | 待确认 | +| RD-SW-UT-ENV-003 | 被测版本 | 待确认 | + +## 缺陷与结论 + +(写满四段式。缺陷汇总、未关闭项影响、总体通过结论。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-SW-UT-DEF-001 | 缺陷摘要 | 待确认 | +| RD-SW-UT-DEF-002 | 总体结论 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SW-UT | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_system_design.md b/templates/docs/rd_system_design.md new file mode 100644 index 00000000..3a23952e --- /dev/null +++ b/templates/docs/rd_system_design.md @@ -0,0 +1,72 @@ +# 系统设计方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SYS-DES +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:总体架构是否满足设计输入、关键子系统与接口是否明确。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 系统概述与上下文 + +(写满四段式。产品定位、使用场景、系统边界、与外部环境/用户的关系。可用 Mermaid 上下文图并加标题。不做疗效宣称。) + +## 子系统划分与职责 + +(写满四段式。列出子系统/模块、职责、关键接口归属。) + +| 子系统 | 职责 | 关键接口 | 备注 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 关键架构决策 + +(写满四段式。技术路线、软硬件划分、安全架构要点、备选方案否决理由。) + +| 决策点 | 选用方案 | 否决方案 | 依据 | +| --- | --- | --- | --- | +| 待确认 | 待确认 | 待确认 | 待确认 | + +## 接口与数据流 + +(写满四段式。人机/电气/数据/网络接口;主数据流与控制流。未知标「待确认」。) + +| 接口 ID | 双方 | 类型 | 协议/约束 | 来源需求 | +| --- | --- | --- | --- | --- | +| IF-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SYS-DES | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_system_reqs.md b/templates/docs/rd_system_reqs.md new file mode 100644 index 00000000..f04581c8 --- /dev/null +++ b/templates/docs/rd_system_reqs.md @@ -0,0 +1,141 @@ +# 系统需求规格书(SR) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-SR +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 +- 产品:{{device_type}} +- 预期用途:{{intended_use}} +- 目标市场:{{target_market}} + +## 结论 + +(一句话:系统需求是否可验证、可追溯到用户需求与法规输入。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 文件简介 + +(本需求规格书针对 {{device_type}} 的市场需求、功能需求与临床/使用流程编写,说明系统需实现的功能与约束。) + +### 目的 + +(明确产品开发需求;作为系统设计、子系统设计、风险分析、临床、测试与验证的依据;信息不足标「待确认」。) + +### 范围 + +(含产品介绍、法规、环境、网络安全、可维护性、性能、可靠性、安全性、易用性等系统需求。) + +### 面向对象 + +(产品策划、系统设计、子系统需求设计、风险分析、临床、测试、验证人员。) + +## 产品介绍 + +### 产品说明 + +(基于材料与字段,标〔材料:文件名〕。不做疗效宣称。) + +### 产品的预期用途 + +{{intended_use}} + +### 产品的结构及组成 + +(结构与组成、关键子系统、能量源、软件/网络安全、人体接触方式等。缺项「待确认」。) + +## 需求输入 + +| 文档编号 | 文件名称 | 版本 | +| --- | --- | --- | +| RD-UN | 用户需求规格书 | 待确认 | +| RD-RISK-PLAN | 风险管理计划 | 待确认 | + +(优先引用 `prior_docs/` 中已批准文档;无 URS 时列出 PIR/技术要求等材料并说明 UR 来源。) + +## 详细需求 + +### 基本法规项 + +| 序号 | 法规项编号 | 软件开发/设计参考的法规文件 | +| --- | --- | --- | +| 1 | 待确认 | 待确认 | + +(来自材料与 `known_standards`;指导原则标注「非强制标准」。) + +### 需求标识 + +**需求分类及标识方法:RX-nnnn** + +| 类别 | 代号 | 含义 | +| --- | --- | --- | +| R | R | 需求(requirement) | +| F | F | 功能需求 | +| P | P | 性能需求 | +| I | I | 接口需求 | +| E | E | 环境需求 | +| U | U | 易用性需求 | +| R | R | 可靠性需求 | +| M | M | 可维护性需求 | +| L | L | 网络安全需求 | +| S | S | 安全需求 | +| O | O | 其他需求 | +| nnnn | 0001… | 流水号 | + +(系统需求 ID 使用 RS/RF/RP 等前缀 + 四位流水号;用户需求 ID 使用 UR-nnnn / UN-nnn。) + +## 需求描述 + +(主表:每条 applies 的系统需求须为原子表述;标准条款须拆至参考文档粒度,禁止合并多条款为一行。) + +| 用户需求ID | 系统需求ID | 系统需求描述 | 备注 | +| --- | --- | --- | --- | +| UR-001 | RS-0001 | 待确认 | 待确认 | + +## 系统需求(摘要表) + +| ID | 需求陈述 | 追溯 UN | 验证方式 | +| --- | --- | --- | --- | +| SR-001 | 待确认 | UN-001 | 待确认 | + +## 术语和缩写 + +(列出正文用到的术语与缩写;无则写 NA。) + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-SR | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 历史记录 + +| 版本 | 日期 | 修订人 | 更改内容 | +| --- | --- | --- | --- | +| A | {{generated_at}} | | 新建 | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。系统需求须追溯到需求输入中的 UR/UN 或材料。不编造未提出的功能。不做疗效宣称。冲突需求并列,不自行取舍。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_tech_req.md b/templates/docs/rd_tech_req.md new file mode 100644 index 00000000..f630a368 --- /dev/null +++ b/templates/docs/rd_tech_req.md @@ -0,0 +1,76 @@ +# 产品技术要求 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-TECH +- 记录归属:DHF / DMR +- 版本:A +- 阶段:06 设计输出 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 产品技术要求总览 + +(写满四段式。覆盖功能/性能/安全/环境等技术要求范围;缺项写「待确认」。不做疗效宣称。) + +| ID | 类别 | 要求陈述 | 验证方式 | +| --- | --- | --- | --- | +| RD-TECH-TR-001 | 待确认 | 待确认 | 待确认 | + +## 性能与安全指标 + +(写满四段式。量化指标、允差、测量条件。) + +| ID | 指标 | 目标值/范围 | 测量条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-TECH-PS-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 法规与标准符合性要点 + +(写满四段式。适用法规/标准要点;禁止编造条款号。) + +| ID | 法规/标准 | 适用要点 | 符合性证据计划 | +| --- | --- | --- | --- | +| RD-TECH-RG-001 | 待确认 | 待确认 | 待确认 | + +## 标识、标签与说明书约束 + +(写满四段式。标签要素、IFU 约束、UDI/批号等;缺项写「待确认」。) + +| ID | 约束项 | 要求 | 来源 | +| --- | --- | --- | --- | +| RD-TECH-LB-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-TECH | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_transfer_review.md b/templates/docs/rd_transfer_review.md new file mode 100644 index 00000000..295c488d --- /dev/null +++ b/templates/docs/rd_transfer_review.md @@ -0,0 +1,86 @@ +# 设计开发评审记录(转换阶段) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-XFER-REV +- 记录归属:DHF +- 版本:A +- 阶段:11 转换评审 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 评审范围与对象 + +(写满四段式。说明本阶段评审要确认什么、覆盖哪些输入/输出文件;信息不足标「待确认」。引用已批准上游文档时写出版本与批准状态。阶段语境:11 转换评审。) + +| 文件 | 版本 | 批准状态 | 与本评审关系 | +| --- | --- | --- | --- | +| REV-DOC-001 | 待确认 | 待确认 | 待确认 | + +## 评审人员与职责 + +(写满四段式。列出参评角色、姓名、职责边界;缺项写「待确认」。禁止编造人员资质。) + +| 角色 | 姓名 | 职责 | +| --- | --- | --- | +| REV-P-001 | 待确认 | 待确认 | + +## 问题与决议 + +(写满四段式。每个问题须有决议、责任人与期限;空洞「同意通过」且无实质讨论时须在解读段说明依据。缺项写「待确认」。) + +| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 | +| --- | --- | --- | --- | --- | --- | +| REV-I-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 遗留行动项跟踪 + +(写满四段式。汇总未关闭行动项、对后续阶段的阻塞影响与跟进方式。) + +| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 | +| --- | --- | --- | --- | --- | --- | +| REV-A-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 评审结论 + +(写满四段式。给出通过 / 有条件通过 / 不通过及判定依据;有条件通过须列条件与验证方式。信息不足标「待确认」。) + +| 结论项 | 内容 | +| --- | --- | +| 总体结论 | 待确认 | +| 条件/限制 | 待确认 | +| 进入下一阶段前提 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-XFER-REV | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_use_error.md b/templates/docs/rd_use_error.md new file mode 100644 index 00000000..308a349a --- /dev/null +++ b/templates/docs/rd_use_error.md @@ -0,0 +1,80 @@ +# 使用错误评估报告 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-USE-ERR +- 记录归属:DHF +- 版本:A +- 阶段:12 确认 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清使用错误评估的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-USE-ERR-S-001 | 目的 | 待确认 | +| RD-USE-ERR-S-002 | 范围 | 待确认 | +| RD-USE-ERR-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-USE-ERR-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-USE-ERR-N-001 | 样本量 | 待确认 | +| RD-USE-ERR-N-002 | 接受准则 | 待确认 | +| RD-USE-ERR-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-USE-ERR-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-USE-ERR | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_user_needs.md b/templates/docs/rd_user_needs.md new file mode 100644 index 00000000..afed456a --- /dev/null +++ b/templates/docs/rd_user_needs.md @@ -0,0 +1,78 @@ +# 用户需求规格书(UN) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-UN +- 记录归属:DHF +- 版本:A +- 阶段:03 设计输入 + +## 结论 + +(一句话:用户需求是否完整、可验证且可向下追溯。信息不足标「待确认」。不适用则写「本文件不适用」及理由。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 用户与使用场景 + +(写满四段式。目标用户、使用环境、操作流程与痛点。不做疗效宣称。) + +| 项 | 内容 | +| --- | --- | +| 目标用户 | 待确认 | +| 使用环境 | 待确认 | +| 关键操作流程 | 待确认 | + +## 用户需求 + +(写满:每条原子需求;空行用「待确认」示例填满至少一行。) + +| ID | 需求陈述 | 来源 | 优先级 | 验证方式 | +| --- | --- | --- | --- | --- | +| UN-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 约束与非功能期望 + +(写满四段式。法规/标准约束、可用性、可靠性、服务与培训期望。) + +| ID | 类型 | 陈述 | 优先级 | +| --- | --- | --- | --- | +| UN-N-001 | 待确认 | 待确认 | 待确认 | + +## 冲突与待澄清 + +(写满四段式。相互冲突的用户诉求并列,不自行取舍。) + +| 冲突/澄清项 | 选项 | 建议下一步 | +| --- | --- | --- | +| 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-UN | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_user_test.md b/templates/docs/rd_user_test.md new file mode 100644 index 00000000..15516f1e --- /dev/null +++ b/templates/docs/rd_user_test.md @@ -0,0 +1,80 @@ +# 用户测试报告 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-USER-T +- 记录归属:DHF +- 版本:A +- 阶段:12 确认 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清用户测试的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-USER-T-S-001 | 目的 | 待确认 | +| RD-USER-T-S-002 | 范围 | 待确认 | +| RD-USER-T-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-USER-T-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-USER-T-N-001 | 样本量 | 待确认 | +| RD-USER-T-N-002 | 接受准则 | 待确认 | +| RD-USER-T-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-USER-T-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-USER-T | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_validation_plan.md b/templates/docs/rd_validation_plan.md new file mode 100644 index 00000000..4b46fb33 --- /dev/null +++ b/templates/docs/rd_validation_plan.md @@ -0,0 +1,80 @@ +# 设计确认方案 + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-VAL-PLAN +- 记录归属:DHF +- 版本:A +- 阶段:12 确认 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 目的与范围 + +(写满四段式。写清设计确认的目的、适用范围、不适用边界;缺项写「待确认」。不做疗效宣称。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-VAL-PLAN-S-001 | 目的 | 待确认 | +| RD-VAL-PLAN-S-002 | 范围 | 待确认 | +| RD-VAL-PLAN-S-003 | 不适用边界 | 待确认 | + +## 方法与判定标准 + +(写满四段式。试验/评估方法、设备/环境前提、通过/失败判定标准;禁止编造标准条款号,未知标「待确认」。) + +| ID | 方法/步骤 | 判定标准 | 适用条件 | 来源 | +| --- | --- | --- | --- | --- | +| RD-VAL-PLAN-M-001 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 样本量与统计(如适用) + +(写满四段式。样本量依据、接受准则、统计方法;不适用写「不适用」及理由。) + +| ID | 项 | 内容 | +| --- | --- | --- | +| RD-VAL-PLAN-N-001 | 样本量 | 待确认 | +| RD-VAL-PLAN-N-002 | 接受准则 | 待确认 | +| RD-VAL-PLAN-N-003 | 统计方法 | 待确认 | + +## 风险与残余风险关联 + +(写满四段式。关联风险管理文件中的危害/控制措施;无关联写「待确认」并说明后续更新点。) + +| ID | 关联危害/控制 | 本方案验证点 | 残余风险结论线索 | +| --- | --- | --- | --- | +| RD-VAL-PLAN-R-001 | 待确认 | 待确认 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-VAL-PLAN | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/templates/docs/rd_verification_review.md b/templates/docs/rd_verification_review.md new file mode 100644 index 00000000..09f24896 --- /dev/null +++ b/templates/docs/rd_verification_review.md @@ -0,0 +1,86 @@ +# 设计开发评审记录(验证阶段) + +- 项目:{{project_name}} +- 生成时间:{{generated_at}} +- 快照:{{snapshot_id}} +- 文件编号:RD-VER-REV +- 记录归属:DHF +- 版本:A +- 阶段:09 验证评审 + +## 结论 + +(一句话概括本文件核心结论。信息不足标「待确认」。不适用则写「本文件不适用」及理由。不做疗效宣称。) + +## 适用性 + +- 适用 / 不适用:{{applicability}} +- 不适用理由:{{na_rationale}} + +## 评审范围与对象 + +(写满四段式。说明本阶段评审要确认什么、覆盖哪些输入/输出文件;信息不足标「待确认」。引用已批准上游文档时写出版本与批准状态。阶段语境:09 验证评审。) + +| 文件 | 版本 | 批准状态 | 与本评审关系 | +| --- | --- | --- | --- | +| REV-DOC-001 | 待确认 | 待确认 | 待确认 | + +## 评审人员与职责 + +(写满四段式。列出参评角色、姓名、职责边界;缺项写「待确认」。禁止编造人员资质。) + +| 角色 | 姓名 | 职责 | +| --- | --- | --- | +| REV-P-001 | 待确认 | 待确认 | + +## 问题与决议 + +(写满四段式。每个问题须有决议、责任人与期限;空洞「同意通过」且无实质讨论时须在解读段说明依据。缺项写「待确认」。) + +| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 | +| --- | --- | --- | --- | --- | --- | +| REV-I-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 遗留行动项跟踪 + +(写满四段式。汇总未关闭行动项、对后续阶段的阻塞影响与跟进方式。) + +| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 | +| --- | --- | --- | --- | --- | --- | +| REV-A-001 | 待确认 | 待确认 | 待确认 | 待确认 | 待确认 | + +## 评审结论 + +(写满四段式。给出通过 / 有条件通过 / 不通过及判定依据;有条件通过须列条件与验证方式。信息不足标「待确认」。) + +| 结论项 | 内容 | +| --- | --- | +| 总体结论 | 待确认 | +| 条件/限制 | 待确认 | +| 进入下一阶段前提 | 待确认 | + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +| --- | --- | --- | +| RD-VER-REV | | | + +## 已有材料摘要 + +{{materials}} + +## 待确认清单 + +- (列出本阶段仍缺、会影响后续设计/验证/确认的项) + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +| --- | --- | --- | --- | +| 编制 | | 已编制 | {{generated_at}} | +| 审核 | | | | +| 批准 | | | | + +## 说明 + +本文档由研发小队在任务快照上生成,属于设计历史记录。后续补充的材料不会改变本次内容。 diff --git a/web/e2e/README.md b/web/e2e/README.md new file mode 100644 index 00000000..4ac20830 --- /dev/null +++ b/web/e2e/README.md @@ -0,0 +1,20 @@ +# Playwright E2E 入口矩阵 + +默认 `playwright test` / `npm run test:e2e:list` 只枚举用例,**不实跑**(各 spec 内部有门禁 skip)。 + +| Spec | 门禁变量 | npm 脚本 | 备注 | +|------|----------|----------|------| +| `document-revision-runtime.spec.ts` | `RUN_UI_ACCEPTANCE=1` | `npm run test:ui-acceptance` | 主 UI-acceptance(含版本历史 UI-10) | +| `admin-console.spec.ts` | `RUN_UI_ACCEPTANCE=1` | `npm run test:e2e:admin-console` | 需管理员账号 | +| `controlled-document-reload.spec.ts` | `RUN_UI_ACCEPTANCE=1` | `npm run test:e2e:controlled-document-reload` | | +| `governance-action-level-authz.spec.ts` | `RUN_UI_ACCEPTANCE=1` | `npm run test:e2e:governance-action-level-authz` | | +| `intent-plan-cards.spec.ts` | `RUN_UI_ACCEPTANCE=1` | `npm run test:e2e:intent-plan-cards` | | +| `project143-regression.spec.ts` | `RUN_UI_ACCEPTANCE=1` | `npm run test:e2e:project143-regression` | 项目 143 只读 | +| `workflow-plan-409-frozen-duties.spec.ts` | `RUN_UI_ACCEPTANCE=1` | `npm run test:e2e:workflow-plan-409-frozen-duties` | | +| `conversation-intent-dual-tab.spec.ts` | `INTENT_DUAL_TAB=1` | `npm run test:e2e:conversation-intent-dual-tab` | 独立双标签风险口径 | + +约定: + +- `playwright test --list` 期望:**29 tests in 8 files**(Wave 3 退役冗余 `document-revision.spec.ts` / 消灭 `DOCREV_E2E_BASE_URL`)。 +- 新增 spec 必须同时补本表与 `package.json` 脚本。 +- 不升 `MULTI_INTENT_PLAN_TIER`;不触碰项目 143 写路径。 diff --git a/web/e2e/document-revision.spec.ts b/web/e2e/document-revision.spec.ts deleted file mode 100644 index 4557cebe..00000000 --- a/web/e2e/document-revision.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Smoke coverage for DOCREV toolbar / compare entry points. - * Full dual-user conflict E2E requires authenticated env fixtures. - */ -test.describe('document revision UI', () => { - test.skip(!process.env.DOCREV_E2E_BASE_URL, 'set DOCREV_E2E_BASE_URL to run'); - - test('toolbar renders version history control', async ({ page }) => { - await page.goto(process.env.DOCREV_E2E_BASE_URL!); - await expect(page.getByRole('button', { name: '版本历史' })).toBeVisible({ timeout: 30_000 }); - }); -}); diff --git a/web/package.json b/web/package.json index 89f3d531..4930e6c5 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,15 @@ "build": "tsc && vite build && node scripts/precompress-dist.mjs", "preview": "vite preview", "test": "tsx --test \"src/**/!(*InteractiveCard*|*chatWindowArtifact*|*deliverableSync*).test.ts\" && vitest run src/components/chat/InteractiveCard.test.ts src/components/chat/chatWindowArtifact.test.ts src/components/chat/deliverableSync.test.ts", - "test:ui-acceptance": "RUN_UI_ACCEPTANCE=1 playwright test e2e/document-revision-runtime.spec.ts --project=chromium" + "test:ui-acceptance": "RUN_UI_ACCEPTANCE=1 playwright test e2e/document-revision-runtime.spec.ts --project=chromium", + "test:e2e:admin-console": "RUN_UI_ACCEPTANCE=1 playwright test e2e/admin-console.spec.ts --project=chromium", + "test:e2e:controlled-document-reload": "RUN_UI_ACCEPTANCE=1 playwright test e2e/controlled-document-reload.spec.ts --project=chromium", + "test:e2e:conversation-intent-dual-tab": "INTENT_DUAL_TAB=1 playwright test e2e/conversation-intent-dual-tab.spec.ts --project=chromium", + "test:e2e:governance-action-level-authz": "RUN_UI_ACCEPTANCE=1 playwright test e2e/governance-action-level-authz.spec.ts --project=chromium", + "test:e2e:intent-plan-cards": "RUN_UI_ACCEPTANCE=1 playwright test e2e/intent-plan-cards.spec.ts --project=chromium", + "test:e2e:project143-regression": "RUN_UI_ACCEPTANCE=1 playwright test e2e/project143-regression.spec.ts --project=chromium", + "test:e2e:workflow-plan-409-frozen-duties": "RUN_UI_ACCEPTANCE=1 playwright test e2e/workflow-plan-409-frozen-duties.spec.ts --project=chromium", + "test:e2e:list": "playwright test --list" }, "dependencies": { "@tanstack/react-virtual": "^3.14.11", diff --git a/web/src/App.tsx b/web/src/App.tsx index 262899d1..8fca6e4d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -49,6 +49,17 @@ import { import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter'; import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync'; import { resolveCurrentDocRole } from './lib/currentDocRole'; +import { holdsDocDuty } from './lib/docGovActionVisibility'; +import { + claimDocument, + submitForReview, + reviewDecision, + approverDecision, + proposeModification, + decideModificationProposal, + mapLeaseToAstEditLease, + type DocGovRecord, +} from './api/docgov'; import { canAddBlockComment } from './lib/commentPolicy'; import { pollUntilPostTaskServerDoc, @@ -219,7 +230,7 @@ import { useUnreadMarks } from './hooks/useUnreadMarks'; import { CategoryDock } from './components/chat/CategoryDock'; import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow'; import { ChatCategoryStat, ChatDateStat, QuotedMessage, AssistantMessageStatus } from './types/workbench'; -import { saveFile, restoreFileRawToDeliverables } from './data/fileStore'; +import { saveFile, restoreFileRawToDeliverables, applyRestoredFileRaw } from './data/fileStore'; import { uploadProjectAsset } from './api/projectAssets'; import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns'; @@ -322,14 +333,13 @@ export default function App() { }, [deliverables]); // ✅ 启动时从 IndexedDB 恢复上传文件的 fileRaw(解决页面刷新后 File 对象丢失问题) - // 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch。 + // 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch(函数式合并,不覆盖期间新增)。 useEffect(() => { let cancelled = false; - restoreFileRawToDeliverables(deliverables).then((restored) => { - if (!cancelled && restored !== deliverables) { - // 只有真正有内容被恢复时才更新 state(避免无意义的 re-render) - setDeliverables(restored); - } + const snapshot = deliverablesRef.current; + restoreFileRawToDeliverables(snapshot).then((restored) => { + if (cancelled || restored === snapshot) return; + setDeliverables((prev) => applyRestoredFileRaw(prev, restored)); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -592,13 +602,7 @@ export default function App() { }; }, [session, sessionValidated, currentUser.id, projects]); - const handleLogout = () => { - void authClient.logout().finally(() => { - setSession(null); - setCapabilities([]); - setSessionValidated(false); - }); - }; + // handleLogout 定义在 turnRuntime/taskRunStream hook 之后,便于 abort 全会话在途流。 // 核心功能权限计算 const canCreateProject = useMemo(() => { @@ -1548,11 +1552,13 @@ export default function App() { const [commentingBlock, setCommentingBlock] = useState(null); const [highlightedBlockId, setHighlightedBlockId] = useState(null); const [pendingProposal, setPendingProposal] = useState<{ + proposalId: string; changes: string; impact: string; risk: string; patchBlockId?: string; } | null>(null); + const [proposalError, setProposalError] = useState(null); // 消息流:权威来源为服务端窗口;不再全量持久化到 localStorage const [messages, setMessages] = useState([]); @@ -2809,6 +2815,16 @@ export default function App() { } }; + const handleLogout = () => { + turnRuntime.abortAllSessions(); + taskRunStream.abortAllSessions(); + void authClient.logout().finally(() => { + setSession(null); + setCapabilities([]); + setSessionValidated(false); + }); + }; + // 发送消息与对话驱动双轨逻辑 const handleSendMessage = async ( text: string, @@ -2934,8 +2950,18 @@ export default function App() { currentDocRole === 'author'; const isReviewPassShortcut = (text.includes('通过') || text.includes('同意')) && - ((docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') || - (docAST.lifecycleState === 'approving' && currentDocRole === 'approver')); + ((docAST.lifecycleState === 'reviewing' && + holdsDocDuty(currentUser.id, 'reviewer', { + authorId: docAST.authorId, + reviewerId: docAST.reviewerId, + approverId: docAST.approverId, + })) || + (docAST.lifecycleState === 'approving' && + holdsDocDuty(currentUser.id, 'approver', { + authorId: docAST.authorId, + reviewerId: docAST.reviewerId, + approverId: docAST.approverId, + }))); const sendActiveDoc = deliverables.find((d) => d.docId === activeDocId) || visibleDeliverables.find((d) => d.docId === activeDocId); @@ -3169,31 +3195,68 @@ export default function App() { if (text.includes('修改') || text.includes('更新') || text.includes('调整')) { if (docAST.isClaimed && currentDocRole === 'author') { - setPendingProposal({ - changes: '拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。', - impact: '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。', - risk: '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。', - }); - - setMessages((prev) => [ - ...prev, - { - id: 'ast-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '🛡️ 文档认领保护机制拦截', - content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】。请您确认是否准许本次修改。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); + const changes = + '拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。'; + const impact = '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。'; + const risk = '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。'; + const docId = activeDocIdRef.current; + void (async () => { + try { + setProposalError(null); + const prop = await proposeModification({ + doc_id: docId, + triggered_by: 'web_ui', + proposed_changes: changes, + impact_analysis: impact, + risk_assessment: risk, + }); + setPendingProposal({ + proposalId: prop.proposal_id, + changes: prop.proposed_changes || changes, + impact: prop.impact_analysis || impact, + risk: prop.risk_assessment || risk, + }); + setMessages((prev) => [ + ...prev, + { + id: 'ast-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🛡️ 文档认领保护机制拦截', + content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】(${prop.proposal_id})。请您确认是否准许本次修改。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setProposalError(message); + setMessages((prev) => [ + ...prev, + { + id: 'ast-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '⚠️ 变更提案失败', + content: `未能创建变更提案:${message}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } + })(); return; } } if (text.includes('通过') || text.includes('同意')) { - if (docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') { - handlePassReview(); + const duties = { + authorId: docAST.authorId, + reviewerId: docAST.reviewerId, + approverId: docAST.approverId, + }; + if (docAST.lifecycleState === 'reviewing' && holdsDocDuty(currentUser.id, 'reviewer', duties)) { + void handlePassReview(); setMessages((prev) => [ ...prev, { @@ -3208,8 +3271,8 @@ export default function App() { ]); return; } - if (docAST.lifecycleState === 'approving' && currentDocRole === 'approver') { - handleApprove(); + if (docAST.lifecycleState === 'approving' && holdsDocDuty(currentUser.id, 'approver', duties)) { + void handleApprove(); return; } } @@ -3220,32 +3283,106 @@ export default function App() { return { accepted: true }; }; - // 编制人接受变更提议 + // 编制人接受变更提议(经 HTTP decide) const handleAcceptProposal = () => { - setPendingProposal(null); - const children = docAST.rootBlock.children || []; - const pBlock = children.find((c) => c.type === 'paragraph'); - if (pBlock) { - pBlock.content += '\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。'; - pBlock.metadata = { - lastModifiedBy: '智能体协同小队 (经张工确认签署)', - lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), - }; - setDocAST({ ...docAST }); - } + if (!pendingProposal?.proposalId) return; + const proposalId = pendingProposal.proposalId; + void (async () => { + try { + setProposalError(null); + await decideModificationProposal({ + proposal_id: proposalId, + approve: true, + reason: '编制人确认采纳变更提案', + }); + setPendingProposal(null); + const children = docAST.rootBlock.children || []; + const pBlock = children.find((c) => c.type === 'paragraph'); + if (pBlock) { + pBlock.content += + '\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。'; + pBlock.metadata = { + lastModifiedBy: '智能体协同小队 (经张工确认签署)', + lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), + }; + setDocAST({ ...docAST }); + } - setMessages((prev) => [ - ...prev, - { - id: 'ast-p-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '✅ 协同变更已生效', - content: `变更提议已成功执行并落盘至《${docAST.title}》,受控文档已同步更新。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); + setMessages((prev) => [ + ...prev, + { + id: 'ast-p-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '✅ 协同变更已生效', + content: `变更提议 ${proposalId} 已批准并落盘至《${docAST.title}》,受控文档已同步更新。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setProposalError(message); + setMessages((prev) => [ + ...prev, + { + id: 'ast-p-err-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '⚠️ 批准提案失败', + content: `批准变更提案失败:${message}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } + })(); + }; + + const handleRejectProposal = () => { + if (!pendingProposal?.proposalId) { + setPendingProposal(null); + return; + } + const proposalId = pendingProposal.proposalId; + void (async () => { + try { + setProposalError(null); + await decideModificationProposal({ + proposal_id: proposalId, + approve: false, + reason: '编制人拒绝本次变更', + }); + setPendingProposal(null); + setMessages((prev) => [ + ...prev, + { + id: 'ast-p-rej-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🚫 变更提案已拒绝', + content: `已拒绝变更提议 ${proposalId},受控文档保持原状。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setProposalError(message); + setMessages((prev) => [ + ...prev, + { + id: 'ast-p-rej-err-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '⚠️ 拒绝提案失败', + content: `拒绝变更提案失败:${message}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } + })(); }; // 块操作:编排层先 PATCH,成功才提交 AST;失败保留编辑器并明确提示 @@ -3328,80 +3465,189 @@ export default function App() { setDocAST({ ...docAST }); }; - // 法定生命周期流转 - const handleClaim = () => { - docAST.isClaimed = true; - setDocAST({ ...docAST }); + // 法定生命周期流转(真实 docgov HTTP) + const applyDocGovRecord = (doc: DocGovRecord) => { + const nextState = (doc.current_state || docAST.lifecycleState) as DocLifecycleState; + const nextClaimed = doc.author_claimed ?? docAST.isClaimed; + const lease = mapLeaseToAstEditLease(doc); + const nextAst: DocumentAST = { + ...docAST, + lifecycleState: nextState, + isClaimed: nextClaimed, + authorId: doc.author_id ?? docAST.authorId, + author: doc.author_name || docAST.author, + reviewerId: doc.reviewer_id ?? docAST.reviewerId, + reviewer: doc.reviewer_name || docAST.reviewer, + approverId: doc.approver_id ?? docAST.approverId, + approver: doc.approver_name || docAST.approver, + dutyPolicy: doc.duty_policy || docAST.dutyPolicy, + contentSha256: doc.content_sha256 || docAST.contentSha256, + editLease: lease ?? docAST.editLease, + }; + setDocAST(nextAst); setDeliverables((prev) => - prev.map((d) => (d.docId === activeDocId ? { ...d, isClaimed: true } : d)) + prev.map((d) => + d.docId === activeDocId || d.docId === doc.doc_id + ? { + ...d, + lifecycleState: nextState, + isClaimed: nextClaimed, + authorId: doc.author_id ?? d.authorId, + author: doc.author_name || d.author, + reviewerId: doc.reviewer_id ?? d.reviewerId, + reviewer: doc.reviewer_name || d.reviewer, + approverId: doc.approver_id ?? d.approverId, + approver: doc.approver_name || d.approver, + dutyPolicy: doc.duty_policy || d.dutyPolicy, + editLease: lease ?? d.editLease, + } + : d, + ), ); }; - const handleSubmitReview = () => { - docAST.lifecycleState = 'reviewing'; - setDocAST({ ...docAST }); - setDeliverables((prev) => - prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'reviewing' } : d)) - ); - }; - - const handlePassReview = () => { - docAST.lifecycleState = 'approving'; - setDocAST({ ...docAST }); - setDeliverables((prev) => - prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'approving' } : d)) - ); - }; - - const handleRejectReview = (reason: string) => { - docAST.lifecycleState = 'drafting'; - setDocAST({ ...docAST }); - setDeliverables((prev) => - prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'drafting' } : d)) - ); - + const pushDocGovError = (actionLabel: string, err: unknown) => { + const message = err instanceof Error ? err.message : String(err); setMessages((prev) => [ ...prev, { - id: 'rej-' + Date.now(), + id: 'docgov-err-' + Date.now(), sessionId: activeSessionId, role: 'system', - content: `⚠️ 李主管(审核负责人)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`, + content: `⚠️ ${actionLabel}失败:${message}`, timestamp: new Date().toTimeString().substring(0, 5), }, ]); }; - const handleApprove = () => { - docAST.lifecycleState = 'approved'; - setDocAST({ ...docAST }); - setDeliverables((prev) => - prev.map((d) => (d.docId === activeDocId ? { ...d, lifecycleState: 'approved' } : d)) - ); + const handleClaim = async () => { + try { + const doc = await claimDocument({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + user_role: 'author', + }); + applyDocGovRecord(doc); + } catch (err) { + pushDocGovError('认领文档', err); + } + }; - setMessages((prev) => [ - ...prev, - { - id: 'appr-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '🏆 终审批准生效', - content: `🎉 祝贺!王总监(批准人)已终审批准《${docAST.title}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`, - timestamp: new Date().toTimeString().substring(0, 5), - recommendedCases: [ - { - docId: docAST.docId, - taskKey: docAST.taskKey, - artifactTitle: docAST.title, - sectionTitle: '终审已生效标杆案例', - similarityScore: 1.0, - keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'], - summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。', - }, - ], - }, - ]); + const handleSubmitReview = async () => { + try { + const doc = await submitForReview({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + }); + applyDocGovRecord(doc); + } catch (err) { + pushDocGovError('提交送审', err); + } + }; + + const handlePassReview = async () => { + try { + const doc = await reviewDecision({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + action: 'approve', + }); + applyDocGovRecord(doc); + } catch (err) { + pushDocGovError('审核通过', err); + } + }; + + const handleRejectReview = async (reason: string) => { + try { + const doc = await reviewDecision({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + action: 'reject', + reason, + }); + applyDocGovRecord(doc); + setMessages((prev) => [ + ...prev, + { + id: 'rej-' + Date.now(), + sessionId: activeSessionId, + role: 'system', + content: `⚠️ ${currentUser.name}(审核)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } catch (err) { + pushDocGovError('审核驳回', err); + } + }; + + const handleApprove = async () => { + try { + const doc = await approverDecision({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + action: 'approve', + }); + applyDocGovRecord(doc); + setMessages((prev) => [ + ...prev, + { + id: 'appr-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🏆 终审批准生效', + content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${docAST.title}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`, + timestamp: new Date().toTimeString().substring(0, 5), + recommendedCases: [ + { + docId: docAST.docId, + taskKey: docAST.taskKey, + artifactTitle: docAST.title, + sectionTitle: '终审已生效标杆案例', + similarityScore: 1.0, + keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'], + summarySnippet: + '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。', + }, + ], + }, + ]); + } catch (err) { + pushDocGovError('终审批准', err); + } + }; + + const handleRejectApprove = async (reason: string) => { + try { + const doc = await approverDecision({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + action: 'reject', + reason, + rollback_to: 'drafting', + }); + applyDocGovRecord(doc); + setMessages((prev) => [ + ...prev, + { + id: 'rej-appr-' + Date.now(), + sessionId: activeSessionId, + role: 'system', + content: `⚠️ ${currentUser.name}(批准人)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } catch (err) { + pushDocGovError('批准驳回', err); + } }; // 导出 DOCX (Word 渲染器) @@ -3931,12 +4177,17 @@ export default function App() { {pendingProposal && (
+ {proposalError && ( +
+ {proposalError} +
+ )} setPendingProposal(null)} + onReject={handleRejectProposal} />
)} @@ -4206,7 +4457,7 @@ export default function App() { onPassReview={handlePassReview} onRejectReview={handleRejectReview} onApprove={handleApprove} - onRejectApprove={handleRejectReview} + onRejectApprove={handleRejectApprove} onShareToProject={handleShareToProject} onRevokeShare={handleRevokeShare} /> diff --git a/web/src/api/chatSessions.test.ts b/web/src/api/chatSessions.test.ts new file mode 100644 index 00000000..89c330c2 --- /dev/null +++ b/web/src/api/chatSessions.test.ts @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { upsertChatSession } from './chatSessions'; +import { ProjectSettingsApiError } from './projectSettingsError'; + +test('upsertChatSession surfaces safe INTERNAL envelope instead of raw JSON text', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + code: 'INTERNAL', + message: '请求无法完成', + request_id: 'req-fixture-1', + error: 'INTERNAL', + }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ); + try { + await assert.rejects( + () => upsertChatSession({ session_id: 's1', title: 't', user_id: 1, project_id: 143 }), + (err: unknown) => { + assert.ok(err instanceof ProjectSettingsApiError); + assert.equal((err as ProjectSettingsApiError).status, 500); + assert.equal((err as ProjectSettingsApiError).message, '请求无法完成'); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/web/src/api/chatSessions.ts b/web/src/api/chatSessions.ts index 7bead403..936dfc09 100644 --- a/web/src/api/chatSessions.ts +++ b/web/src/api/chatSessions.ts @@ -1,4 +1,5 @@ import { getBearerAuthHeaders } from './authHeaders'; +import { projectSettingsErrorFromResponse } from './projectSettingsError'; export interface ChatSessionSummary { session_id: string; @@ -50,7 +51,7 @@ export async function upsertChatSession(input: UpsertChatSessionInput): Promise< }); if (!res.ok) { const text = await res.text(); - throw new Error(text || `注册会话失败: ${res.status}`); + throw projectSettingsErrorFromResponse(res.status, text, `注册会话失败: ${res.status}`); } return res.json(); } diff --git a/web/src/api/chatStream.test.ts b/web/src/api/chatStream.test.ts index 1320dce3..5d1e2fd5 100644 --- a/web/src/api/chatStream.test.ts +++ b/web/src/api/chatStream.test.ts @@ -33,3 +33,13 @@ test('parseSSEChunk: ignores heartbeat comments and keeps last id', () => { assert.equal(events.length, 2); assert.equal(lastSeq, 2); }); + +test('parseSSEChunk: skips a single malformed frame and keeps parsing the next valid frame', () => { + const raw = + 'id: 1\nevent: delta\ndata: {not valid json\n\n' + + 'id: 2\nevent: delta\ndata: {"type":"delta","delta":"ok"}\n\n'; + const { events, lastSeq } = parseSSEChunk(raw); + assert.equal(events.length, 1); + assert.equal(events[0].delta, 'ok'); + assert.equal(lastSeq, 2); +}); diff --git a/web/src/api/chatStream.ts b/web/src/api/chatStream.ts index 70b921fa..255eade0 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -119,7 +119,12 @@ function parseFrame(frame: string): { event: StreamEvent | null; seq?: number } // SSE comment / heartbeat frames (": ...") have no data — ignore. if (!dataLine) return { event: null, seq }; - return { event: JSON.parse(dataLine) as StreamEvent, seq }; + try { + return { event: JSON.parse(dataLine) as StreamEvent, seq }; + } catch (err) { + console.warn('[sse] 忽略无法解析的帧:', dataLine.slice(0, 200), err); + return { event: null, seq }; + } } export function parseSSEChunk( diff --git a/web/src/api/docgov.test.ts b/web/src/api/docgov.test.ts new file mode 100644 index 00000000..bd6179b4 --- /dev/null +++ b/web/src/api/docgov.test.ts @@ -0,0 +1,188 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + claimDocument, + submitForReview, + reviewDecision, + approverDecision, + proposeModification, + decideModificationProposal, +} from './docgov'; + +const sampleRecord = { + doc_id: 'doc-1', + current_state: 'reviewing', + author_id: 7, + author_name: 'A', + reviewer_id: 7, + reviewer_name: 'A', + approver_id: 7, + approver_name: 'A', + author_claimed: true, + duty_policy: 'author_only', +}; + +describe('docgov lifecycle API client', () => { + const originalFetch = globalThis.fetch; + const originalWindow = (globalThis as { window?: unknown }).window; + let lastCall: { url: string; init?: RequestInit } | null = null; + + beforeEach(() => { + lastCall = null; + const localStorage = { + getItem: () => + JSON.stringify({ token: 'test-token', user: { id: 7, name: 'A' } }), + setItem: () => undefined, + removeItem: () => undefined, + }; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + lastCall = { url: String(input), init }; + return new Response(JSON.stringify(sampleRecord), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + globalThis.fetch = mockFetch; + (globalThis as { window: unknown }).window = { fetch: mockFetch, localStorage }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window: unknown }).window = originalWindow; + } + }); + + it('claimDocument posts to /api/v1/docgov/claim with author role', async () => { + const doc = await claimDocument({ + doc_id: 'doc-1', + user_id: 7, + user_name: 'A', + user_role: 'author', + }); + assert.equal(lastCall?.url, '/api/v1/docgov/claim'); + assert.equal(lastCall?.init?.method, 'POST'); + const body = JSON.parse(String(lastCall?.init?.body)); + assert.deepEqual(body, { + doc_id: 'doc-1', + user_id: 7, + user_name: 'A', + user_role: 'author', + }); + assert.equal(doc.duty_policy, 'author_only'); + }); + + it('submitForReview posts to /api/v1/docgov/submit-review', async () => { + await submitForReview({ doc_id: 'doc-1', user_id: 7, user_name: 'A' }); + assert.equal(lastCall?.url, '/api/v1/docgov/submit-review'); + assert.equal(lastCall?.init?.method, 'POST'); + const body = JSON.parse(String(lastCall?.init?.body)); + assert.deepEqual(body, { doc_id: 'doc-1', user_id: 7, user_name: 'A' }); + }); + + it('reviewDecision posts action and optional reason', async () => { + await reviewDecision({ + doc_id: 'doc-1', + user_id: 7, + user_name: 'A', + action: 'reject', + reason: '缺条款', + }); + assert.equal(lastCall?.url, '/api/v1/docgov/review-decision'); + const body = JSON.parse(String(lastCall?.init?.body)); + assert.equal(body.action, 'reject'); + assert.equal(body.reason, '缺条款'); + }); + + it('approverDecision posts action and rollback_to', async () => { + await approverDecision({ + doc_id: 'doc-1', + user_id: 7, + user_name: 'A', + action: 'reject', + reason: '需重写', + rollback_to: 'drafting', + }); + assert.equal(lastCall?.url, '/api/v1/docgov/approver-decision'); + const body = JSON.parse(String(lastCall?.init?.body)); + assert.equal(body.action, 'reject'); + assert.equal(body.rollback_to, 'drafting'); + }); + + it('proposeModification posts to /api/v1/docgov/proposal', async () => { + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + lastCall = { url: String(input), init }; + return new Response( + JSON.stringify({ + proposal_id: 'prop_1', + doc_id: 'doc-1', + proposed_changes: '改一处', + status: 'pending_author_approval', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }) as typeof fetch; + globalThis.fetch = mockFetch; + (globalThis as { window: { fetch: typeof fetch; localStorage: unknown } }).window = { + fetch: mockFetch, + localStorage: { + getItem: () => + JSON.stringify({ token: 'test-token', user: { id: 7, name: 'A' } }), + setItem: () => undefined, + removeItem: () => undefined, + }, + }; + + const prop = await proposeModification({ + doc_id: 'doc-1', + triggered_by: 'web_ui', + proposed_changes: '改一处', + impact_analysis: '影响小', + risk_assessment: '低风险', + }); + assert.equal(lastCall?.url, '/api/v1/docgov/proposal'); + assert.equal(lastCall?.init?.method, 'POST'); + const body = JSON.parse(String(lastCall?.init?.body)); + assert.equal(body.doc_id, 'doc-1'); + assert.equal(body.proposed_changes, '改一处'); + assert.equal(prop.proposal_id, 'prop_1'); + }); + + it('decideModificationProposal posts to /api/v1/docgov/proposal/decide', async () => { + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + lastCall = { url: String(input), init }; + return new Response( + JSON.stringify({ + proposal_id: 'prop_1', + doc_id: 'doc-1', + proposed_changes: '改一处', + status: 'approved_by_author', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }) as typeof fetch; + globalThis.fetch = mockFetch; + (globalThis as { window: { fetch: typeof fetch; localStorage: unknown } }).window = { + fetch: mockFetch, + localStorage: { + getItem: () => + JSON.stringify({ token: 'test-token', user: { id: 7, name: 'A' } }), + setItem: () => undefined, + removeItem: () => undefined, + }, + }; + + const prop = await decideModificationProposal({ + proposal_id: 'prop_1', + approve: true, + reason: '同意', + }); + assert.equal(lastCall?.url, '/api/v1/docgov/proposal/decide'); + const body = JSON.parse(String(lastCall?.init?.body)); + assert.equal(body.proposal_id, 'prop_1'); + assert.equal(body.approve, true); + assert.equal(prop.status, 'approved_by_author'); + }); +}); diff --git a/web/src/api/docgov.ts b/web/src/api/docgov.ts index e85a3fef..0f216b8e 100644 --- a/web/src/api/docgov.ts +++ b/web/src/api/docgov.ts @@ -1,4 +1,4 @@ -/** 受控文档正文保存与编辑租约 API */ +/** 受控文档正文保存、编辑租约与生命周期流转 API */ import { getBearerAuthHeaders } from './authHeaders'; @@ -14,6 +14,90 @@ export interface SaveDocumentContentPayload { work_dir?: string; } +/** 与后端 docgov.DocumentRecord JSON 对齐的精简回填类型。 */ +export interface DocGovRecord { + doc_id: string; + project_id?: number; + task_key?: string; + title?: string; + current_state: string; + is_locked?: boolean; + is_template_only?: boolean; + author_id?: number; + author_name?: string; + reviewer_id?: number; + reviewer_name?: string; + approver_id?: number; + approver_name?: string; + author_claimed?: boolean; + reviewer_claimed?: boolean; + approver_claimed?: boolean; + last_rejected_reason?: string; + last_rejected_by_role?: string; + last_rejected_by_name?: string; + duty_policy?: string; + duty_locked?: boolean; + content_sha256?: string; + lease_holder_type?: string; + lease_holder_id?: string; + lease_expires_at?: string; +} + +export type DocGovDecisionAction = 'approve' | 'reject'; + +export interface ClaimDocumentPayload { + doc_id: string; + user_id: number; + user_name: string; + user_role: string; +} + +export interface SubmitForReviewPayload { + doc_id: string; + user_id: number; + user_name: string; +} + +export interface ReviewDecisionPayload { + doc_id: string; + user_id: number; + user_name: string; + action: DocGovDecisionAction; + comment?: string; + reason?: string; +} + +export interface ApproverDecisionPayload { + doc_id: string; + user_id: number; + user_name: string; + action: DocGovDecisionAction; + comment?: string; + reason?: string; + rollback_to?: string; +} + +async function postDocGov(path: string, body: unknown): Promise { + const resp = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify(body), + }); + if (!resp.ok) { + const text = await resp.text(); + let message = text || `request failed: ${resp.status}`; + try { + const parsed = JSON.parse(text) as { message?: string; error?: string }; + if (parsed.message) message = parsed.message; + else if (parsed.error) message = parsed.error; + } catch { + /* keep raw text */ + } + throw new Error(message); + } + return resp.json() as Promise; +} + export async function saveDocumentContent(payload: SaveDocumentContentPayload): Promise<{ content_sha256: string; doc?: { @@ -23,16 +107,64 @@ export async function saveDocumentContent(payload: SaveDocumentContentPayload): content_sha256?: string; }; }> { - const resp = await fetch(`${API_BASE}/api/v1/docgov/content`, { - method: 'POST', - headers: getBearerAuthHeaders('application/json'), - body: JSON.stringify(payload), - }); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(text || `save content failed: ${resp.status}`); - } - return resp.json(); + return postDocGov('/api/v1/docgov/content', payload); +} + +export async function claimDocument(payload: ClaimDocumentPayload): Promise { + return postDocGov('/api/v1/docgov/claim', payload); +} + +export async function submitForReview(payload: SubmitForReviewPayload): Promise { + return postDocGov('/api/v1/docgov/submit-review', payload); +} + +export async function reviewDecision(payload: ReviewDecisionPayload): Promise { + return postDocGov('/api/v1/docgov/review-decision', payload); +} + +export async function approverDecision(payload: ApproverDecisionPayload): Promise { + return postDocGov('/api/v1/docgov/approver-decision', payload); +} + +export interface ProposeModificationPayload { + doc_id: string; + triggered_by?: string; + proposed_changes: string; + impact_analysis?: string; + risk_assessment?: string; +} + +export interface DecideModificationProposalPayload { + proposal_id: string; + approve: boolean; + reason?: string; +} + +export interface ChangeProposalRecord { + id?: number; + proposal_id: string; + doc_id: string; + project_id?: number; + triggered_by?: string; + proposed_changes: string; + impact_analysis?: string; + risk_assessment?: string; + status: string; + author_decision_reason?: string; + decided_at?: string | null; + created_at?: string; +} + +export async function proposeModification( + payload: ProposeModificationPayload, +): Promise { + return postDocGov('/api/v1/docgov/proposal', payload); +} + +export async function decideModificationProposal( + payload: DecideModificationProposalPayload, +): Promise { + return postDocGov('/api/v1/docgov/proposal/decide', payload); } export function mapLeaseToAstEditLease(doc: { diff --git a/web/src/api/documentRevision.ts b/web/src/api/documentRevision.ts index bad7f6ea..53aca539 100644 --- a/web/src/api/documentRevision.ts +++ b/web/src/api/documentRevision.ts @@ -99,10 +99,3 @@ export function decideAgentCandidate( { method: 'POST', body: JSON.stringify(body) }, ); } - -export function patchNotification(id: number, status: 'read' | 'acted' | 'dismissed') { - return api<{ ok: boolean }>(`/api/v1/notifications/${id}`, { - method: 'PATCH', - body: JSON.stringify({ status }), - }); -} diff --git a/web/src/api/freeformDoc.test.ts b/web/src/api/freeformDoc.test.ts new file mode 100644 index 00000000..cf76b6c5 --- /dev/null +++ b/web/src/api/freeformDoc.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { updateFreeformDoc } from './freeformDoc'; +import { ProjectSettingsApiError } from './projectSettingsError'; + +test('updateFreeformDoc exposes CONCURRENCY_CONFLICT on 409', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + error_code: 'CONCURRENCY_CONFLICT', + message: '文档已被更新产生版本冲突,请刷新重试', + }), + { status: 409, headers: { 'Content-Type': 'application/json' } }, + ); + try { + await assert.rejects( + () => updateFreeformDoc('doc_fixture', { contentMarkdown: 'x', expectedVersion: 1 }), + (err: unknown) => { + assert.ok(err instanceof ProjectSettingsApiError); + assert.equal((err as ProjectSettingsApiError).status, 409); + assert.equal((err as ProjectSettingsApiError).code, 'CONCURRENCY_CONFLICT'); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/web/src/api/freeformDoc.ts b/web/src/api/freeformDoc.ts index e4c34296..10d0eed3 100644 --- a/web/src/api/freeformDoc.ts +++ b/web/src/api/freeformDoc.ts @@ -4,6 +4,7 @@ */ import { getBearerAuthHeaders } from './authHeaders'; +import { projectSettingsErrorFromResponse } from './projectSettingsError'; export interface FreeformDocSummary { doc_id: string; @@ -174,11 +175,8 @@ export async function updateFreeformDoc( }); if (!res.ok) { - if (res.status === 409) { - throw new Error('文档已被更新产生版本冲突,请刷新后重试'); - } - const errData = await res.json().catch(() => ({})); - throw new Error(errData.message || `保存失败 (${res.status})`); + const text = await res.text(); + throw projectSettingsErrorFromResponse(res.status, text, `保存失败 (${res.status})`); } const body = await res.json(); return body.data; diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index 8b25a32c..0fd139e7 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -23,6 +23,7 @@ export type WorkflowDocumentSummary = { reviewer_name?: string; approver_id?: number; approver_name?: string; + duty_policy?: string; }; export type ControlledDocConsistency = diff --git a/web/src/api/projectMembers.test.ts b/web/src/api/projectMembers.test.ts new file mode 100644 index 00000000..a17b2692 --- /dev/null +++ b/web/src/api/projectMembers.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { addProjectMember, updateProjectMember } from './projectMembers'; +import { ProjectSettingsApiError } from './projectSettingsError'; + +test('addProjectMember exposes structured ProjectSettingsApiError on 403', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ code: 'AUTHZ', message: '无权访问', error: 'ERR_FORBIDDEN' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + try { + await assert.rejects( + () => addProjectMember(143, { user_id: 2, project_role: 'member' }), + (err: unknown) => { + assert.ok(err instanceof ProjectSettingsApiError); + assert.equal((err as ProjectSettingsApiError).status, 403); + // Envelope code wins over bare error string in the shared parser. + assert.equal((err as ProjectSettingsApiError).code, 'AUTHZ'); + assert.equal((err as ProjectSettingsApiError).message, '无权访问'); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('updateProjectMember surfaces safe INTERNAL message on 500 duty-sync failure', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ code: 'INTERNAL', message: '请求无法完成', error: 'ERR_DUTY_SYNC' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ); + try { + await assert.rejects( + () => updateProjectMember(143, { user_id: 2, project_role: 'member' }), + (err: unknown) => { + assert.ok(err instanceof ProjectSettingsApiError); + assert.equal((err as ProjectSettingsApiError).status, 500); + assert.equal((err as ProjectSettingsApiError).code, 'INTERNAL'); + assert.equal((err as ProjectSettingsApiError).message, '请求无法完成'); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/web/src/api/projectMembers.ts b/web/src/api/projectMembers.ts index d7fc98c4..08546091 100644 --- a/web/src/api/projectMembers.ts +++ b/web/src/api/projectMembers.ts @@ -1,4 +1,5 @@ import { getBearerAuthHeaders } from './authHeaders'; +import { projectSettingsErrorFromResponse } from './projectSettingsError'; export type ApiProjectMemberRole = 'owner' | 'member' | 'viewer'; export type ApiProjectMemberStatus = 'active' | 'exited'; @@ -27,15 +28,9 @@ export interface UpsertProjectMemberPayload { status?: ApiProjectMemberStatus; } -async function parseError(res: Response, fallback: string): Promise { +async function memberApiError(res: Response, fallback: string) { const text = await res.text(); - if (!text) return fallback; - try { - const parsed = JSON.parse(text) as { message?: string; error?: string }; - return parsed.message || parsed.error || text; - } catch { - return text; - } + return projectSettingsErrorFromResponse(res.status, text, fallback); } export async function listProjectMembers(projectId: number): Promise { @@ -43,7 +38,7 @@ export async function listProjectMembers(projectId: number): Promise { const draft = { workflow_definition_key: 'main', workflow_version: 1, stages: [] }; assert.equal(retainDraftOnFrozenConflict(draft), draft); }); + + it('prefers error_code over legacy bare error string (DOC_BUSY)', () => { + const body = parseProjectSettingsErrorBody( + JSON.stringify({ + error: 'document_busy', + error_code: 'DOC_BUSY', + message: '文档正在由其他用户编辑中,当前请求未创建新任务', + }), + ); + assert.equal(body.code, 'DOC_BUSY'); + }); }); diff --git a/web/src/api/projectSettingsError.ts b/web/src/api/projectSettingsError.ts index 5a5a4940..cc0aa75a 100644 --- a/web/src/api/projectSettingsError.ts +++ b/web/src/api/projectSettingsError.ts @@ -59,6 +59,7 @@ export function parseProjectSettingsErrorBody(text: string): { const parsed = JSON.parse(text) as { error?: string; code?: string; + error_code?: string; message?: string; issues?: ProjectSettingsIssue[]; }; @@ -78,9 +79,11 @@ export function parseProjectSettingsErrorBody(text: string): { .join(';'); const envelopeCode = String(parsed.code || ''); // Prefer domain business code when envelope is a generic VERSION_CONFLICT/etc. + // freeformDoc / taskRuns 历史端点用 error_code;优先于裸 error 字符串。 const code = businessCode || (envelopeCode && envelopeCode !== 'VERSION_CONFLICT' ? envelopeCode : '') || + String(parsed.error_code || '') || String(parsed.error || '') || envelopeCode; return { diff --git a/web/src/api/projectWorkflow.ts b/web/src/api/projectWorkflow.ts index 433ca7ae..721d9315 100644 --- a/web/src/api/projectWorkflow.ts +++ b/web/src/api/projectWorkflow.ts @@ -4,8 +4,6 @@ import { WorkflowDefinition, DocumentLineage, CreateProjectInput, - ActivateWorkflowInput, - ActivateWorkflowResult, } from '../types/projectWorkflow'; import { getBearerAuthHeaders } from './authHeaders'; @@ -68,29 +66,6 @@ export async function fetchProjectWorkflows(projectId: number): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/active-workflow`, { - method: 'PUT', - headers: getBearerAuthHeaders('application/json'), - body: JSON.stringify(input), - }); - if (!res.ok) { - const text = await res.text(); - let msg = text; - try { - const parsed = JSON.parse(text); - if (parsed.message) msg = parsed.message; - } catch { - // ignore - } - throw new Error(msg || `切换激活工作流失败: ${res.status}`); - } - return res.json(); -} - export async function fetchWorkflowCatalog(): Promise { const res = await fetch(`${API_BASE}/api/v1/workflows`, { headers: getBearerAuthHeaders(), diff --git a/web/src/api/taskRuns.test.ts b/web/src/api/taskRuns.test.ts index a640e244..4b8e70cd 100644 --- a/web/src/api/taskRuns.test.ts +++ b/web/src/api/taskRuns.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createTaskRun, readTaskRunSSEOnce, subscribeTaskRunEvents } from './taskRuns'; +import { createTaskRun, readTaskRunSSEOnce, subscribeTaskRunEvents, TaskRunHttpError } from './taskRuns'; import { parseSSEChunk } from './chatStream'; test('createTaskRun exposes DOC_BUSY detail without a retryable run', async () => { @@ -19,10 +19,10 @@ test('createTaskRun exposes DOC_BUSY detail without a retryable run', async () = await assert.rejects( () => createTaskRun({ session_id: 'fixture', project_id: 143 }), (err: unknown) => { - const typed = err as { httpStatus?: number; errorCode?: string; detail?: string }; - assert.equal(typed.httpStatus, 409); - assert.equal(typed.errorCode, 'DOC_BUSY'); - assert.match(typed.detail || '', /未创建新任务/); + const typed = err as { status?: number; code?: string; message?: string }; + assert.equal(typed.status, 409); + assert.equal(typed.code, 'DOC_BUSY'); + assert.match(typed.message || '', /未创建新任务/); return true; }, ); @@ -100,3 +100,59 @@ test('readTaskRunSSEOnce marks non-terminal EOF for reconnect', async () => { globalThis.fetch = originalFetch; } }); + +test('subscribeTaskRunEvents fails fast on 401 without retrying', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response('', { status: 401 }); + }; + try { + await assert.rejects( + () => + subscribeTaskRunEvents('run_401', { + onEvent: () => undefined, + maxReconnects: 5, + reconnectDelayMs: 1, + sleep: async () => undefined, + }), + (err: unknown) => { + assert.ok(err instanceof TaskRunHttpError); + assert.equal(err.status, 401); + return true; + }, + ); + assert.equal(calls, 1, 'must not retry on 401'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('subscribeTaskRunEvents fails fast on 404 without retrying', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response('', { status: 404 }); + }; + try { + await assert.rejects( + () => + subscribeTaskRunEvents('run_404', { + onEvent: () => undefined, + maxReconnects: 5, + reconnectDelayMs: 1, + sleep: async () => undefined, + }), + (err: unknown) => { + assert.ok(err instanceof TaskRunHttpError); + assert.equal(err.status, 404); + return true; + }, + ); + assert.equal(calls, 1, 'must not retry on 404'); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index eb7a55d0..406c0717 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -1,5 +1,6 @@ import { parseSSEChunk, type StreamEvent } from './chatStream'; import { mergeAuthHeaders, getBearerAuthHeaders } from './authHeaders'; +import { projectSettingsErrorFromResponse } from './projectSettingsError'; export type CreateTaskRunBody = Record & { session_id: string; @@ -22,26 +23,8 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +export class TaskRunHttpError extends Error { + status: number; + constructor(status: number) { + super(`subscribe task run failed: HTTP ${status}`); + this.name = 'TaskRunHttpError'; + this.status = status; + } +} + +function isNonRetryableStatus(status: number): boolean { + return status === 401 || status === 404; +} + /** 单次 SSE 读取;意外 EOF 时返回 { terminal:false, lastSeq } 供上层重连。 */ export async function readTaskRunSSEOnce( runId: string, @@ -68,7 +64,7 @@ export async function readTaskRunSSEOnce( signal, }); if (!resp.ok || !resp.body) { - throw new Error(`subscribe task run failed: HTTP ${resp.status}`); + throw new TaskRunHttpError(resp.status); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); @@ -130,6 +126,9 @@ export async function subscribeTaskRunEvents( // 意外 EOF:未见到 done/error,按 after_seq 重连 } catch (err) { if (signal?.aborted) return; + if (err instanceof TaskRunHttpError && isNonRetryableStatus(err.status)) { + throw err; + } if (attempt >= maxReconnects) throw err; } if (attempt >= maxReconnects) { diff --git a/web/src/components/doc/DocGovActionBar.tsx b/web/src/components/doc/DocGovActionBar.tsx index e0126d21..dc2f1be2 100644 --- a/web/src/components/doc/DocGovActionBar.tsx +++ b/web/src/components/doc/DocGovActionBar.tsx @@ -33,7 +33,6 @@ interface DocGovActionBarProps { export const DocGovActionBar: React.FC = ({ ast, - currentRole, currentUserId, canOverrideVisibility = true, onOpenVisibilityModal, @@ -71,9 +70,9 @@ export const DocGovActionBar: React.FC = ({ 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); + const showSubmit = canShowDocGovApprovalAction('submit', currentUserId, duties); + const showReview = canShowDocGovApprovalAction('review', currentUserId, duties); + const showApprove = canShowDocGovApprovalAction('approve', currentUserId, duties); return (
= ({ ? '批准中' : '编制中'} + {ast.dutyPolicy === 'author_only' && ( + + 编制人兼任 + + )} {ast.isClaimed && ( • 已被编制人认领 diff --git a/web/src/components/notifications/DocumentNotificationList.tsx b/web/src/components/notifications/DocumentNotificationList.tsx deleted file mode 100644 index 7bdf6e42..00000000 --- a/web/src/components/notifications/DocumentNotificationList.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { patchNotification } from '../../api/documentRevision'; - -export type DocNotificationItem = { - id: number; - type: string; - title: string; - body: string; - status: string; - created_at?: string; -}; - -export function DocumentNotificationList(props: { - items: DocNotificationItem[]; - onChanged?: () => void; -}) { - return ( -
    - {props.items.map((n) => ( -
  • -
    {n.title}
    -
    {n.body}
    -
    - - - -
    -
  • - ))} -
- ); -} diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx index 34ca41af..9231d22f 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -29,11 +29,12 @@ import { } from '../../lib/projectSettingsMode'; import { canToggleHumanApproval, - classifyClientDuty, clientDutyIssues, formatDutyIssues, locateDutyIssue, needsThreeMembersForDuties, + resolveClientDocTaskDuty, + toggleAuthorOnly, toggleHumanApproval, } from '../../lib/projectSettingsDuties'; import { ACTIVATION_UNMANAGED_FREEZE_HINT } from '../../lib/blockedGovernanceCopy'; @@ -502,7 +503,13 @@ export const ProjectSettingsModal: React.FC = ({
{st.stage_name || st.stage_key}
{st.doc_tasks.map((dt, ti) => { - const managed = classifyClientDuty(dt.duties) !== 'none' || requireApprovalKeys.includes(dt.task_key); + const resolved = resolveClientDocTaskDuty(dt); + const managed = + resolved.mode !== 'none' || + dt.duty_policy === 'author_only' || + requireApprovalKeys.includes(dt.task_key); + const authorOnly = dt.duty_policy === 'author_only'; + const authorOnlyAllowed = Boolean(settings?.author_only_policy?.allowed); const inline = locateDutyIssue(dutyIssues, dt.task_key); return (
@@ -530,7 +537,63 @@ export const ProjectSettingsModal: React.FC = ({ 纳入人工审批 )} + {dt.enabled && managed && ( + + )} {dt.enabled && (managed || !dt.approval_configurable) && ( + authorOnly ? ( +
+ + {dt.duties.author_user_id ? ( +
+ 审核、批准:同编制人(自动承担) +
+ ) : null} +
+ ) : (
{(['author', 'reviewer', 'approver'] as const).map((role) => ( ))}
+ ) )} {inline?.message && (
{inline.message}
@@ -587,6 +651,16 @@ export const ProjectSettingsModal: React.FC = ({ 纳入人工审批:{preview.summary.governed_doc_tasks ?? preview.summary.enabled_doc_tasks} · 不纳入:{preview.summary.unmanaged_doc_tasks ?? 0}
职责指派:{preview.summary.assigned_duties} 项
+ {(preview.summary.author_only_doc_tasks ?? 0) > 0 && ( +
+ 兼任审批:{preview.summary.author_only_doc_tasks} 份 + {(preview.summary.author_only_documents || []).length > 0 + ? `(${(preview.summary.author_only_documents || []) + .map((doc) => doc.title || doc.task_key) + .join('、')})` + : ''} +
+ )} {(preview.summary.unmanaged_documents || []).length > 0 && (
例外文档:{(preview.summary.unmanaged_documents || []).map((doc) => doc.title || doc.task_key).join('、')} diff --git a/web/src/components/user/SettingsModal.tsx b/web/src/components/user/SettingsModal.tsx deleted file mode 100644 index bd085967..00000000 --- a/web/src/components/user/SettingsModal.tsx +++ /dev/null @@ -1,826 +0,0 @@ -import { FC, useState, useEffect } from 'react'; -import { - X, - Settings, - Shield, - Plus, - Trash2, - Check, - Users, - Key, - Info, - CheckCircle2, -} from 'lucide-react'; -import { RoleDefinition, PermissionKey } from '../../types/rbac'; -import { UserProfile } from '../../types/docgov'; - -export interface SettingsModalProps { - isOpen: boolean; - onClose: () => void; - roles: RoleDefinition[]; - onSaveRoles: (roles: RoleDefinition[]) => void; - users: UserProfile[]; - currentUser: UserProfile; - onUpdateUserRole: (userId: number, roleId: string) => void; -} - -const ALL_PERMISSION_ITEMS: { - key: PermissionKey; - label: string; - desc: string; - category: '项目与流程' | '权限与安全' | '文档与导出'; -}[] = [ - { - key: 'project:create', - label: '创建新项目/立项', - desc: '允许在左侧栏发起新研发项目与阶段模板克隆', - category: '项目与流程', - }, - { - key: 'project:assign_lead', - label: '设置/变更文档责任人', - desc: '允许指派或变更文档的法定编制人、审核人与批准人', - category: '项目与流程', - }, - { - key: 'perm:manage', - label: '系统权限与角色管理', - desc: '超级管理特权,可增删改角色库并配置全局权限', - category: '权限与安全', - }, - { - key: 'workflow:config_visibility', - label: '配置工作流默认可见性', - desc: '允许配置整个研发阶段或工作流的基线可见性规则', - category: '权限与安全', - }, - { - key: 'doc:override_visibility', - label: '配置单文档可见性覆盖', - desc: '允许对敏感或涉密文档设置独立可见性(覆盖工作流默认)', - category: '权限与安全', - }, - { - key: 'workflow:view_all', - label: '查阅常规受控工作流文档', - desc: '默认是否可浏览项目内的受控工作流核心交付物', - category: '文档与导出', - }, - { - key: 'doc:export', - label: '导出受控成果物 (DOCX/XLSX)', - desc: '允许将交付物编译导出为合规 DOCX 报告或测算 XLSX 表格', - category: '文档与导出', - }, -]; - -export const SettingsModal: FC = ({ - isOpen, - onClose, - roles, - onSaveRoles, - users, - currentUser, - onUpdateUserRole, -}) => { - const [activeTab, setActiveTab] = useState<'roles' | 'users'>('roles'); - const [selectedRoleId, setSelectedRoleId] = useState(roles[0]?.id || 'role-admin'); - const [editRoles, setEditRoles] = useState(roles); - const [isCreatingRole, setIsCreatingRole] = useState(false); - const [newRoleName, setNewRoleName] = useState(''); - const [newRoleCode, setNewRoleCode] = useState(''); - const [newRoleDesc, setNewRoleDesc] = useState(''); - const [saveSuccessTip, setSaveSuccessTip] = useState(false); - - // 同步外部 roles - useEffect(() => { - setEditRoles(roles); - if (!roles.some((r) => r.id === selectedRoleId) && roles.length > 0) { - setSelectedRoleId(roles[0].id); - } - }, [roles]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) { - onClose(); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, onClose]); - - if (!isOpen) return null; - - const currentSelectedRole = - editRoles.find((r) => r.id === selectedRoleId) || editRoles[0]; - - const handleTogglePermission = (permKey: PermissionKey) => { - if (!currentSelectedRole || currentSelectedRole.isSystem) return; - - const exists = currentSelectedRole.permissions.includes(permKey); - const updatedPermissions = exists - ? currentSelectedRole.permissions.filter((k) => k !== permKey) - : [...currentSelectedRole.permissions, permKey]; - - const updatedRoles = editRoles.map((r) => - r.id === currentSelectedRole.id - ? { ...r, permissions: updatedPermissions, updatedAt: new Date().toLocaleString() } - : r - ); - - setEditRoles(updatedRoles); - onSaveRoles(updatedRoles); - showSavedNotification(); - }; - - const handleCreateRole = () => { - if (!newRoleName.trim() || !newRoleCode.trim()) return; - - const newId = `role-custom-${Date.now()}`; - const newRole: RoleDefinition = { - id: newId, - name: newRoleName.trim(), - code: newRoleCode.trim().toUpperCase(), - description: newRoleDesc.trim() || '自定义业务角色', - isSystem: false, - permissions: ['workflow:view_all', 'doc:export'], - createdAt: new Date().toLocaleString(), - updatedAt: new Date().toLocaleString(), - }; - - const nextRoles = [...editRoles, newRole]; - setEditRoles(nextRoles); - onSaveRoles(nextRoles); - setSelectedRoleId(newId); - setIsCreatingRole(false); - setNewRoleName(''); - setNewRoleCode(''); - setNewRoleDesc(''); - showSavedNotification(); - }; - - const handleDeleteRole = (roleId: string) => { - const roleToDelete = editRoles.find((r) => r.id === roleId); - if (!roleToDelete || roleToDelete.isSystem) return; - - if (window.confirm(`确定要删除角色【${roleToDelete.name}】吗?删除后该角色用户将自动退回普通工程师。`)) { - const nextRoles = editRoles.filter((r) => r.id !== roleId); - setEditRoles(nextRoles); - onSaveRoles(nextRoles); - setSelectedRoleId(nextRoles[0]?.id || 'role-admin'); - showSavedNotification(); - } - }; - - const showSavedNotification = () => { - setSaveSuccessTip(true); - setTimeout(() => setSaveSuccessTip(false), 2000); - }; - - return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > -
- {/* 顶部标题栏 */} -
-
-
- -
-
-
- 系统权限与角色管理控制台 -
-
- 全局动态角色 CRUD · 权限配置矩阵 · 人员角色委派与实测切换 -
-
-
- -
- {saveSuccessTip && ( - - 已实时自动保存 - - )} - -
-
- - {/* 标签栏导航 */} -
- - - -
- - {/* 内容区 */} -
- {activeTab === 'roles' ? ( -
- {/* 左侧角色列表 */} -
-
- - 系统全局角色 ({editRoles.length}) - - -
- - {/* 新增角色输入面板 */} - {isCreatingRole && ( -
- setNewRoleName(e.target.value)} - style={{ - padding: '6px 8px', - fontSize: '12px', - borderRadius: '4px', - border: '1px solid var(--border)', - background: 'var(--bg-panel)', - color: 'var(--text-strong)', - }} - /> - setNewRoleCode(e.target.value)} - style={{ - padding: '6px 8px', - fontSize: '12px', - borderRadius: '4px', - border: '1px solid var(--border)', - background: 'var(--bg-panel)', - color: 'var(--text-strong)', - }} - /> -
- - -
-
- )} - - {/* 角色卡片列表 */} -
- {editRoles.map((role) => { - const isSelected = role.id === selectedRoleId; - return ( -
setSelectedRoleId(role.id)} - style={{ - padding: '10px 12px', - borderRadius: '6px', - marginBottom: '6px', - cursor: 'pointer', - backgroundColor: isSelected ? 'var(--bg-active, #1F2837)' : 'transparent', - border: isSelected ? '1px solid var(--brand-500)' : '1px solid transparent', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - transition: 'all 0.15s ease', - }} - > -
-
- - {role.name} - - {role.isSystem && ( - - SYSTEM - - )} -
-
- 代码: {role.code} · {role.permissions.length} 项权限 -
-
- - {!role.isSystem && ( - - )} -
- ); - })} -
-
- - {/* 右侧权限配置矩阵 */} -
- {currentSelectedRole && ( - <> -
-
-
- {currentSelectedRole.name} ({currentSelectedRole.code}) -
- {currentSelectedRole.isSystem && ( - - 🛡️ 系统内置超级管理员,默认具备所有权限且防误删 - - )} -
-
- {currentSelectedRole.description} -
-
- -
-
- - 默认赋予的原子功能权限项 (勾选即刻生效): -
- -
- {ALL_PERMISSION_ITEMS.map((item) => { - const isChecked = - currentSelectedRole.isSystem || - currentSelectedRole.permissions.includes(item.key); - const isDisabled = currentSelectedRole.isSystem; - - return ( -
{ - if (!isDisabled) handleTogglePermission(item.key); - }} - style={{ - padding: '10px 14px', - borderRadius: '8px', - border: '1px solid var(--border)', - backgroundColor: isChecked ? 'rgba(79, 107, 255, 0.05)' : 'var(--bg-subtle)', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - cursor: isDisabled ? 'default' : 'pointer', - transition: 'all 0.15s ease', - }} - > -
-
- - {item.label} - - - {item.category} - -
-
- {item.key} · {item.desc} -
-
- -
- {isChecked && } -
-
- ); - })} -
-
- - )} -
-
- ) : ( - /* Tab 2: 人员角色委派与当前登录人切换 */ -
-
- - - 在此为研发中心人员委派全局角色。支持一键将当前会话登录人切换为不同角色身份,快速实测“新建项目”、“指派负责人”及“单文档可见性覆盖”的受控拦截效果。 - -
- -
- {users.map((u) => { - const isCurrent = u.id === currentUser.id; - const userRoleId = u.roleId || 'role-engineer'; - - return ( -
-
-
- {u.name.slice(0, 1)} -
-
-
- - {u.name} - - - 工号: {u.employeeId} - - {isCurrent && ( - - 当前登录身份 - - )} -
-
- {u.department} · {u.position} -
-
-
- -
- {/* 角色委派下拉框 */} -
- 委派全局角色: - -
- - {isCurrent ? ( - - ✓ 当前登录身份 - - ) : null} -
-
- ); - })} -
-
- )} -
- - {/* 底部按钮栏 */} -
- - 水木东方医疗器械智能协同工作台 · 全生命周期权限治理 - - -
-
-
- ); -}; diff --git a/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx b/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx deleted file mode 100644 index ffe842ef..00000000 --- a/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { AlertTriangle, ArrowRight } from 'lucide-react'; - -interface WorkflowPrerequisiteBannerProps { - workflowKey: string; - hasApprovedSystemReqs: boolean; - onSwitchToProductManager: () => void; -} - -export const WorkflowPrerequisiteBanner: React.FC = ({ - workflowKey, - hasApprovedSystemReqs, - onSwitchToProductManager, -}) => { - if (workflowKey !== 'test_manager' || hasApprovedSystemReqs) { - return null; - } - - return ( -
-
- - - 前置依赖未就绪: - 智能测试经理工作流强依赖同项目已批准的《系统需求规格书 (RD-SR)》。请先由产品经理完成编制并经三级审批批准。 - -
- -
- ); -}; diff --git a/web/src/data/fileStore.test.ts b/web/src/data/fileStore.test.ts new file mode 100644 index 00000000..c02d092c --- /dev/null +++ b/web/src/data/fileStore.test.ts @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyRestoredFileRaw } from './fileStore'; +import type { DeliverableItem } from '../types/workbench'; + +function baseItem(overrides: Partial & { docId: string }): DeliverableItem { + return { + projectId: 1, + taskKey: 'intake', + title: '上传材料', + stage: 'V0.1', + category: 'private', + creatorId: 1, + creatorName: '测试', + isFixedWorkflow: false, + isTemplateOnly: false, + lifecycleState: 'drafting', + isClaimed: true, + author: '测试', + reviewer: '', + approver: '', + updatedAt: '2026-09-18', + sourceType: 'uploaded_file', + ...overrides, + }; +} + +test('applyRestoredFileRaw: 以 prev 为权威,只补丁缺失 fileRaw,不丢新增条目', () => { + const blobA = new Blob(['a'], { type: 'text/plain' }); + const blobB = new Blob(['b'], { type: 'text/plain' }); + + const mountSnapshot: DeliverableItem[] = [ + baseItem({ + docId: 'doc-a', + fileMeta: { + originalName: 'a.txt', + size: '1B', + mimeType: 'text/plain', + extension: 'txt', + uploadedAt: '2026-09-18', + }, + }), + ]; + + const restored = [ + { + ...mountSnapshot[0], + fileMeta: { + ...mountSnapshot[0].fileMeta!, + fileRaw: blobA, + }, + }, + ]; + + // resolve 前用户又新增了 doc-b,且 doc-a 仍缺 fileRaw + const prev: DeliverableItem[] = [ + ...mountSnapshot, + baseItem({ + docId: 'doc-b', + title: '新上传', + fileMeta: { + originalName: 'b.txt', + size: '1B', + mimeType: 'text/plain', + extension: 'txt', + fileRaw: blobB, + uploadedAt: '2026-09-18', + }, + }), + ]; + + const merged = applyRestoredFileRaw(prev, restored); + assert.equal(merged.length, 2, 'must keep newly added deliverable'); + assert.equal(merged[0].docId, 'doc-a'); + assert.equal(merged[0].fileMeta?.fileRaw, blobA); + assert.equal(merged[1].docId, 'doc-b'); + assert.equal(merged[1].fileMeta?.fileRaw, blobB); +}); + +test('applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用', () => { + const prev = [ + baseItem({ + docId: 'doc-x', + fileMeta: { + originalName: 'x.txt', + size: '1B', + mimeType: 'text/plain', + extension: 'txt', + uploadedAt: '2026-09-18', + }, + }), + ]; + const out = applyRestoredFileRaw(prev, prev); + assert.equal(out, prev); +}); diff --git a/web/src/data/fileStore.ts b/web/src/data/fileStore.ts index c9471b92..9a085874 100644 --- a/web/src/data/fileStore.ts +++ b/web/src/data/fileStore.ts @@ -181,3 +181,37 @@ export async function restoreFileRawToDeliverables( }; }); } + +/** + * 将 restore 快照中的 fileRaw 合并进当前 deliverables(函数式更新用)。 + * 以 prev 为权威列表,仅按 docId 补丁缺失的 fileRaw;不丢弃 restore 期间新增的条目。 + */ +export function applyRestoredFileRaw( + prev: DeliverableItem[], + restored: DeliverableItem[] +): DeliverableItem[] { + const restoredRawByDocId = new Map(); + for (const d of restored) { + const raw = d.fileMeta?.fileRaw; + if (raw) { + restoredRawByDocId.set(d.docId, raw); + } + } + if (restoredRawByDocId.size === 0) return prev; + + let changed = false; + const next = prev.map((d) => { + if (!d.fileMeta || d.fileMeta.fileRaw) return d; + const blob = restoredRawByDocId.get(d.docId); + if (!blob) return d; + changed = true; + return { + ...d, + fileMeta: { + ...d.fileMeta, + fileRaw: blob, + }, + }; + }); + return changed ? next : prev; +} diff --git a/web/src/data/storage.ts b/web/src/data/storage.ts index 3759020b..80b6aa18 100644 --- a/web/src/data/storage.ts +++ b/web/src/data/storage.ts @@ -137,11 +137,6 @@ export function loadMessages(): ChatMessage[] { return enrichMessagesWithCategories(result); } -/** @deprecated 全量 MESSAGES 持久化已废弃,请使用 saveSessionWindowCache */ -export function saveMessages(_messages: ChatMessage[]): void { - // no-op: 权威历史来自服务端窗口接口 -} - // ================================================================================== // P0: 会话窗口 LRU 缓存与输入草稿持久化 (替代原全量 MESSAGES 持久化) // ================================================================================== @@ -161,14 +156,14 @@ function getDraftCacheKey(sessionId: string): string { return `${DRAFT_CACHE_PREFIX}${sessionId}`; } -function updateWindowLRU(sessionId: string): void { +function updateWindowLRU(cacheKey: string): void { const current = safeGetItem(WINDOW_LRU_KEY, []); - const updated = [sessionId, ...current.filter((id) => id !== sessionId)]; + const updated = [cacheKey, ...current.filter((id) => id !== cacheKey)]; if (updated.length > MAX_CACHED_WINDOWS) { const evicted = updated.slice(MAX_CACHED_WINDOWS); - evicted.forEach((id) => { + evicted.forEach((key) => { if (typeof window !== 'undefined' && window.localStorage) { - window.localStorage.removeItem(getWindowCacheKey(id)); + window.localStorage.removeItem(key); } }); } @@ -180,8 +175,9 @@ export function loadSessionWindowCache(sessionId: string, userId?: number): Chat } export function saveSessionWindowCache(sessionId: string, buckets: ChatDayBucket[], userId?: number): void { - safeSetItem(getWindowCacheKey(sessionId, userId), buckets); - updateWindowLRU(sessionId); + const cacheKey = getWindowCacheKey(sessionId, userId); + safeSetItem(cacheKey, buckets); + updateWindowLRU(cacheKey); } export function clearSessionWindowCache(sessionId: string, userId?: number): void { diff --git a/web/src/data/storage.windowCache.test.ts b/web/src/data/storage.windowCache.test.ts new file mode 100644 index 00000000..688eea75 --- /dev/null +++ b/web/src/data/storage.windowCache.test.ts @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + saveSessionWindowCache, + loadSessionWindowCache, + clearSessionWindowCache, +} from './storage'; +import type { ChatDayBucket } from '../types/workbench'; + +function installMemoryLocalStorage(): { store: Map; restore: () => void } { + const store = new Map(); + const originalWindow = (globalThis as { window?: unknown }).window; + const localStorage = { + getItem(key: string) { + return store.has(key) ? store.get(key)! : null; + }, + setItem(key: string, value: string) { + store.set(key, value); + }, + removeItem(key: string) { + store.delete(key); + }, + }; + (globalThis as { window: unknown }).window = { localStorage }; + return { + store, + restore: () => { + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window: unknown }).window = originalWindow; + } + }, + }; +} + +function sampleBucket(dayKey: string): ChatDayBucket { + return { + dayKey, + count: 1, + loadedCount: 1, + truncated: false, + firstMessageId: 1, + lastMessageId: 1, + messages: [ + { + id: `msg-${dayKey}`, + sessionId: 's', + role: 'user', + content: 'hi', + timestamp: '12:00', + }, + ], + }; +} + +test('window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤儿条目', () => { + const { store, restore } = installMemoryLocalStorage(); + try { + const userId = 42; + // MAX_CACHED_WINDOWS = 3;写入 4 个不同 session 应驱逐最早的带前缀 key + const sessions = ['sess-a', 'sess-b', 'sess-c', 'sess-d']; + for (const sid of sessions) { + saveSessionWindowCache(sid, [sampleBucket(`2026-09-18-${sid}`)], userId); + } + + const prefixed = [...store.keys()].filter((k) => k.startsWith('smdf_chat_window_u')); + assert.equal(prefixed.length, 3, `expected 3 prefixed window keys, got ${prefixed.join(',')}`); + assert.equal( + loadSessionWindowCache('sess-a', userId), + null, + 'oldest sess-a with user prefix must be evicted' + ); + assert.ok(loadSessionWindowCache('sess-b', userId)); + assert.ok(loadSessionWindowCache('sess-c', userId)); + assert.ok(loadSessionWindowCache('sess-d', userId)); + + // 无前缀 key 不应被误认为存活缓存 + assert.equal(store.has('smdf_chat_window_sess-a'), false); + assert.equal(store.has('smdf_chat_window_sess-d'), false); + + clearSessionWindowCache('sess-d', userId); + assert.equal(loadSessionWindowCache('sess-d', userId), null); + } finally { + restore(); + } +}); + +test('window cache: 读写同一 userId 前缀,跨用户不串读', () => { + const { restore } = installMemoryLocalStorage(); + try { + saveSessionWindowCache('sess-shared', [sampleBucket('2026-09-18')], 1); + saveSessionWindowCache('sess-shared', [sampleBucket('2026-09-19')], 2); + const u1 = loadSessionWindowCache('sess-shared', 1); + const u2 = loadSessionWindowCache('sess-shared', 2); + assert.equal(u1?.[0]?.dayKey, '2026-09-18'); + assert.equal(u2?.[0]?.dayKey, '2026-09-19'); + } finally { + restore(); + } +}); diff --git a/web/src/engine/markdownToBlock.ts b/web/src/engine/markdownToBlock.ts index 13ba14b3..0527388f 100644 --- a/web/src/engine/markdownToBlock.ts +++ b/web/src/engine/markdownToBlock.ts @@ -269,6 +269,7 @@ export function markdownToBlockAST( approver: meta?.approver || '', approverId: meta?.approverId, dutiesAuthoritative: meta?.dutiesAuthoritative, + dutyPolicy: meta?.dutyPolicy, revisionEtag: meta?.revisionEtag, summary: meta?.summary || '由项目助手自动生成的受控交付物', updatedAt: meta?.updatedAt || new Date().toISOString().replace('T', ' ').substring(0, 16), diff --git a/web/src/hooks/useChatWindow.cacheKey.test.ts b/web/src/hooks/useChatWindow.cacheKey.test.ts new file mode 100644 index 00000000..808c91c2 --- /dev/null +++ b/web/src/hooks/useChatWindow.cacheKey.test.ts @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +test('useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId', () => { + const here = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(join(here, 'useChatWindow.ts'), 'utf8'); + const calls = [...src.matchAll(/saveSessionWindowCache\(([^)]*)\)/g)]; + assert.ok(calls.length >= 8, `expected >=8 saveSessionWindowCache calls, got ${calls.length}`); + for (const m of calls) { + const args = m[1]; + assert.match( + args, + /\buserId\b/, + `saveSessionWindowCache call must include userId: saveSessionWindowCache(${args})` + ); + } +}); diff --git a/web/src/hooks/useChatWindow.ts b/web/src/hooks/useChatWindow.ts index 47170dbb..3252a9f8 100644 --- a/web/src/hooks/useChatWindow.ts +++ b/web/src/hooks/useChatWindow.ts @@ -232,7 +232,7 @@ export function useChatWindow({ if (removed) totalMsgs -= removed.loadedCount; } - saveSessionWindowCache(sessionId, protectedStack); + saveSessionWindowCache(sessionId, protectedStack, userId); return protectedStack; }); @@ -247,7 +247,7 @@ export function useChatWindow({ setPhase('ready'); } }, - [hasMoreBefore, phase, oldestLoadedDay, sessionId] + [hasMoreBefore, phase, oldestLoadedDay, sessionId, userId] ); // 加载当日全量消息 @@ -268,14 +268,14 @@ export function useChatWindow({ } return b; }); - saveSessionWindowCache(sessionId, updated); + saveSessionWindowCache(sessionId, updated, userId); return updated; }); } catch (err) { console.error('[useChatWindow] loadFullDay failed', err); } }, - [sessionId] + [sessionId, userId] ); // 日期跳转: 聚焦指定日期 @@ -312,7 +312,7 @@ export function useChatWindow({ if (next.length > MAX_WINDOW_DAYS_STACK) { next.splice(0, next.length - MAX_WINDOW_DAYS_STACK); } - saveSessionWindowCache(sessionId, next); + saveSessionWindowCache(sessionId, next, userId); return next; }); @@ -329,7 +329,7 @@ export function useChatWindow({ console.warn(`[useChatWindow] jumpToDate failed for ${dayKey}:`, err); } }, - [sessionId, dayStack] + [sessionId, userId, dayStack] ); // 跨日锚点拉取: 根据 messageId 拉取所属日分片并合并进窗口 @@ -362,7 +362,7 @@ export function useChatWindow({ if (next.length > MAX_WINDOW_DAYS_STACK) { next.splice(0, next.length - MAX_WINDOW_DAYS_STACK); } - saveSessionWindowCache(sessionId, next); + saveSessionWindowCache(sessionId, next, userId); return next; }); return true; @@ -372,7 +372,7 @@ export function useChatWindow({ } return false; }, - [sessionId, dayStack] + [sessionId, userId, dayStack] ); // 追加新消息 (SSE 或用户发送) @@ -413,11 +413,11 @@ export function useChatWindow({ updated.sort((a, b) => (a.dayKey < b.dayKey ? -1 : 1)); } - saveSessionWindowCache(sessionId, updated); + saveSessionWindowCache(sessionId, updated, userId); return updated; }); }, - [sessionId] + [sessionId, userId] ); // 原地更新指定消息 @@ -436,13 +436,13 @@ export function useChatWindow({ return bucket; }); if (changed) { - saveSessionWindowCache(sessionId, updated); + saveSessionWindowCache(sessionId, updated, userId); return updated; } return prev; }); }, - [sessionId] + [sessionId, userId] ); // 将流式临时 ID 替换为服务端落库真实 serverId @@ -468,13 +468,13 @@ export function useChatWindow({ return bucket; }); if (changed) { - saveSessionWindowCache(sessionId, updated); + saveSessionWindowCache(sessionId, updated, userId); return updated; } return prev; }); }, - [sessionId] + [sessionId, userId] ); // 将 liveMessages (来自组件层实时状态,如刚发送的 user 消息或 streaming 中的 assistant 气泡) diff --git a/web/src/hooks/useTaskRunStream.ts b/web/src/hooks/useTaskRunStream.ts index d919e95a..ba678491 100644 --- a/web/src/hooks/useTaskRunStream.ts +++ b/web/src/hooks/useTaskRunStream.ts @@ -89,6 +89,15 @@ export function useTaskRunStream(activeSessionId: string) { [getRuntime, notify], ); + const abortAllSessions = useCallback(() => { + for (const rt of storeRef.current.sessions.values()) { + rt.controller?.abort(); + rt.controller = null; + rt.thinking = false; + } + notify(); + }, [notify]); + return { runtime: snapshot, isThinking: snapshot.thinking, @@ -98,5 +107,6 @@ export function useTaskRunStream(activeSessionId: string) { setRunId, setController, abortSession, + abortAllSessions, }; } diff --git a/web/src/hooks/useTurnRuntime.test.ts b/web/src/hooks/useTurnRuntime.test.ts index efb11b11..26b49643 100644 --- a/web/src/hooks/useTurnRuntime.test.ts +++ b/web/src/hooks/useTurnRuntime.test.ts @@ -44,3 +44,42 @@ test('turn runtime: session A stream survives session B switch', () => { assert.equal(isSessionThinking('session-A'), true); assert.equal(isSessionThinking('session-B'), true); }); + +test('abortAllSessions aborts controllers across all sessions', () => { + type TurnRuntime = { + clientTurnId: string; + sessionId: string; + status: string; + controller: { aborted: boolean; abort: () => void }; + }; + const store = new Map>(); + + const makeController = () => { + const c = { aborted: false, abort() { c.aborted = true; } }; + return c; + }; + + const register = (rt: TurnRuntime) => { + if (!store.has(rt.sessionId)) store.set(rt.sessionId, new Map()); + store.get(rt.sessionId)!.set(rt.clientTurnId, rt); + }; + + const abortAllSessions = () => { + for (const sessionMap of store.values()) { + for (const rt of sessionMap.values()) { + if (rt.status === 'pending' || rt.status === 'streaming') { + rt.controller.abort(); + rt.status = 'cancelled'; + } + } + } + }; + + const a = makeController(); + const b = makeController(); + register({ clientTurnId: 't1', sessionId: 's1', status: 'streaming', controller: a }); + register({ clientTurnId: 't2', sessionId: 's2', status: 'pending', controller: b }); + abortAllSessions(); + assert.equal(a.aborted, true); + assert.equal(b.aborted, true); +}); diff --git a/web/src/hooks/useTurnRuntime.ts b/web/src/hooks/useTurnRuntime.ts index cf66589e..7b95fc8e 100644 --- a/web/src/hooks/useTurnRuntime.ts +++ b/web/src/hooks/useTurnRuntime.ts @@ -108,6 +108,18 @@ export function useTurnRuntime(activeSessionId: string) { notify(); }, [activeSessionId, getSessionMap, notify]); + const abortAllSessions = useCallback(() => { + for (const sessionMap of storeRef.current.turns.values()) { + for (const rt of sessionMap.values()) { + if (rt.status === 'pending' || rt.status === 'streaming') { + rt.controller.abort(); + rt.status = 'cancelled'; + } + } + } + notify(); + }, [notify]); + const isSessionThinking = useCallback( (sessionId: string) => { const sessionMap = storeRef.current.turns.get(sessionId); @@ -138,6 +150,7 @@ export function useTurnRuntime(activeSessionId: string) { removeTurn, abortTurn, abortActiveSessionTurns, + abortAllSessions, isThinking, isSessionThinking, getTurn, diff --git a/web/src/lib/controlledDocHydrate.ts b/web/src/lib/controlledDocHydrate.ts index cdfe8bdf..780d30a3 100644 --- a/web/src/lib/controlledDocHydrate.ts +++ b/web/src/lib/controlledDocHydrate.ts @@ -52,9 +52,11 @@ export function buildControlledLoadErrorSkeleton(doc: DeliverableItem): Document approver: doc.approver, approverId: doc.approverId, dutiesAuthoritative: doc.dutiesAuthoritative ?? true, + dutyPolicy: doc.dutyPolicy, }); ast.governanceMode = 'controlled'; ast.editLease = doc.editLease; + ast.dutyPolicy = doc.dutyPolicy; return ast; } @@ -77,9 +79,11 @@ export function buildControlledLoadingSkeleton(doc: DeliverableItem): DocumentAS approver: doc.approver, approverId: doc.approverId, dutiesAuthoritative: doc.dutiesAuthoritative ?? true, + dutyPolicy: doc.dutyPolicy, }); ast.governanceMode = 'controlled'; ast.editLease = doc.editLease; + ast.dutyPolicy = doc.dutyPolicy; return ast; } @@ -106,10 +110,12 @@ export function buildControlledDocumentAST( approver: doc.approver, approverId: doc.approverId, dutiesAuthoritative: doc.dutiesAuthoritative ?? true, + dutyPolicy: doc.dutyPolicy, }); fullAST.governanceMode = 'controlled'; fullAST.editLease = doc.editLease; fullAST.dutiesAuthoritative = doc.dutiesAuthoritative ?? true; + fullAST.dutyPolicy = doc.dutyPolicy; return fullAST; } @@ -166,6 +172,7 @@ export async function hydrateControlledDocumentContent( ); ast.revisionEtag = etag; ast.dutiesAuthoritative = doc.dutiesAuthoritative ?? true; + ast.dutyPolicy = doc.dutyPolicy; return ast; })().finally(() => { if (inflightHydrates.get(key) === pending) { diff --git a/web/src/lib/deliverableSync.ts b/web/src/lib/deliverableSync.ts index f2db4640..2a4092c5 100644 --- a/web/src/lib/deliverableSync.ts +++ b/web/src/lib/deliverableSync.ts @@ -22,12 +22,14 @@ function applyAuthoritativeDuties( base.reviewer = s.reviewer_name || ''; base.approverId = s.approver_id; base.approver = s.approver_name || ''; + base.dutyPolicy = s.duty_policy || ''; return; } if (isNewSlot) { base.author = ''; base.reviewer = ''; base.approver = ''; + base.dutyPolicy = ''; } } diff --git a/web/src/lib/docGovActionVisibility.test.ts b/web/src/lib/docGovActionVisibility.test.ts index cbbd3e38..6f5682e8 100644 --- a/web/src/lib/docGovActionVisibility.test.ts +++ b/web/src/lib/docGovActionVisibility.test.ts @@ -1,23 +1,46 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { canShowDocGovApprovalAction, isUnmanagedDocDuties } from './docGovActionVisibility'; +import { + canShowDocGovApprovalAction, + holdsDocDuty, + isUnmanagedDocDuties, +} from './docGovActionVisibility'; const full = { authorId: 1, reviewerId: 2, approverId: 3 }; +const authorOnly = { authorId: 7, reviewerId: 7, approverId: 7 }; 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); + assert.equal(canShowDocGovApprovalAction('submit', 1, {}), false); + assert.equal(canShowDocGovApprovalAction('review', 2, {}), false); + assert.equal(canShowDocGovApprovalAction('approve', 3, {}), 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); + it('gates separated duties by user id, not folded role label', () => { + assert.equal(canShowDocGovApprovalAction('submit', 1, full), true); + assert.equal(canShowDocGovApprovalAction('submit', 99, full), false); + assert.equal(canShowDocGovApprovalAction('review', 2, full), true); + assert.equal(canShowDocGovApprovalAction('review', 1, full), false); + assert.equal(canShowDocGovApprovalAction('approve', 3, full), true); + assert.equal(canShowDocGovApprovalAction('approve', 1, full), false); + }); + + it('author_only: same user holds all three duties and sees all actions', () => { + assert.equal(holdsDocDuty(7, 'author', authorOnly), true); + assert.equal(holdsDocDuty(7, 'reviewer', authorOnly), true); + assert.equal(holdsDocDuty(7, 'approver', authorOnly), true); + assert.equal(canShowDocGovApprovalAction('submit', 7, authorOnly), true); + assert.equal(canShowDocGovApprovalAction('review', 7, authorOnly), true); + assert.equal(canShowDocGovApprovalAction('approve', 7, authorOnly), true); + }); + + it('author_only: non-duty user sees no actions', () => { + assert.equal(holdsDocDuty(8, 'author', authorOnly), false); + assert.equal(holdsDocDuty(8, 'reviewer', authorOnly), false); + assert.equal(holdsDocDuty(8, 'approver', authorOnly), false); + assert.equal(canShowDocGovApprovalAction('submit', 8, authorOnly), false); + assert.equal(canShowDocGovApprovalAction('review', 8, authorOnly), false); + assert.equal(canShowDocGovApprovalAction('approve', 8, authorOnly), false); }); }); diff --git a/web/src/lib/docGovActionVisibility.ts b/web/src/lib/docGovActionVisibility.ts index e0c0e3f8..433f4d02 100644 --- a/web/src/lib/docGovActionVisibility.ts +++ b/web/src/lib/docGovActionVisibility.ts @@ -8,6 +8,8 @@ export type DocDutyIds = { approverId?: number; }; +export type DocDutyRole = 'author' | 'reviewer' | 'approver'; + export function isUnmanagedDocDuties(duties: DocDutyIds): boolean { return !( (duties.authorId ?? 0) > 0 || @@ -16,7 +18,49 @@ export function isUnmanagedDocDuties(duties: DocDutyIds): boolean { ); } +/** 直接比对用户 ID 与职责槽位,不经过折叠后的单角色 currentRole。 */ +export function holdsDocDuty( + userId: number | undefined, + role: DocDutyRole, + duties: DocDutyIds, +): boolean { + if (!userId || userId <= 0) return false; + switch (role) { + case 'author': + return (duties.authorId ?? 0) === userId; + case 'reviewer': + return (duties.reviewerId ?? 0) === userId; + case 'approver': + return (duties.approverId ?? 0) === userId; + default: { + const _exhaustive: never = role; + return _exhaustive; + } + } +} + export function canShowDocGovApprovalAction( + action: DocGovApprovalAction, + currentUserId: number | undefined, + duties: DocDutyIds, +): boolean { + if (isUnmanagedDocDuties(duties)) return false; + switch (action) { + case 'submit': + return holdsDocDuty(currentUserId, 'author', duties); + case 'review': + return holdsDocDuty(currentUserId, 'reviewer', duties); + case 'approve': + return holdsDocDuty(currentUserId, 'approver', duties); + default: { + const _exhaustive: never = action; + return _exhaustive; + } + } +} + +/** @deprecated Prefer holdsDocDuty; kept for callers that still pass a folded role label. */ +export function canShowDocGovApprovalActionByRole( action: DocGovApprovalAction, currentRole: UserRole, duties: DocDutyIds, diff --git a/web/src/lib/projectSettingsDuties.test.ts b/web/src/lib/projectSettingsDuties.test.ts index 7e6287c7..219851a3 100644 --- a/web/src/lib/projectSettingsDuties.test.ts +++ b/web/src/lib/projectSettingsDuties.test.ts @@ -7,6 +7,8 @@ import { clientDutyIssues, locateDutyIssue, needsThreeMembersForDuties, + resolveClientDocTaskDuty, + toggleAuthorOnly, toggleHumanApproval, } from './projectSettingsDuties'; @@ -81,4 +83,91 @@ describe('projectSettingsDuties', () => { ]); assert.equal(needsThreeMembersForDuties(draft), true); }); + + it('resolves author_only to A/A/A without three-member requirement', () => { + const task = { + task_key: 'intake', + enabled: true, + approval_configurable: true, + duty_policy: 'author_only' as const, + duties: { author_user_id: 7 }, + }; + const resolved = resolveClientDocTaskDuty(task); + assert.equal(resolved.mode, 'full'); + assert.equal(resolved.isAuthorOnly, true); + assert.deepEqual(resolved.effective, { + author_user_id: 7, + reviewer_user_id: 7, + approver_user_id: 7, + }); + const draft = plan([task]); + assert.equal(needsThreeMembersForDuties(draft), false); + assert.deepEqual(clientDutyIssues(draft, [{ userId: 7 }]), []); + }); + + it('rejects author_only without author or with conflicting reviewers', () => { + const missing = plan([ + { + task_key: 'intake', + task_title: '需求', + enabled: true, + approval_configurable: true, + duty_policy: 'author_only', + duties: {}, + }, + ]); + assert.equal( + locateDutyIssue(clientDutyIssues(missing, [{ userId: 1 }]), 'intake')?.code, + 'AUTHOR_ONLY_AUTHOR_REQUIRED', + ); + + const conflict = plan([ + { + task_key: 'intake', + task_title: '需求', + enabled: true, + approval_configurable: true, + duty_policy: 'author_only', + duties: { author_user_id: 1, reviewer_user_id: 2 }, + }, + ]); + assert.equal( + locateDutyIssue(clientDutyIssues(conflict, [{ userId: 1 }, { userId: 2 }]), 'intake')?.code, + 'AUTHOR_ONLY_DUTY_CONFLICT', + ); + }); + + it('mixed full + author_only still requires three members for the full doc', () => { + const draft = plan([ + { + task_key: 'intake', + enabled: true, + approval_configurable: true, + duties: { author_user_id: 1, reviewer_user_id: 2, approver_user_id: 3 }, + }, + { + task_key: 'research', + enabled: true, + approval_configurable: true, + duty_policy: 'author_only', + duties: { author_user_id: 1 }, + }, + ]); + assert.equal(needsThreeMembersForDuties(draft), true); + }); + + it('toggleAuthorOnly clears review/approve slots and restores on disable', () => { + const base = { + task_key: 'intake', + enabled: true, + approval_configurable: true, + duties: { author_user_id: 7, reviewer_user_id: 2, approver_user_id: 3 }, + }; + const on = toggleAuthorOnly(base, true); + assert.equal(on.duty_policy, 'author_only'); + assert.deepEqual(on.duties, { author_user_id: 7 }); + const off = toggleAuthorOnly(on, false); + assert.equal(off.duty_policy, ''); + assert.deepEqual(off.duties, { author_user_id: 7 }); + }); }); diff --git a/web/src/lib/projectSettingsDuties.ts b/web/src/lib/projectSettingsDuties.ts index b7eb24c1..98ed3a9c 100644 --- a/web/src/lib/projectSettingsDuties.ts +++ b/web/src/lib/projectSettingsDuties.ts @@ -12,17 +12,62 @@ export function classifyClientDuty(duty?: DocDuty): ClientDutyMode { return 'partial'; } +/** 对齐 Go ResolveDocTaskDuty:author_only 仅要求编制人,有效三职派生为 A/A/A。 */ +export function resolveClientDocTaskDuty(task: DocTaskPlan): { + mode: ClientDutyMode; + effective: DocDuty; + isAuthorOnly: boolean; +} { + if (!task.enabled) { + return { mode: 'none', effective: {}, isAuthorOnly: false }; + } + const policy = (task.duty_policy || '').trim(); + if (policy === 'author_only') { + const author = task.duties?.author_user_id ?? 0; + if (author <= 0) { + return { mode: 'partial', effective: {}, isAuthorOnly: true }; + } + return { + mode: 'full', + effective: { + author_user_id: author, + reviewer_user_id: author, + approver_user_id: author, + }, + isAuthorOnly: true, + }; + } + if (policy && policy !== 'separated') { + return { mode: 'partial', effective: {}, isAuthorOnly: false }; + } + const mode = classifyClientDuty(task.duties); + return { mode, effective: { ...(task.duties || {}) }, isAuthorOnly: false }; +} + export function canToggleHumanApproval(task: Pick, planning: boolean): boolean { return planning && Boolean(task.approval_configurable); } export function toggleHumanApproval(task: DocTaskPlan, managed: boolean): DocTaskPlan { if (!managed) { - return { ...task, duties: {} }; + return { ...task, duty_policy: '', duties: {} }; } return { ...task }; } +/** 切换兼任模式:开启时只保留编制人并清空审核/批准;关闭时清除 duty_policy。 */ +export function toggleAuthorOnly(task: DocTaskPlan, enabled: boolean): DocTaskPlan { + if (!enabled) { + return { ...task, duty_policy: '' }; + } + const author = task.duties?.author_user_id; + return { + ...task, + duty_policy: 'author_only', + duties: author ? { author_user_id: author } : {}, + }; +} + export function clientDutyIssues( plan: WorkflowPlan, members: Array<{ userId: number }>, @@ -35,9 +80,50 @@ export function clientDutyIssues( .flatMap((stage) => stage.doc_tasks.filter((task) => task.enabled)); for (const task of enabled) { - const mode = classifyClientDuty(task.duties); const field = `${task.task_key}.duties`; const label = task.task_title || task.task_key; + const policy = (task.duty_policy || '').trim(); + + if (policy && policy !== 'separated' && policy !== 'author_only') { + issues.push({ + code: 'UNKNOWN_DUTY_POLICY', + field: `${task.task_key}.duty_policy`, + message: `文档「${label}」的职责模式无效`, + }); + continue; + } + + if (policy === 'author_only') { + const author = task.duties?.author_user_id ?? 0; + if (author <= 0) { + issues.push({ + code: 'AUTHOR_ONLY_AUTHOR_REQUIRED', + field, + message: `文档「${label}」兼任模式必须指定编制人`, + }); + continue; + } + const reviewer = task.duties?.reviewer_user_id ?? 0; + const approver = task.duties?.approver_user_id ?? 0; + if ((reviewer > 0 && reviewer !== author) || (approver > 0 && approver !== author)) { + issues.push({ + code: 'AUTHOR_ONLY_DUTY_CONFLICT', + field, + message: `文档「${label}」兼任模式不能指定与编制人不同的审核人或批准人`, + }); + continue; + } + if (!memberIDs.has(author)) { + issues.push({ + code: 'DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER', + field: `${task.task_key}.author`, + message: `文档「${label}」的负责人不是当前成员`, + }); + } + continue; + } + + const { mode, effective } = resolveClientDocTaskDuty(task); if (mode === 'partial') { issues.push({ code: 'DUTY_PARTIAL_ASSIGNMENT', @@ -55,7 +141,7 @@ export function clientDutyIssues( continue; } if (mode === 'full') { - const ids = [task.duties.author_user_id, task.duties.reviewer_user_id, task.duties.approver_user_id]; + const ids = [effective.author_user_id, effective.reviewer_user_id, effective.approver_user_id]; if (new Set(ids).size !== ids.length) { issues.push({ code: 'SEPARATION_OF_DUTY_VIOLATION', @@ -64,9 +150,9 @@ export function clientDutyIssues( }); } for (const [role, id] of [ - ['author', task.duties.author_user_id], - ['reviewer', task.duties.reviewer_user_id], - ['approver', task.duties.approver_user_id], + ['author', effective.author_user_id], + ['reviewer', effective.reviewer_user_id], + ['approver', effective.approver_user_id], ] as const) { if (id && !memberIDs.has(id)) { issues.push({ @@ -82,13 +168,16 @@ export function clientDutyIssues( } export function needsThreeMembersForDuties(plan: WorkflowPlan, requireApprovalKeys: string[] = []): boolean { - return plan.stages.some((stage) => - stage.enabled && - stage.doc_tasks.some((task) => { - if (!task.enabled) return false; - if (!task.approval_configurable || requireApprovalKeys.includes(task.task_key)) return true; - return classifyClientDuty(task.duties) === 'full'; - }), + return plan.stages.some( + (stage) => + stage.enabled && + stage.doc_tasks.some((task) => { + if (!task.enabled) return false; + const { mode, isAuthorOnly } = resolveClientDocTaskDuty(task); + if (isAuthorOnly) return false; + if (!task.approval_configurable || requireApprovalKeys.includes(task.task_key)) return true; + return mode === 'full'; + }), ); } diff --git a/web/src/types/block.ts b/web/src/types/block.ts index 68c1d7c2..e77c261b 100644 --- a/web/src/types/block.ts +++ b/web/src/types/block.ts @@ -67,6 +67,8 @@ export interface DocumentAST { approverId?: number; /** 服务端受控职责为权威来源;即使 id 为 0 也不得按 name 提权。 */ dutiesAuthoritative?: boolean; + /** 逐文档职责模式;author_only 表示编制人兼任审核与批准。 */ + dutyPolicy?: string; /** 打开受控文档时取得的 revision-state ETag,人工 PATCH 必须使用该基线。 */ revisionEtag?: string; summary?: string; diff --git a/web/src/types/projectSettings.ts b/web/src/types/projectSettings.ts index f060febb..71246f5a 100644 --- a/web/src/types/projectSettings.ts +++ b/web/src/types/projectSettings.ts @@ -4,11 +4,15 @@ export interface DocDuty { approver_user_id?: number; } +export type DutyPolicy = 'separated' | 'author_only'; + export interface DocTaskPlan { task_key: string; task_title?: string; enabled: boolean; approval_configurable?: boolean; + /** 逐文档职责模式;缺省/空值按 separated 解读。 */ + duty_policy?: DutyPolicy | ''; duties: DocDuty; } @@ -48,6 +52,12 @@ export interface WorkflowEventSummary { created_at: string; } +export interface GovernancePolicyCapability { + allowed: boolean; + revision: number; + blocked_reason?: string; +} + export interface ProjectSettings { project_id: number; lifecycle_status: string; @@ -62,6 +72,7 @@ export interface ProjectSettings { live_instance?: { id: number; status: string; config_revision: number }; published_workflows?: PublishedOption[]; recent_workflow_events?: WorkflowEventSummary[]; + author_only_policy?: GovernancePolicyCapability; } export interface TerminationPreview { @@ -72,6 +83,12 @@ export interface TerminationPreview { impact?: string; } +export interface PreviewDocRef { + task_key: string; + title: string; + author_user_id?: number; +} + export interface ActivationPreview { confirmation_token: string; plan_hash: string; @@ -85,7 +102,9 @@ export interface ActivationPreview { governed_doc_tasks?: number; unmanaged_doc_tasks?: number; assigned_duties: number; - unmanaged_documents?: Array<{ task_key: string; title: string }>; + author_only_doc_tasks?: number; + unmanaged_documents?: PreviewDocRef[]; + author_only_documents?: PreviewDocRef[]; first_stage_key?: string; first_stage_name?: string; }; diff --git a/web/src/types/projectWorkflow.ts b/web/src/types/projectWorkflow.ts index c625b1cf..a807b34b 100644 --- a/web/src/types/projectWorkflow.ts +++ b/web/src/types/projectWorkflow.ts @@ -75,17 +75,3 @@ export interface CreateProjectInput { /** @deprecated Accepted by API for catalog checks only; create dialog must not send this. */ initial_workflow_key?: string; } - -export interface ActivateWorkflowInput { - workflow_key: string; - expected_version: number; - reason?: string; -} - -export interface ActivateWorkflowResult { - project_id: number; - active_workflow_key: string; - version: number; - changed_at: string; - changed_by: number; -} diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 0086882a..cbd30934 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -321,6 +321,8 @@ export interface DeliverableItem { approverId?: number; /** 来自服务端 summary 的职责;true 时禁止 name fallback 提权。 */ dutiesAuthoritative?: boolean; + /** 逐文档职责模式;author_only 表示编制人兼任审核与批准。 */ + dutyPolicy?: string; updatedAt: string; summary?: string; sourceMessageSnippet?: string;