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/archive/design/2026-09-17-personal-engineering-control-center-plan.md b/.doc/archive/design/2026-09-17-personal-engineering-control-center-plan.md new file mode 100644 index 00000000..db87f8bb --- /dev/null +++ b/.doc/archive/design/2026-09-17-personal-engineering-control-center-plan.md @@ -0,0 +1,609 @@ +--- +title: "个人工程中控台:实时项目看板与离线 HTML 快照" +type: feat +date: 2026-09-17 +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-plan-bootstrap +execution: code +--- + +> **已废弃(2026-09-17;2026-09-19 更新)**:旧版只读工程中控台代码已移除。后来短暂合入的 Control Center 也已移出本仓库,进度管理由独立项目承担。本文仅作历史证据保留。 + +# 个人工程中控台:实时项目看板与离线 HTML 快照 + +## Goal Capsule + +为仓库所有者提供一个独立于产品后台的本地只读中控台。打开一个页面即可判断当前系统由什么组成、哪些任务正在计划或实施、真实分支和 worktree 在哪里、各环境实际运行了什么,并能在两次点击内到达对应的设计、审计、整改、任务和测试材料。 + +中控台同时提供两种输出: + +1. `serve`:持续采集本地仓库、Git 和运行环境,提供自动刷新的 HTML 页面与机器可读 JSON; +2. `export`:基于同一份不可变快照生成单文件 HTML,便于归档、离线查看和交接。 + +这不是产品功能,不接入产品后台、产品数据库或产品权限体系;它也不是发布系统,不执行 Git、Docker、部署或文档写操作。 + +### 成功标准 + +- 在当前仓库规模下,首次可用页面目标为 2 秒内出现;慢数据源可随后标记为 `degraded`,不得拖垮整页。 +- 正常情况下,本地文件、Git 或运行态变化在 5 秒内反映到新快照。 +- 页面分别展示“文档声明、Git 实施、验证证据、环境实装”,不把任务文档的 `done` 翻译为已合并或已部署。 +- 每个关键结论均显示来源、采集时间、新鲜度和可信类型;冲突保留双方证据,不静默选边。 +- 在线模式检索 allowlist 内未截断的 Markdown 正文;离线导出同时给出 included、truncated 和 metadata-only 清单,二进制附件只索引路径、类型、大小和归属。 +- 单文件 HTML 默认权限为 `0600`,可脱离仓库阅读已纳入 inclusion manifest 的 Markdown 正文,并醒目标出截断或仅元数据条目;不打包 `.doc/**/assets`。 +- 任一采集源失败时仍可查看最近成功数据,并明确显示 `stale/error/last_success_at`。 + +--- + +## Product Contract + +### 用户与使用场景 + +唯一主要用户是仓库所有者。典型使用包括: + +- 每天开始工作时查看焦点任务、阻塞、集成槽和环境异常; +- 从模块进入设计、任务、测试和部署证据链; +- 判断某项工作究竟只是有计划、正在实现、已经验证,还是已在某环境实装; +- 查看本地分支、远端跟踪引用、ahead/behind、worktree 和未提交变更; +- 发现 `CURRENT`、功能矩阵、发布门禁、Git 与 Docker 之间的漂移; +- 导出一个自包含 HTML,作为某一时点的项目快照。 + +### 已确定的产品决策 + +#### PKD-1:独立本地工具 + +采用独立 Go CLI 和本地 Web 页面,不加入现有产品后台。 + +(session-settled: user-approved — chosen over product-admin integration: the console is for the repository owner’s local engineering management and the production image excludes `.doc` and `.git`) + +#### PKD-2:只读实时页与离线快照共用数据模型 + +实时服务和导出文件必须由同一份版本化快照生成,避免在线页面与离线报告出现两套口径。 + +(session-settled: user-approved — chosen over a static-only report: the requested console must follow agent tasks and project state as they change) + +### 核心需求 + +| ID | 需求 | +|---|---| +| R1 | CLI 默认解析当前 Git 仓库根,也接受显式 `--repo`;页面始终显示绝对根路径、当前分支、HEAD 和采集时间。 | +| R2 | 仅监听 `127.0.0.1`,不依赖产品容器、产品数据库或产品登录。 | +| R3 | 架构与模块页聚合 `module-boundaries.yaml`、系统设计索引和 `FEATURE-MATRIX`,展示模块、能力、依赖和证据链。 | +| R4 | 任务状态至少拆为 `doc_status`、Git facets、`verification_result`/`verification_freshness`、deployment facets;可并存事实不得压成单值枚举。 | +| R5 | Git 页展示本地/跟踪分支、ahead/behind、最近提交、worktree、dirty/untracked 和任务声明匹配情况;不得隐式 `fetch`。 | +| R6 | 部署页按 shared/dev/test/main 分别展示容器状态、应用 readiness、服务完整性、schema、镜像与 promotion receipt;不得计算未经定义的综合绿灯。 | +| R7 | 文档页支持按 taxonomy、状态、模块和关键词筛选,展示正文、正向链接、反向链接、broken、orphan 与 unindexed 状态;查询状态可通过 URL 恢复。 | +| R8 | 一个不可变快照包含 `schema_version`、`generation_id`、`revision_id`、`generated_at`、仓库/提交身份、来源状态、事实与冲突。 | +| R9 | 自动刷新采用完整快照原子切换;每个来源独立超时,失败时保留最近成功数据并标注过期。 | +| R10 | 每条关键事实标注 `declared`、`observed`、`verified` 或 `inferred`,并保留来源路径和采集时间。 | +| R11 | 单 HTML 内嵌结构化模型、页面资源和允许导出的 Markdown 正文;附件只保留元数据及相对链接,并显示内容覆盖清单。 | +| R12 | 对 Agent 暴露稳定的只读 JSON,而不是让 Agent 解析 HTML;JSON 与页面引用同一 `generation_id` 和 `revision_id`。 | +| R13 | TaskState 显示声明的 owner/Agent 类型/认领/mutex 与基于 Git 的最近活动证据;Herdr 工程 Agent 和产品 PI Agent Runner 使用不同类型。没有运行态来源时必须显示 unknown,不得把“已认领”写成“正在执行”。 | +| R14 | 文件、命令和 HTTP 均采用 allowlist;拒绝路径穿越、symlink 越界、原始 HTML 执行、远程资源加载和凭证读取。 | + +### 事实源与权威边界 + +| 领域 | 权威来源 | 中控台处理方式 | +|---|---|---| +| 发布门禁 | `.doc/remediation/gap-matrix.md` | 只解释发布 blocker,不替代能力完成度。 | +| 能力成熟度 | `.doc/status/FEATURE-MATRIX.md` | 显示功能完成度,不据此推断是否部署。 | +| 当前焦点、认领、集成槽 | `.doc/ops/CURRENT.md` | 作为团队声明,与 Git 和运行态对账。 | +| 任务计划 | `ops/short-term`、`ops/long-term`、backlog front matter | 作为计划状态,不据此推断实现。 | +| 分支与 worktree | Git plumbing | 作为本地观测;远端状态注明“基于本地 refs,未联网刷新”。 | +| 环境运行态 | Docker/Compose 元数据与 `/ready` | 分开展示容器、应用与服务完整性。 | +| 验收证据 | `.doc/test-reports` 及 promotion receipts | 显示证据存在性与身份,不以文件名替代内容校验。 | + +来源之间不设全局覆盖顺序。各来源回答自己的问题;值不一致时生成 drift,并并列展示原值、时间和来源。 + +### 用户流程 + +```mermaid +flowchart LR + Start[启动 serve 或 export] --> Root[解析显式 repo root] + Root --> Collect[并行采集文档 Git 运行态] + Collect --> Snapshot[构建不可变快照] + Snapshot --> Reconcile[按领域对账] + Reconcile --> Live[本地实时页面和 JSON] + Reconcile --> Export[单文件 HTML] + Live --> Refresh[定时生成下一快照] + Refresh --> Snapshot +``` + +总览默认优先显示 `in_progress`、`blocked`、`planned`、`partial/deferred`、drift 和 degraded。`done` 与 archive 默认收起,避免历史材料淹没当前工作。 + +任务详情按照以下证据链导航: + +```mermaid +flowchart LR + Audit[audit 问题] --> Remediation[remediation 方案] + Remediation --> Task[任务声明] + Task --> Git[分支 worktree commit merge] + Git --> Test[测试与验收报告] + Test --> Deploy[环境 artifact 与 readiness] +``` + +显式 Markdown 链接和既定 taxonomy 属于强关系;仅由文件名或 slug 推断的关系标为“候选”,不得参与完成度计算。 + +### 状态模型 + +```mermaid +flowchart TB + D[doc_status: planned/in_progress/blocked/done] --> View[并列状态视图] + G[Git facets: branch/worktree/merge/cleanliness/ahead-behind] --> View + V[Verification: result + freshness + bound identity] --> View + P[Deployment facets: container/readiness/services/artifact identity] --> View + View --> Drift{来源是否一致} + Drift -->|是| Consistent[consistent] + Drift -->|否| Conflict[drift + 双方证据] +``` + +“实装”定义为存在可核对的环境观测证据,而不是任务文档为 `done`。容器可达但 artifact identity 无法核对时显示“运行可见、版本未验证”,不得提升为 verified deployment。当文档声明 healthy、Docker 显示异常而 `/ready` 为 ok 时,页面保留多值并标记 drift,不输出统一绿灯。 + +### 真实验收样本 + +1. rdev:存在调研、设计、任务与 backlog,但若没有 CURRENT 认领和真实分支,应显示“已计划、未认领、未实施、未实装”。 +2. main 环境:若 CURRENT 声明整体 healthy,而 events 容器实际 restarting、应用 `/ready` 仍为 ok,应同时显示三项证据与 drift。 +3. 本地 dev:若分支领先 `origin/dev`,必须显示“本地已具备、远端未确认可用”,不得写成已远程发布。 +4. `system-admin-console`:作为产品管理后台能力,与本计划的个人工程中控台分开展示,名称和边界不得混淆。 + +### 安全与非目标 + +一期明确不做: + +- 不写文档,不修改任务状态,不占用集成槽; +- 不创建、切换、合并、删除或推送 Git 分支; +- 不执行 `git fetch`,不调用远端托管平台 API; +- 不启动、停止、重建或部署容器; +- 不控制 Herdr 或向 Agent 派发任务; +- 不读取 `.env`、`.secrets`、Docker `Config.Env`、数据库数据或 `.git` 内部文件正文; +- 不加入产品 API、产品前端、产品数据库或生产镜像; +- 不执行 Markdown 原始 HTML、脚本、Mermaid 或远程资源。 + +后续候选包括跨快照历史、远端 CI/Gitea 聚合、受限 Markdown/Mermaid 渲染、Agent 查询 API 和修复建议。任何写操作继续保持在本工具之外。 + +--- + +## Planning Contract + +### High-Level Technical Design + +以下图示冻结边界和协议,不规定具体函数签名。 + +```mermaid +flowchart LR + Docs[.doc sources] --> DC[Document collector] + Git[Git/worktrees] --> GC[Git collector] + Run[Docker/ready/receipts] --> RC[Runtime collector] + DC --> Build[Snapshot builder] + GC --> Build + RC --> Build + Build --> Rec[Reconcile facts and conflicts] + Rec --> JSON[Read-only JSON] + Rec --> Web[Local HTML] + Rec --> File[Offline HTML] +``` + +```mermaid +sequenceDiagram + participant UI as Browser + participant C as Collection coordinator + participant S as Sources + participant P as Snapshot publisher + UI->>C: poll with revision_id + C-->>UI: current snapshot immediately + C->>S: singleflight collection with deadlines + S-->>C: facts or source error + C->>P: publish complete generation atomically + P-->>UI: new revision or not-modified +``` + +```mermaid +stateDiagram-v2 + [*] --> Loading + Loading --> Fresh: first collection succeeds + Loading --> ErrorUnknown: first collection fails + Fresh --> Refreshing: TTL expires + Refreshing --> Fresh: new generation succeeds + Refreshing --> Stale: source fails and last-known exists + Stale --> Fresh: source recovers + ErrorUnknown --> Fresh: source first succeeds +``` + +### 技术决策 + +#### KTD-1:采用仓库内独立 Go CLI + +新增 `cmd/engineering-dashboard` 与 `internal/engineeringdashboard`,复用仓库 Go 工具链但不导入产品业务包。Go 标准库足以提供本地 HTTP、资源嵌入、JSON、并发和原子文件输出;现有 `yaml.v3` 可用于已定义的 YAML 契约。 + +#### KTD-2:快照是唯一交付模型 + +采集器只生产事实,reconcile 层只生产派生状态和冲突,HTTP/API/export 只消费已完成的不可变快照。快照至少包含: + +```text +schema_version, generation_id, revision_id, generated_at, collection_duration +repository { root, branch, head, dirty } +sources[] { name, status, observed_at, last_success_at, error } +facts[] { subject, field, value, provenance, source_ref, observed_at, confidence } +conflicts[] { subject, field, kind, fact_refs[] } +documents[], modules[], tasks[], git[], environments[] +``` + +`generation_id` 唯一标识一次采集,包含采集批次语义;`revision_id` 只由 schema、仓库身份和规范化的非易变事实、冲突、来源状态确定,明确排除 `generated_at`、`observed_at` 和采集耗时。HTML 与 JSON 必须展示同一对标识,HTTP ETag 和前端是否重绘使用 `revision_id`。一期不读取旧 schema 的外部 JSON;离线 HTML 自带对应 renderer,因此不依赖未来二进制迁移。 + +#### KTD-3:专用解析器与容错索引并存 + +对 CURRENT、FEATURE-MATRIX、gap-matrix、任务 front matter 和 module boundaries 编写契约化解析器。文档层只索引 promotion receipt 的路径与链接,结构化部署凭证由运行态采集器唯一负责。对其余 Markdown 只提取安全元数据、标题、正文与链接。专用解析器失败时保留原文入口并标记 degraded,不凭模糊规则编造状态。 + +#### KTD-4:命令执行采用固定 argv 和超时 + +Git 与 Docker 采集通过注入式 command runner 执行固定程序和分离 argv,不通过 shell 拼接。`--repo` 先转为绝对、symlink-resolved 的 Git 根并作为单个参数传递。每个来源默认 2 秒超时;错误输出做长度限制和敏感字段清理。Git 使用 porcelain/plumbing 输出,例如 `status --porcelain=v2`、`worktree list --porcelain`、`for-each-ref`、`rev-list`、`merge-base`。 + +#### KTD-5:一期采用短轮询,不引入持久数据库 + +服务端由单一采集协调器按 TTL 触发完整重采集,2–5 秒刷新;同一仓库最多一轮采集在途,请求只读取当前快照,不直接叠加扫描。每轮带单调 generation 序号和整轮 deadline,只有当前有效轮次能原子发布。页面使用 ETag/`revision_id` 避免重复传输。last-known 只在同一 `serve` 进程内有效;冷启动或单次 export 首次采集失败时显示 unknown/error,不伪造历史值。文件监听、SSE 通知、持久缓存和历史数据库均后置。 + +#### KTD-6:安全阅读优先于富渲染 + +在线详情页一期将 Markdown 全部作为纯文本安全转义,并保留换行和空白;标题、列表、链接、代码、图片和 Mermaid 均显示源码,不生成 HTML 节点或发起资源请求。静态导出内嵌同一正文和内部导航。所有文档 ID 都是规范化仓库相对路径,只允许 `.doc/**/*.md` 及明确列入 allowlist 的根 Markdown;读取前后都校验解析路径,直接拒绝 symlink,并对 `.git`、`.env*`、`.secrets`、`data` 等敏感路径执行最终目标 denylist。 + +#### KTD-7:导出为自包含、有限尺寸的证据快照 + +导出将模型、CSS、JS 和允许的 Markdown 正文写入单 HTML,采用临时文件加 rename 原子落盘,默认 `0600`。数据使用 HTML-safe JSON 或等价编码封装,正文只通过 `textContent`/DOM API 写入;CSP 禁止网络、外部资源和内联事件执行。默认只嵌入 Git tracked、allowlist 内的 Markdown;untracked Markdown 只列元数据,除非用户显式选择。导出前输出 inclusion manifest 与敏感模式告警;`.doc/**/assets`、数据库、构建产物和二进制报告不嵌入。 + +### 建议目录 + +```text +cmd/engineering-dashboard/ + main.go +internal/engineeringdashboard/ + model.go + collector.go + source_docs.go + source_git.go + source_runtime.go + reconcile.go + server.go + export.go + assets/ + index.html + app.js + app.css + testdata/ + docs/ + git/ + runtime/ +scripts/ + engineering-dashboard.sh +.doc/test-reports/ + YYYY-MM-DD-engineering-dashboard-test-report.md +``` + +### 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| 文档结构并不完全统一 | 只对 SSOT 和任务 front matter 使用严格契约;其余文档容错索引并暴露解析告警。 | +| 看板制造“假完成” | 多轴 facets、事实 provenance、验证身份绑定、冲突不覆盖、真实反例测试。 | +| Docker/Git 卡住或不可用 | 独立超时、并行采集、last-known 状态、degraded 页面。 | +| 文档 XSS 或路径穿越 | 纯文本渲染、HTML-safe 数据封装、CSP、路径 allowlist、拒绝 symlink、最终目标敏感路径 denylist。 | +| `.doc` 附件导致扫描/导出失控 | 默认跳过 assets 内容,只采集元数据;设置单文档和总正文上限并报告截断。 | +| 导出副本离开本机后 `0600` 不再提供保护 | 页面首部标明敏感快照、仓库身份和生成时间;输出 inclusion manifest,交接者自行控制接收者、保留期与删除。 | +| worktree 与主 checkout 混淆 | 只管理 `--repo` 指定/解析出的根,页面常驻显示根与 HEAD;其他 worktree 仅作为 Git 元数据。 | +| 本地 remote refs 被误认为远端实时状态 | 显示 refs 观测时间和“未联网刷新”,不自动 fetch。 | +| 与产品 `system-admin-console` 命名冲突 | CLI、包和文档统一使用 `engineering-dashboard` / “个人工程中控台”。 | + +--- + +## Implementation Units + +### U1:冻结快照、事实与状态契约 + +**目标**:先建立不会把计划、实现、验证、部署混为一谈的模型与测试夹具。 + +**覆盖需求**:R4、R8、R10、R12、R13;承接 PKD-2 的同源快照决策。 + +**依赖**:无。 + +**文件**: + +- `internal/engineeringdashboard/model.go` +- `internal/engineeringdashboard/model_test.go` +- `internal/engineeringdashboard/testdata/**` + +**实施要点**: + +1. 定义版本化 `Snapshot`、`SourceState`、`Fact`、`Conflict`、`TaskState`、`AgentClaim`、`EnvironmentState`。 +2. provenance 固定为 `declared|observed|verified|inferred`;freshness 固定为 `fresh|stale|error|unknown`。 +3. 状态使用可组合 facets:Git 分为 branch/worktree/merge/cleanliness/ahead-behind;验证分为 result/freshness/subject identity;部署分为 container/readiness/services/artifact identity。 +4. TaskState 保留 owner、agent kind、claim、mutex、声明分支、观测 worktree、最近活动时间及证据;声明与观测不可互相提升。 +5. 建立 rdev、dev ahead、main events restarting、CURRENT/worktree 不匹配等夹具。 + +**测试场景**: + +- 文档 `done` 但无 merge/deploy 时,Git 与部署 facets 不改变; +- `claimed` 但无可观测运行信号时,Agent 活动保持 unknown;Herdr 与产品 PI Runner 不共享类型; +- 同字段多来源不一致时生成 conflict 并保留所有 fact 引用; +- JSON round-trip 保留 schema 与 snapshot identity; +- 枚举未知值失败且错误可定位。 + +**完成证据**:模型测试通过,schema 示例落入 Go fixture,不存在页面专用的第二套状态类型。 + +### U2:实现文档图谱与领域解析器 + +**目标**:将 `.doc` taxonomy、模块、任务、测试和链接转换为可追溯文档图谱。 + +**覆盖需求**:R3、R7、R10、R11、R14。 + +**依赖**:U1。 + +**文件**: + +- `internal/engineeringdashboard/source_docs.go` +- `internal/engineeringdashboard/docs_test.go` + +**实施要点**: + +1. 扫描 allowlist 内 Markdown,默认跳过任何 `assets` 目录正文;记录附件元数据。 +2. 解析 front matter、标题、显式 Markdown 链接和反向链接。 +3. 为 CURRENT、FEATURE-MATRIX、gap-matrix、任务文、module boundaries 和 test report 建立专用适配器;receipt 只在文档图谱中作为文档与链接,结构化解析归 U4。 +4. 区分 `broken`、`orphan`、`unindexed`;slug 相似只形成候选关系。 +5. 规范化路径并阻止 `..`、绝对路径和任何 symlink;最终目标再次应用扩展名 allowlist 与敏感路径 denylist。 + +**测试场景**: + +- taxonomy 内文档可检索且反向链接正确; +- broken link 不导致整个索引失败; +- front matter 缺失或非标准时产生告警并仍可阅读; +- assets 不读正文,越界 symlink 被拒绝; +- rdev 文档链被发现,但没有被推断为已实现。 + +**完成证据**:文档索引能从 fixture 生成稳定 JSON,路径安全负例通过。 + +### U3:实现 Git 与 worktree 采集 + +**目标**:以只读 Git plumbing 形成可复现的分支、提交和 worktree 观测。 + +**覆盖需求**:R1、R4、R5、R10、R14。 + +**依赖**:U1。 + +**文件**: + +- `internal/engineeringdashboard/source_git.go` +- `internal/engineeringdashboard/git_test.go` + +**实施要点**: + +1. 抽象 command runner,固定 argv、工作目录、超时和输出上限。 +2. 收集当前分支/HEAD、dirty/untracked、local/remote-tracking refs、ahead/behind、merge-base 和 worktree。 +3. 不执行 fetch;remote tracking ref 始终带本地观测时间与免责声明。 +4. 对账任务声明的 branch/worktree,但不因路径名相似而认定一致。 + +**测试场景**: + +- 本地分支领先 origin 时显示 ahead 且不显示“远端可用”; +- worktree 目录名与真实检出分支不一致时生成 drift; +- 命令超时、Git 不存在、非 Git 目录时单源 degraded; +- runner 记录中不存在 shell、fetch、push 或写命令。 + +**完成证据**:临时 Git 仓库集成测试和 fake runner 单元测试通过。 + +### U4:实现运行态与部署证据采集 + +**目标**:按环境独立呈现容器、应用 readiness、服务完整性、schema、镜像和晋升凭证。 + +**覆盖需求**:R4、R6、R10、R14。 + +**依赖**:U1。 + +**文件**: + +- `internal/engineeringdashboard/source_runtime.go` +- `internal/engineeringdashboard/runtime_test.go` + +**实施要点**: + +1. 从仓库内批准的 Compose 静态配置和 allowlist labels 映射环境与服务;无法明确映射时显示 unknown,不用名称猜测。容器 JSON 只提取容器名、image/digest、状态、restart count、health,以及 allowlist 内的 Compose project/service labels;不复制 Env、Mounts、完整 NetworkSettings 或任意 labels。 +2. 对 allowlist 中的本地 `/ready` 端点执行短超时、响应大小受限的请求;禁用重定向,或对每一跳重新校验 scheme、解析后 IP、端口和 host。 +3. 解析 promotion receipt/candidate image 等仓库证据,保留 artifact identity。 +4. 将 container health、app readiness、required-service completeness 分开建模。 + +**测试场景**: + +- app ready 且 events restarting 时同时保留两项状态; +- Docker 不可用或 HTTP 超时时,其他数据源仍可输出; +- receipt 与容器 image 不一致时生成 deployment drift;容器可达但 identity 缺失时标记“运行可见、版本未验证”; +- readiness 重定向到外网或非 allowlist 本地端口时拒绝访问; +- 输出中不存在容器环境变量或凭证。 + +**完成证据**:fixture 和 fake HTTP/Docker runner 测试通过,main 分叉场景被覆盖。 + +### U5:实现对账、派生视图与新鲜度 + +**目标**:把来源事实组合成用户可判断的视图,但不替来源做未经定义的裁决。 + +**覆盖需求**:R3、R4、R6、R8、R9、R10、R13。 + +**依赖**:U2、U3、U4。 + +**文件**: + +- `internal/engineeringdashboard/reconcile.go` +- `internal/engineeringdashboard/reconcile_test.go` + +**实施要点**: + +1. 按领域 SSOT 规则构建 module、task、environment 和 evidence-chain 视图。 +2. 实现 conflict 分类:declared-vs-observed、task-vs-git、test-vs-subject、receipt-vs-runtime、stale-source、missing-evidence。 +3. 快照构建期间每个来源独立记录 `observed_at`、持续时间和错误;成功后一次性发布。 +4. 失败源只可复用同一 serve 进程的 last-known,并标注 stale、age 与 last-success;冷启动无缓存时为 unknown/error。过期数据不隐藏,但按年龄提升告警级别。 +5. 测试报告只有在 commit/artifact identity 与当前 subject 对齐时才能派生 pass;身份缺失为 unbound,身份不匹配为 stale/drift。 + +**测试场景**: + +- 三个真实样本得到预期多轴状态; +- 同一组输入顺序变化不改变规范化快照; +- 单源失败不会删除其他源,也不会重写 last-known 的观测时间;冷启动失败没有伪造 last-known; +- 旧测试报告、新 HEAD 与不同 image 不会派生当前验证通过; +- 恢复后 stale 自动清除并产生新 snapshot ID。 + +**完成证据**:reconcile table tests 覆盖一致、漂移、缺失、过期和恢复。 + +### U6:实现本地服务、总览与文档导航 + +**目标**:提供能快速判断异常并下钻证据的本地只读页面和 JSON。 + +**覆盖需求**:R1、R2、R3、R7–R10、R12–R14;承接 PKD-1 和 PKD-2。 + +**依赖**:U5。 + +**文件**: + +- `internal/engineeringdashboard/server.go` +- `internal/engineeringdashboard/server_test.go` +- `internal/engineeringdashboard/assets/index.html` +- `internal/engineeringdashboard/assets/app.js` +- `internal/engineeringdashboard/assets/app.css` +- `cmd/engineering-dashboard/main.go` + +**实施要点**: + +1. `serve --repo --addr 127.0.0.1:`;一期无条件拒绝所有非 loopback 地址,包括通配、IPv6 非 loopback 和显式传入值。 +2. 暴露 `/api/v1/snapshot`、`/api/v1/doc/`、`/healthz`;所有接口只读。严格校验 Host/Origin/Fetch Metadata,不信任代理转发头;页面和 API 使用每次启动生成的不可预测访问令牌,降低 DNS rebinding 与跨站读取风险。 +3. 页面分为总览、架构/模块、任务、Git、部署、文档和证据链。 +4. 自动刷新完整快照,保留当前筛选、滚动位置、焦点和选中文档;ETag 未变化时不重绘。 +5. 明确一级视图状态:initial loading、fresh empty、fresh success、partial/degraded with last-known、hard error without cache、refreshing、recovered。刷新中继续展示上一完整快照,直到下一版本原子替换。 +6. 搜索覆盖标题、仓库相对路径、front matter 和未截断正文;不同维度用 AND、同一维度多选用 OR,默认先排当前任务和异常证据,再按匹配度与路径稳定排序。筛选、查询和选中文档写入 URL 参数,支持浏览器前进/后退与深链。 +7. 桌面优先;窄屏下侧栏变为可关闭抽屉,宽表格保留可横向滚动的语义表头,详情区单列。所有功能可键盘完成,使用 landmark/标题/表格语义,drift/degraded 不只靠颜色表达,自动刷新通过非打断式 live region 报告且不抢焦点。 +8. Markdown 正文只进入文本节点;在线页与导出页均设置限制性 CSP,不执行 HTML、脚本、SVG、远程 URL 或事件属性。 + +**测试场景**: + +- 只监听 loopback,方法 allowlist 拒绝写请求;Host/Origin/token、DNS rebinding 和跨 Origin 负例被拒绝; +- HTML 与 JSON 的 generation/revision identity 一致; +- 文档 ID 越界、symlink 越界、未知 ID 返回安全错误; +- degraded source 在总览可见且页面仍可导航; +- 任务状态四轴、drift 和来源时间在浏览器中可见。 +- 首次加载、空数据、无缓存错误、刷新、恢复状态可区分;键盘、焦点、语义结构、窄屏和非颜色告警验收通过。 +- 搜索组合逻辑、排序、无结果、URL 深链和刷新后的视图保持通过。 + +**完成证据**:HTTP tests、基本无障碍检查和真实浏览器验收通过。 + +### U7:实现离线导出、启动脚本与治理文档 + +**目标**:生成安全、可携带、可追踪的单文件快照,并完成仓库治理接线。 + +**覆盖需求**:R1、R8、R9、R11、R14;承接 PKD-1 和 PKD-2。 + +**依赖**:U6。 + +**文件**: + +- `internal/engineeringdashboard/export.go` +- `internal/engineeringdashboard/export_test.go` +- `scripts/engineering-dashboard.sh` +- `.doc/status/FEATURE-MATRIX.md` +- `.doc/ops/CURRENT.md` +- `.doc/test-reports/YYYY-MM-DD-engineering-dashboard-test-report.md` + +**实施要点**: + +1. `export --repo --output ` 构建一次不可变快照并原子写入;首次采集失败的来源显示 unknown/error。 +2. 以 HTML-safe JSON/等价编码内嵌允许的 Markdown 正文、索引和页面资源;附件保持元数据与相对链接,所有正文只写入文本节点。 +3. 默认权限 `0600`,输出页显示生成时间、repo、HEAD、snapshot ID 和 degraded/drift。 +4. 薄脚本只负责定位仓库与调用 Go CLI,不隐藏参数或执行安装/部署副作用。 +5. 实施期间按仓库规范更新 CURRENT、FEATURE-MATRIX 和 test report;完成后再迁移任务生命周期状态。 + +**测试场景**: + +- 导出文件脱离仓库后仍能搜索和阅读 Markdown; +- 导出中不含附件字节、绝对凭证路径、env 值或远程请求;``、事件属性、SVG、危险 URL scheme 和 Unicode 边界载荷不能逃逸数据上下文; +- 默认 tracked/untracked 与敏感模式策略、inclusion manifest 和覆盖清单符合契约; +- 采集部分失败时仍能导出并保留错误; +- 输出中 JSON/HTML snapshot identity 一致,权限符合预期; +- 大文档和总正文上限触发时明确标记截断。 + +**完成证据**:export tests、离线浏览器验收和治理文档回链完整。 + +--- + +## Verification Contract + +### 自动验证 + +实施阶段至少运行: + +```bash +go test ./internal/engineeringdashboard/... +go test ./cmd/engineering-dashboard/... +go vet ./... +go test -short ./... +git diff --check +``` + +涉及共享容器的只读验收也必须遵守 CURRENT 集成槽规则;中控台本身不得为测试启动、停止或重建共享容器。 + +### 契约验证矩阵 + +| 领域 | 必须证明 | +|---|---| +| 模型 | 计划、Git facets、验证、部署 facets 互不覆盖;Agent 声明和运行观测分离;所有派生结论可追溯到 facts。 | +| 文档 | taxonomy、全文检索、反链、broken/unindexed、路径安全与 assets 排除。 | +| Git | ahead/behind、worktree、dirty、local refs 新鲜度和无隐式 fetch。 | +| 部署 | container/app/service 三类信号分离,receipt/artifact 身份可核对。 | +| 降级 | 每个源超时、错误、stale、恢复都不会让整页不可用。 | +| 实时 | 变化在目标时间内形成完整新快照,不出现半新半旧卡片。 | +| 导出 | 单文件离线可读、默认 `0600`、不嵌 assets、不产生网络请求。 | +| 安全 | loopback、Host/Origin/startup token、read-only methods、命令 allowlist、路径边界、Docker 字段 allowlist、CSP 与输出脱敏。 | + +### 浏览器验收 + +1. 从总览进入 rdev,确认“计划/实施/验证/实装”四轴与源文档链接均可见。 +2. 在仓库外复制导出 HTML,断网打开并全文搜索、阅读至少一份设计与一份测试报告。 +3. 制造 fixture 中的 CURRENT/Docker/readiness 冲突,确认页面保留多值且不出现综合绿灯。 +4. 切换 fixture 快照,确认页面刷新后筛选条件和当前文档保持不变。 +5. 尝试路径穿越、未知文档、POST、DNS rebinding、跨 Origin、readiness 重定向和含原始 HTML/脚本逃逸载荷的 Markdown,确认均不能越界、外联或执行。 + +### 性能与容量验证 + +- 记录当前 `.doc` 规模下的冷启动、增量刷新、JSON 大小和 HTML 导出大小。 +- 建议一期门槛:UI shell 与来源状态在 2 秒内可交互,P95 完整快照重建不超过 5 秒,单源超时不超过 2 秒;基线不达标时必须记录实测并拆分后台正文索引,不能伪造 PASS。 +- 导出达到配置上限时必须显式列出被截断文档,不允许静默丢失。 + +--- + +## Definition of Done + +- [ ] `serve` 和 `export` 在显式仓库根下可重复运行,且没有产品后台依赖。 +- [ ] 实时 HTML、离线 HTML 与 JSON 使用同一版本化快照契约。 +- [ ] `generation_id` 标识采集实例,`revision_id` 标识内容版本并驱动 ETag;无内容变化不会触发重绘。 +- [ ] 架构、模块、功能、任务、Git、部署、文档和证据链均可从页面访问。 +- [ ] 计划、实施、验证、实装四轴独立,rdev、dev ahead、main events 分叉样本通过。 +- [ ] Agent 认领与可观测活动分开,Herdr 工程 Agent 与产品 PI Agent Runner 不混称。 +- [ ] 所有 drift、degraded、stale 都带来源和时间,不存在未经定义的综合绿灯。 +- [ ] 默认 loopback、Host/Origin/startup token、只读命令、路径边界、脱敏、CSP 和 Markdown 安全测试通过。 +- [ ] 首次加载、空数据、部分失败、无缓存失败、恢复、键盘、窄屏和 URL 深链验收通过。 +- [ ] 导出 HTML 脱离仓库可读 Markdown,且不包含二进制 assets 或凭证数据。 +- [ ] 单元、集成、全仓短测、浏览器验收和 `git diff --check` 均有真实输出。 +- [ ] `.doc/test-reports` 报告回链任务文,FEATURE-MATRIX 与 CURRENT 按实际状态更新。 +- [ ] 没有遗留调试入口、写操作按钮、未使用的替代模型或与产品后台重名的组件。 + +--- + +## Appendix + +### 一期交付边界 + +一期交付:本地只读服务、版本化快照 JSON、架构/任务/Git/部署/文档视图、四轴状态、drift/degraded、全文搜索、安全文档阅读和单 HTML 导出。 + +后续评估:跨快照历史趋势、远端托管/CI 聚合、受限富文本与 Mermaid、跨仓库聚合、面向 Agent 的筛选查询 API。 + +永久排除:在页面中修改文档、切换或推送分支、控制 Agent、执行部署、读取密钥、成为产品后台模块。 + +### 方案依据 + +- `.doc/README.md` 定义的文档 taxonomy 与生命周期; +- `.doc/ops/CURRENT.md` 的焦点、认领、集成槽与环境声明; +- `.doc/status/FEATURE-MATRIX.md` 的模块完成度口径; +- `.doc/remediation/gap-matrix.md` 的发布门禁口径; +- `.doc/system-design/README.md` 与模块边界清单; +- Git/worktree、Docker/Compose、`/ready` 和 promotion receipts 的当前可观测能力。 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-initial-load-performance-plan.md b/.doc/archive/ops/2026-09-17-initial-load-performance-plan.md new file mode 100644 index 00000000..b7f2b321 --- /dev/null +++ b/.doc/archive/ops/2026-09-17-initial-load-performance-plan.md @@ -0,0 +1,41 @@ +--- +title: "首次加载性能诊断与改进方案" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Codex +branch: feature/initial-load-performance-plan +worktree: .worktrees/feature-initial-load-performance-plan +mutex: loading-performance-audit +--- + +# 首次加载性能诊断与改进方案 + +## 目标 +以源码、现有容器静态资源和只读浏览器测量定位首次加载瓶颈,输出可审核的分期改进方案。 + +## 范围 +登录前首屏、登录后初始化、项目/文档首次打开、静态资源交付;区分事实与待验证假设。 + +## 非目标 +不修改业务代码,不重启/重建共享服务,不触发真实对话或文档写任务,不提交或推送其他人的工作。 + +## 步骤 +1. [x] 阅读仓库启动链;独立 dev 基线 worktree,主仓保留其他 Agent 分支。 +2. [x] 采集数值基线和源码证据。 +3. [x] 写 audit 和 remediation,同步 CURRENT,归档任务。 + +## 验收标准 +- [x] 原因有文件位置或运行时测量支持,未覆盖环境明确标注。 +- [x] 方案包含优先级、改动边界、性能预算、回归门禁和回滚。 + +## 证据 / 备注 +仅诊断与方案,不代表优化已实施。fetch 后 origin/dev 落后本地 dev;不拉回或覆盖本地工作。worktree 从创建瞬间的 dev 建立。 + +## 交付 + +- 诊断:`.doc/audit/2026-09-17-initial-load-performance-audit.md` +- 方案:`.doc/remediation/2026-09-17-initial-load-performance-plan.md` +- 报告:`.doc/test-reports/2026-09-17-initial-load-performance-baseline.md` +- 本轮仅方案;没有业务代码、没有部署、未提交、未合dev、未推送。主仓仅同步本任务认领。 +- 临时3199 Vite已停止;既有3100/8080/8081未重启。未占集成槽。 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/archive/ops/short-term/2026-09-17-engineering-dashboard.md b/.doc/archive/ops/short-term/2026-09-17-engineering-dashboard.md new file mode 100644 index 00000000..ea0dff3e --- /dev/null +++ b/.doc/archive/ops/short-term/2026-09-17-engineering-dashboard.md @@ -0,0 +1,59 @@ +--- +title: "个人工程中控台:实时项目看板与离线 HTML 快照" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor-Auto +branch: feature/engineering-dashboard +worktree: .worktrees/feature-engineering-dashboard +mutex: engineering-dashboard +--- + +# 个人工程中控台:实时项目看板与离线 HTML 快照 + +## 目标 + +实现一个独立于产品后台的本地只读中控台,以同一不可变快照驱动实时页面、机器可读 JSON 与单文件 HTML 导出。 + +## 范围 + +- 聚合 `.doc`、Git/worktree、Docker/Compose、`/ready` 和晋升凭证。 +- 展示架构、模块、功能、任务、分支、验证和环境实装证据。 +- 提供 Markdown 搜索、阅读、正反向链接和证据链导航。 +- 显示来源、新鲜度、degraded 与 drift,不把声明状态当作观测状态。 + +## 非目标 + +- 不接入产品后台、产品数据库或产品权限体系。 +- 不写文档,不操作 Git、Docker、部署或 Herdr。 +- 不隐式联网或执行 `git fetch`。 + +## 步骤 + +1. [x] U1:冻结快照、事实与四轴状态契约。 +2. [x] U2:实现文档图谱与领域解析器。 +3. [x] U3:实现 Git 与 worktree 采集。 +4. [x] U4:实现运行态与部署证据采集。 +5. [x] U5:实现对账、派生视图与新鲜度。 +6. [x] U6:实现本地服务、总览与文档导航。 +7. [x] U7:实现离线导出、脚本、浏览器验收和治理文档同步。 + +## 验收标准 + +- [x] 实时 HTML、离线 HTML 和 JSON 引用同一 `generation_id` 与 `revision_id`。 +- [x] 计划、实施、验证、实装四轴独立展示。 +- [x] rdev、dev ahead、main events 分叉三个真实样本按方案输出。 +- [x] 单源失败时页面仍可用,且显示 stale/error/last-success。 +- [x] 默认仅监听 loopback,无写操作、凭证暴露、路径越界和远程资源执行。 +- [x] 导出 HTML 离开仓库后仍可阅读 Markdown,且不嵌入 assets。 + +## 相关链接 + +- 设计与实施方案:[../../archive/design/2026-09-17-personal-engineering-control-center-plan.md](../../archive/design/2026-09-17-personal-engineering-control-center-plan.md) +- Test report: [../../test-reports/2026-09-17-engineering-dashboard-test-report.md](../../test-reports/2026-09-17-engineering-dashboard-test-report.md) + +## 证据 / 备注 + +- 实现提交序列见 `feature/engineering-dashboard`(U1→U7)。 +- 启动:`./scripts/engineering-dashboard.sh serve --repo . --addr 127.0.0.1:8787` +- 导出:`./scripts/engineering-dashboard.sh export --repo . --output /tmp/engdash.html` 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-initial-load-performance-audit.md b/.doc/audit/2026-09-17-initial-load-performance-audit.md new file mode 100644 index 00000000..7e02ea54 --- /dev/null +++ b/.doc/audit/2026-09-17-initial-load-performance-audit.md @@ -0,0 +1,117 @@ +--- +title: "首次加载慢:静态资源、字体与初始化请求诊断" +status: done +created: 2026-09-17 +updated: 2026-09-17 +severity: high +--- + +# 首次加载性能诊断 + +## 结论与适用范围 + +当前已复现的主要问题在浏览器加载入口:登录页也下载约 2.82 MB 未压缩 JavaScript;外部 Google Fonts CSS 阻塞页面加载;若用户访问 Vite 3100,首访进一步扩大为 207 个资源请求、12.24 MB。冷缓存与热缓存差异很大,与“尤其第一次慢”一致。 + +登录后还有已确认的请求放大和后端重复工作,但没有登录态浏览器瀑布图或 SQL 耗时,不能声称已量化其贡献。用户已确认 3100/8080/8081,系统重启后第一次访问约十数秒;设备、网络与“系统重启”包含宿主机还是仅应用尚未确认;本次未复现用户原始环境的完整等待时间。 + +- 主仓读取时 `feature/rdev-core@52f0fbc`;其他 Agent 正在工作,未切换其分支。 +- 文档独立 worktree 基线为 `dev@d0ef696`。创建前已 fetch;本地 dev 有未推送提交,未 reset/覆盖。 +- 实测现有 8080/8081/8000 构建入口与 3100 Vite;以资源 hash 为运行产物标识,**没有证据证明容器恰好等于上述源码 commit**。 +- “首次”定义为新浏览器 context、空 HTTP 缓存;没有重启服务、清空 DB 缓存或重启 Vite,故不是服务/依赖预构建冷启动。 +- 未登录、没有调用对话/文档写接口;浏览器探针未使用真实用户会话。 + +## 实测基线 + +工具:隔离 Playwright v1.63.0 Docker 浏览器,host 网络访问既有服务。宿主浏览器缺 libatk,改用本机已有浏览器镜像;未修改应用容器。每个场景一个样本,仅诊断,不是 p95 或交付验收。 + +| 场景 | 登录输入框可见 | FCP | 资源数 | transferSize 合计 | +|---|---:|---:|---:|---:| +| 8080 本机冷缓存 | 662 ms | 664 ms | 4 | 2,857,761 B | +| 同 context 再次导航 | 62 ms | 60 ms | 5 | 353 B | +| 8080 CDP 10 Mbps / 80 ms 冷缓存 | 2,636 ms | 2,636 ms | 4 | 2,857,761 B | +| 同 context 再次导航 | 60 ms | 60 ms | 5 | 353 B | +| 8080 CDP 2 Mbps / 80 ms 冷缓存 | 11,776 ms | 11,776 ms | 4 | 2,857,761 B | +| 3100 本机冷缓存 | 659 ms | 652 ms | 207 | 12,238,086 B | +| 3100 同 context 再次导航 | 162 ms | 160 ms | 208 | 52,253 B | + +transferSize 是浏览器 Resource Timing 可观测资源求和,不含顶层 HTML,不等于全链路抓包流量。输入框可见按导航命令开始至 selector 可见计时,FCP 来自页面 Performance API,两者采样时钟和帧提交不同。CDP 限速为模拟设置,不能代替真实公网 RTT;本机 document TTFB 仍约 1–3 ms。首次/再次均保持已有服务运行,未运行 Vite 冷预构建基准。 + +### F1. 入口包过大且未按需加载(高置信) + +- `web/src/main.tsx:2` 静态导入 App;`web/src/App.tsx:98,103,108,145-146` 静态导入导出、系统管理、文件预览等模块;未登录判断直到 `App.tsx:3461`,无法避免入口依赖下载和求值。 +- `web/src/features/replyExport/download.ts:1` → docx;`web/src/engine/docxRenderer.ts:19-22` → docx、KaTeX、Mermaid;`web/src/engine/xlsxRenderer.ts:1` → XLSX。 +- `web/src/components/doc/preview/FilePreviewPanel.tsx:19-24` 全部静态导入预览器;DocxViewer、ExcelViewer 各自静态导入重依赖。 +- 浏览器访问登录页即可观测大 JS。3100 瀑布中含 katex.js 485,944 B、xlsx.js 870,702 B(decodedBodySize)。并非仅仅源码中出现依赖但构建已经排除。 +- 8080 主 JS `/assets/index-BLnmtk_3.js` = **2,820,055 B**;8081 = 2,805,141 B;8000 = 2,798,137 B。 + +因果链:空缓存 → 登录视图与全部工具共入口 → 下载/解析无关依赖 → 网络较慢时登录表单迟迟出现。10 Mbps 下主 JS 下载约 2,373 ms;2 Mbps 首屏约 11.78 秒。没有采集 CPU trace,不能进一步把时间分解成具体库的执行成本。 + +### F2. 静态响应无压缩、无明确长缓存策略(高置信) + +- 三个构建入口均携带 `Accept-Encoding: gzip, br` 请求主 JS,响应没有 `Content-Encoding`,`Content-Length` 为原始字节数。 +- `internal/server/server.go:344-384` 使用 `http.ServeFile`,没有静态压缩协商或显式 Cache-Control。 +- 同一 8080 JS 离线 gzip 后 **796,146 B**,相对原始包减少约 **71.8%**。这是体积估算,不是已部署优化结果,也不是端到端提速承诺。 +- 资源有 Last-Modified,没有显式 Cache-Control/ETag。本次再次导航已命中浏览器缓存;**不能写成“完全没有缓存”**。问题是版本化资源缺少可预测的长期缓存契约。 + +源站是上述行为,未知用户实际入口是否另有反向代理压缩。优先在真正提供构建资源的层验证和修复。 + +### F3. 外部字体 CSS 可放大首次白屏(高置信机制,用户网络贡献待测) + +- `web/index.html:9-15` 的 Google Fonts 是 head 中普通 stylesheet。`display=swap` 控制字体显示,不会让外链 CSS 本身变为非阻塞。 +- 本机自然请求字体 CSS 耗时 **508 ms**,DOMContentLoaded 609 ms,登录可见 662 ms。 +- 浏览器拦截并立即失败字体 CSS:登录可见 **210 ms**。Playwright route 会禁用 HTTP cache,因此这个场景不能用于热缓存收益对比,也不能据单样本得出精确净收益。 +- 控制实验仅让字体 CSS 等待 5 秒再失败,其余请求正常:DOMContentLoaded **5,111 ms**、登录可见 **5,161 ms**。证明该依赖可以把外部等待传到登录首屏,**不表示自然字体请求真的用了 5 秒**。 + +### F4. 登录后项目成员扇出并放大会话同步(源码已证实,耗时待测) + +`web/src/App.tsx:524-540` 获取项目后,对每个项目调用成员接口;`:383-387` 每次成员结果用 map 生成新的 projects 数组;`:560-590` 的会话列表 effect 依赖 projects,因此成员返回可重复触发会话列表请求。取消标志仅忽略部分返回,没有 AbortController 终止已发请求。React 批处理可能合并更新,**不能写成固定 N 次会话请求**。 + +项目数 N 增大时,初始成员请求为 N,叠加会话重取、setProjects 及本地持久化。`web/src/data/storage.ts:63` JSON.stringify + localStorage.setItem,`App.tsx:343` 随 projects 更新保存。持久化是否成为长任务还须 profile,不能预设为主要根因。 + +### F5. 认证有重复查询和同 session 写放大(源码已证实,耗时待测) + +`internal/server/auth_middleware.go:48,62` 已认证并保存 principal;`internal/server/auth_handlers.go:126-139` 的 me 再认证。每次经 `internal/auth/service.go:197-203`、`internal/auth/pg_store.go:335-367` 执行 SELECT session、SELECT status、UPDATE session、GetUserByID。正常 PG me 路径共 8 次认证 DB 操作,加一次权限查询。 + +其他鉴权请求也无条件 UPDATE 同一 auth_sessions 行;首屏并发可能放大行锁/连接等待。未采集 pg_stat_activity/锁等待或接口 p95,锁竞争是待验证风险。 + +### F6. 文档列表的隐含写修复与轮询叠加(源码已证实,耗时待测) + +`internal/docgov/project_docs.go:59-74` 列表先调用 `HealProjectDocuments`;`internal/docduty/store.go:173-205` 开事务并取得项目 advisory lock(`internal/docduty/revoke.go:111-126`),重复读取创建者/成员,扫描文档,按需逐条 UPDATE,commit 后才正式 SELECT。 + +前端 `App.tsx:1507-1545` 每 5 秒为租约同步调用这个完整列表。请求时长超过间隔时可能重叠;相同项目多个标签放大成本。**不能直接删除 heal**:须先补齐创建/成员变更/转交/撤销写路径与历史数据修复闭环。本轮没有用该 GET 做只读探针。 + +### F7. 会话列表先聚合消息再分页(源码已证实,耗时待测) + +`internal/ctxmgr/chat_history.go:110-125` 候选会话 JOIN 消息、COUNT、GROUP BY/ORDER BY 后 LIMIT,另取最新消息内容。仅返回少量会话不代表少量消息扫描。`internal/projectworkflow/store.go:171-240` 项目列表也无分页。 + +已有 `chat_messages(session_id, created_at)` 索引(`deploy/postgres/goose/00001_baseline.sql:119`);不笼统认定缺索引。先 EXPLAIN/数据量和可见性分析,再决定先分页后统计、索引或摘要。 + +### F8. 重启场景补查(用户补充后) + +用户明确 3100、8080、8081 在系统重启后首次进入约十数秒。三种冷态须分开:浏览器 HTTP 缓存、Vite 转换/依赖缓存、Go/DB/代理启动就绪。重启若不改变构建 hash,并不会自动清掉浏览器持久 HTTP 缓存;不能直接用新 context 测量解释全部重启现象。 + +现存容器启动记录:dev StartedAt `2026-09-17T07:00:38.127811581Z`,打印“监听地址”于 `07:00:38.293728694Z`(约 **166 ms**);test StartedAt `06:20:05.243037366Z`,打印监听于 `06:20:05.384607542Z`(约 **142 ms**)。这是已有 DB 就绪条件下的一次启动、且日志先于真正 ListenAndServe,**不是 host reboot→ready 时间**;但未支持“Go 每次初始化必然十几秒”。 + +`cmd/server/main.go:41-50` 的初始化在监听前,`internal/bizdev/service.go:69-114` 包括 DB Ping/迁移/成员补齐/seed;DB连接段 timeout 5 秒,后续 seed 使用 Background,缺少分阶段耗时。`internal/app/app.go:355` 的 Runtime.Start 幂等,入口重复调用不会重复启动 worker,不能据此误判双倍启动。 + +Compose app healthcheck 每 10 秒、start_period 20 秒;start_period 是失败宽限窗口,**不代表强制等待20秒**。若代理/运维脚本等健康状态才放流,则探测周期可能影响可见就绪,需要对齐宿主启动、DB healthy、app listen、ready、首次请求时间。3100 的无缓存 Vite 预构建/按请求转换为独立候选,8080/8081 构建产物不存在 Vite 首次编译。 + +隔离 Vite 在 3199 使用独立空 cacheDir,同源码、未重启宿主/DB:监听准备 43 ms,首访登录可见 **1,580 ms**、207 资源/14,445,631 B,再次 171 ms。服务先启动、浏览器随后连接,部分预构建可能已在导航前完成;该值不是“启动到首屏”总时间。支持开发冷态有额外成本,但本机未复现自然网络十数秒;不得仅靠 warmup 宣称已解决 8080/8081。证据见 `../test-reports/assets/2026-09-17-initial-load-performance/vite-cold.json`。 + +本轮不重启正在使用的共享服务;U1/U7 纳入计划中的隔离重启矩阵和阶段计时。没有把服务冷启动排除在用户需求之外。 + +## 已排除与未证明 + +- 本机静态 HTML TTFB 约 1–3 ms;这组未登录测试并非后端生成首页耗时数秒。 +- 没有请求产品 PI Runner,因此本次首屏慢不能归因于大模型冷启动。 +- 服务监听前迁移/种子初始化(`cmd/server/main.go:41-50,84-85`;`internal/bizdev/service.go:69-114`)只解释重启就绪,不等于每个新浏览器首访。 +- 没有用户重启时的 FRP/公网瀑布、登录后基线、慢 CPU profile、数据库 EXPLAIN;不对这些项宣称已复现。 +- 基线现有测试以功能为主;本轮未发现首屏资源预算/冷缓存性能门禁。未运行业务测试,因为没有业务代码变更。 + +## 关联整改与证据 + +- [改进方案](../remediation/2026-09-17-initial-load-performance-plan.md) +- [诊断测量报告](../test-reports/2026-09-17-initial-load-performance-baseline.md) +- [完整浏览器数据](../test-reports/assets/2026-09-17-initial-load-performance/browser.json) +- [受控实验](../test-reports/assets/2026-09-17-initial-load-performance/controlled.json) +- [静态响应摘要](../test-reports/assets/2026-09-17-initial-load-performance/assets.json) 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..7084ed83 --- /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 个人中控台 | 2026-09-19 已移出本仓库 | 进度管理改由独立项目承担;本仓库不再保留该模块 | +| 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 | 2026-09-19 已移出本仓库,不再作为本仓库关闭条件 | +| 首次加载性能 | 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、`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/artifact-vault`、`cmd/dataset`、`cmd/eventmode`、`cmd/recategorize`、`cmd/document-revision-backfill`(仅 bizdev-cli、document-content-reconcile 有);`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/2026-09-19-system-deep-audit.md b/.doc/audit/2026-09-19-system-deep-audit.md new file mode 100644 index 00000000..1d733758 --- /dev/null +++ b/.doc/audit/2026-09-19-system-deep-audit.md @@ -0,0 +1,130 @@ +--- +title: "系统深度体检审计(2026-09-19,dev@e8ed33b)" +status: done +created: 2026-09-19 +updated: 2026-09-19 +severity: high +--- + +# 系统深度体检审计(2026-09-19) + +> 只读审计,未修改业务代码。分支 `docs/system-deep-audit-2026-09-19`,基点 dev@e8ed33b。 +> 方法:go vet / go test -short 静态基线 + 五路并行深潜(后端 server/chat、docgov/events/工作流/迁移、前端 web、配置 flag/死代码、安全鉴权)。 + +## 一、总体结论 + +- **静态基线健康**:`go vet ./...` 零告警;`go test -short ./...` 55 包全 PASS;全仓 TODO/FIXME 仅 2 处非实质。 +- **四条已声明红线全部核实一致**:`MULTI_INTENT_PLAN_TIER=legacy`、`SEMANTIC_SUMMARY_REPLACE` 未接线、`FAV_DEGRADED_READ=false`、SWBOOT-10 fail-closed stub——代码与三环境 compose 均对齐,无漂移。 +- **IAM 主链路复核成立**:路由统一挂鉴权中间件、SQL 全参数化、token 哈希存储、错误出参有内部信息过滤。但存在 dev/test 兜底与端点级鉴权缺口(见 S1/S2)。 +- **本次新发现**:2 项 critical(后端数据竞态 + docgov 吞错)、10 项 high、若干 medium/low。其中 3 项被两路独立审查交叉命中(见 ★ 标记),可信度最高。 +- FEATURE-MATRIX 已声明的 partial/deferred(rdev、SWBOOT、FAV、D7、首载性能)不在本次重复申报范围;本次发现均为**新增**缺口。 + +## 二、Critical(发布前必须评估) + +### C1 ★ docgov 持久化全链路 fire-and-forget 吞错;fencing token 未读回 +`internal/docgov/lease.go:460-467, 485-490`;`internal/docgov/engine.go:318, 392, 405, 470, 477, 650, 743, 750, 1081-1134` + +租约获取/续约/回收、正文哈希、认领状态、提案决定及**全部审计日志**均为 `_, _ = e.pool.Exec(...)` 静默吞错: +- 审计 INSERT 失败 → 合规事件永久丢失,无重试无告警; +- 租约 UPDATE 失败 → 内存与 DB 分叉,重启后可并行编辑同一文档; +- `lease.go:485` 用 `context.Background()` 脱离请求生命周期; +- `loadDocumentFromDBLocked` 的 SELECT 列表**不含 `lease_fencing_token` 列**(00034 迁移已建列)→ 进程重启后 fencing 语义完全失效(合法持锁者被误判 stale,或 stale 写者被放行——两个方向都有缺口)。 + +**建议**:持久化失败必须返回 error 或 slog.Error+指标;审计写入失败进重试队列;lease SELECT 补 fencing 列;persistLeaseLocked 失败回滚内存状态。 + +### C2 ★ Session 全字段数据竞态 + 跨项目污染 +`internal/chat/session.go:275-277`;`internal/server/server.go:432-435`;`internal/server/handlers.go:196-198` + +- `Messages`/`UserCtx`/`LastAccessedAt` 的读写锁粒度碎片化:SSE 路径持 `execMu`,而 `hydrateVisibleHistory`、`handleGetProject`、`cleanStaleSessionsLocked`、`server.go:435` 均不持锁。并发 SSE+REST 同会话时 `go test -race` 可复现 data race。 +- `handleChat` 无锁写 `sess.ProjectID = req.ProjectID`,同一 sessionID 被不同项目请求交错时跨项目串扰。 + +**建议**:Session 加 `sync.RWMutex` 统一保护;ProjectID 改为请求级参数不写回共享 sess。 + +### C3 SSE WriteTimeout=120s 硬杀长任务 + 心跳 goroutine 生命周期不一致 +`internal/server/server.go:352-355`;`internal/server/sse.go:252-271` + +全局 `WriteTimeout: 120s`;SSE 靠 45s 续期+15s 心跳兜底,心跳被中间代理吞掉时长 LLM 回合(dialogueTimeout=3min)被强杀。非流式 `handleRunTask` 同步执行数十分钟任务,120s 必然截断响应(业务已执行、客户端收不到结果 → 幂等重试造成重复执行观感)。心跳 goroutine 模式与 `handleTaskRunEvents`(主循环 select,无此问题)不一致。 + +**建议**:长任务端点异步化(提交+轮询/SSE);心跳并入主 select 或 WaitGroup 收敛。 + +### C4 工作流计划保存跨语句竞态 + completed_by 错填 +`internal/projectinstance/service.go:185-190`;`internal/projectinstance/stage_store.go:132-138` + +- `SaveWorkflowPlan`:upsert 与 bump revision 两条独立事务,并发下同版本号先互相覆盖 plan 再一方 bump 失败 → **版本号与 plan 内容错位落库**。`Activate` 的 token 前置校验与激活事务间存在 TOCTOU。 +- `completed_by = (SELECT activated_by ...)`:最终阶段完成的归属人被错填为激活人,审计归属错误。 + +**建议**:upsert+bump 同事务 + `FOR UPDATE` 锁 project 行;completed_by 显式传当前操作者。 + +## 三、High(建议进入 short-term 整改) + +| # | 发现 | 位置 | 要点 | +|---|------|------|------| +| H1 ★ | dev/test 未认证静默兜底为 user id=1 | `internal/server/auth_middleware.go:48-51` | `Env=dev|test` + `AllowInMemory` + 无 ContextDB 时,无 token 请求静默识别为 id=1(管理员)。main 已被 `validateProductionSecrets` 兜住。建议:兜底路径加审计日志+启动告警;中期删除 legacy 兜底 | +| H2 ★ | docgov 批注/审计端点无资源级鉴权且信任请求体身份 | `internal/server/handlers.go:858-940, 534-552` | 任意登录用户读任意文档批注/审计;`AddComment` 直接用 `req.UserID/UserName/UserRole` 可伪造他人身份;`Claim` 角色由客户端自选。两路审查交叉命中。建议:actor 一律取 principal;读类端点加项目成员校验 | +| H3 ★ | 会话查询类端点水平越权 | `chat_turn_api.go:12-45`;`handlers.go:947-981, 429-451` | `handleGetChatTurn`/`handleListChatSessions`/`handleGetUserContext` 的 session_id/user_id 来自 query 不校验归属;userID<=0 时默认 1(种子 admin)。建议:改用 principal 或比对不一致 403 | +| H4 | 登录/注册无速率限制 | `server.go:295-296` | 全仓无限流;bcrypt 高成本反而便于 CPU 耗尽 DoS。建议:IP+用户名滑动窗口限流 | +| H5 | SSE 任务流缺幂等前置检查 | `internal/server/sse.go:398-424` | REST 路径有 `FindByIdempotencyKey`,SSE 路径没有;EventSource 断线重连产生重复任务执行 | +| H6 | 事件 dispatcher stale-fencing 无限重放 | `internal/events/worker.go:206-275` | ErrStaleFencing 直接 cancel+return 不计 Failed;handler 半成功被回滚后事件无限重放,永不可达 dead,无告警 | +| H7 | 迁移 00001–00009 缺 down;00012 seed 迁移破坏性 DELETE | `deploy/postgres/goose/` | 前 9 个迁移无 down;00012 无条件 DELETE 30 个 session 的真实消息再插种子;多个迁移事务内 CREATE INDEX(非 CONCURRENTLY)大表会锁表 | +| H8 | compose 弱口令兜底 + main 注册默认开放 | `docker-compose.{dev,test}.yml:17,86,101`;`main.yml:101` | dev/test 的 DB/ADMIN 口令有弱默认值;main 已用 `:?required` 强制但 `AUTH_REGISTRATION_ENABLED` 默认 true。建议 test 对齐 main 必填,main 注册默认 false | +| H9 | 前端 KaTeX 占位符 XSS | `web/src/engine/renderMarkdown.ts:44-64, 106-108` | 数学公式 catch 分支把未转义原文放入占位符数组,还原发生在 escapeHtml 之后 → XSS。建议:catch 分支 escapeHtml(math) + 占位符随机化 | +| H10 | `context.Background()` 脱离请求生命周期 | `internal/chat/session.go:131-134`;`server.go:423,458,566` | LLM 摘要/DB 调用用 Background,客户端断开无法取消,慢库时 handler 挂死。建议:透传请求 ctx + WithTimeout | + +## 四、Medium(选录,详见各专项) + +| # | 发现 | 位置 | +|---|------|------| +| M1 | 全局缺安全响应头(CSP/nosniff/X-Frame-Options/Referrer-Policy) | `internal/server/server.go:624-636` | +| M2 | 租约 TTL 无上限(可锁文档 1000 天)+ AcquireLease 无编辑权校验 | `handlers.go:1196-1203`;`docgov/lease.go:62-70` | +| M3 | coordinator 关键词命中强制改写意图 + 模板硬编码"张工您好" | `internal/coordinator/engine.go:409-416, 485` | +| M4 | `fmt.Sscanf` 解析 `u_sess_xxx` 必然失败 → UserID=0 审计落空 | `coordinator/engine.go:930-934, 1067-1071` | +| M5 | `Runtime.Start` 被 main 与 Server.Start 双重调用且错误被吞 | `server.go:343-345`;`cmd/server/main.go:47` | +| M6 | sessions map 无硬上限,惰性清理可被随机 session_id 放大 | `server.go:451-453` | +| M7 | 修订三阶段写(账本→DocGov→freeform)无事务,靠客户端重试补偿 | `document_revision_handlers.go:274-318` | +| M8 | 测试 flag 名笔误 `FAV_PUBLISHER_ENABLE`(少 D)门闩失效 | `internal/artifactvault/publisher_gate_test.go:16` | +| M9 | dev compose 声明永不消费的 `SEMANTIC_SUMMARY_REPLACE`(诱导性配置);dev 与 test/main 默认模型不一致(M2.7-highspeed vs M3) | `docker-compose.dev.yml:119,44` | +| M10 | OpsCutover 硬编码 `"dev"`,test/main 无合规 cutover 路径 | `internal/events/cutover.go:12-19` | +| M11 | 前端乐观已读无失败回滚;MessageStream prevMsgCount 跨会话不重置;SSE 无 online 感知重连 | `web/src/hooks/useUnreadMarks.ts:176-233`;`MessageStream.tsx:148-154` | +| M12 | ExcelViewer 无行数上限内存爆炸;React 19 下 30+ 处裸 setTimeout 无清理 | `ExcelViewer.tsx:42-55` | +| M13 | `deploy/postgres/migrations/` 疑似死目录(14 个旧 SQL 无引用,与 goose 重复) | `deploy/postgres/migrations/` | +| M14 | `Orchestrator.ResolveDocTask` 静默返回 0 值三权掩盖数据不一致 | `internal/workflow/runtime/orchestrator.go:69-83` | +| M15 | PG 事件投递允许乱序而 MemoryBus 有 gap 语义——测试替身与生产行为分叉 | `internal/events/dispatcher.go:56-63` vs `bus.go:99-103` | + +## 五、Low(选录) + +- `handleHealth` 每请求同步两次 DB 往返无缓存无超时(`handlers.go:97-110`) +- `writeStoredEvents` json.Marshal 错误被吞(`task_run_handlers.go:437`) +- `ReplayPlan.Digest()` 不含 DryRun 字段(纵深防御,`internal/events/replay.go:24-38`) +- 前端 localStorage 缓存无版本字段;`lucide-react@^1.16.0` 版本异常需确认来源;App.tsx 4600 行/30 个 useEffect 巨石组件 +- `handleRunTaskStream` 死参数 `includeDetails/requestID`(`sse.go:417-418`) + +## 六、复核确认无问题的面(证据性记录) + +- SQL 注入:全部 pgx 参数化;仅 4 处 fmt.Sprintf 涉 SQL 均为常量 +- CORS:白名单制、无 `*`+凭证;SSE 鉴权失败返回 JSON 而非污染流 +- 日志:密码 bcrypt、token SHA-256 摘要存储;`httpx.LooksInternal` 过滤内部错误串 +- os/exec:bridge 路径来自服务端配置,用户输入只进 stdin payload;无 text/template 引用 +- 事件系统:claim 幂等(SKIP LOCKED + fencing 复合判断)、ack+inbox+checkpoint 单事务、envelope 完整性校验、replay 审批人≠操作人——设计正确 +- 前端:Mermaid strict 模式、链接 noopener、ErrorBoundary 覆盖根组件、EventSource 卸载清理正确 + +## 七、优先级建议 + +1. **进 main 前必须处理**:C1–C4、H1–H5、H8、H9(H8 的 main 注册默认开放一项) +2. **short-term 整改批**:H6、H7、H10、M1、M2、M8(一行修复恢复门闩) +3. **backlog**:其余 medium/low + +## 关联整改 + +- Remediation: 待立项(建议按第七节优先级拆 short-term 任务) + +## 证据 + +- `go vet ./...` 零输出;`go test -short ./...` 55 包 ok(2026-09-19,dev@e8ed33b) +- 五路只读深潜:后端 server/chat、docgov/events/工作流/迁移、前端 web、配置/flag/死代码、安全鉴权 +- 任务条目:[ops/short-term/2026-09-19-system-deep-audit.md](../ops/short-term/2026-09-19-system-deep-audit.md) + +## 开放问题 + +- dev 与 test/main 默认模型不一致(M2.7-highspeed vs M3)是否刻意?未见 `.doc/ops` 对齐记录 +- `deploy/postgres/migrations/` 是否还有外部工具依赖?无则建议归档 +- H1 legacy 兜底是否有仍在使用的内部工具依赖?删除前需盘点 diff --git a/.doc/audit/INDEX.md b/.doc/audit/INDEX.md index f345ffa2..e8200a2d 100644 --- a/.doc/audit/INDEX.md +++ b/.doc/audit/INDEX.md @@ -472,3 +472,6 @@ Day 4-5: Phase C 完善与发布准备(20h) --- *本索引最后更新于 2026-09-12 15:05 UTC* + +- 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..7a39a928 --- /dev/null +++ b/.doc/backlog/2026-09-17-rdev-phase2-backlog.md @@ -0,0 +1,75 @@ +# 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 模式) + +- **状态**:✅ **done**(2026-09-19)— `rd_user_needs` / `rd_system_reqs` 在 `QualityGate.Validate` 中检查连续编号、禁止随机抽取、验证准则与正文/JSON 一致。`pm` 的 `system_reqs` 仍走原有检查。 +- 范围:`rd_user_needs`、`rd_system_reqs`(及 `pm_system_reqs`)输出结构化需求集合;确定性门禁:ID 唯一连续、不随机抽取、缺验证准则拒绝、正文/JSON 不一致拒绝; +- 参考先例:`quality.system_reqs.v2`。 + +### R3 review_minutes / upload_index 专用确定性门禁 + +- **状态**:✅ **done**(2026-09-19)— 评审记录拒绝空洞「同意通过」;附件清单要求索引表与影响分析。`TestRDevQualityGates`。 + +- 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 — 运行时编排与配置化 + +- **状态**:✅ **done**(2026-09-19,`fix/audit-remainder-closeout`) +- per-stage 步骤覆盖(相对默认 organize→engineer→draft→review 的阶段差异) +- regulatory 步接 `complexity_tier`(与 R7 可合并认领) +- 模型去硬编码(配置/环境驱动) +- manifest hint + aliases 元数据补齐 +- **证据**:`TestRDevRegulatoryFollowsComplexityTier`、`TestRDevModelFromEnv`、`TestRDevStageOverrideAndAliases` + +### S3 — overlay 与质量门禁扩面 + +- **状态**:部分(2026-09-19)。overlay 与 R2/R3 已落地;P3 真 PI 未做 +- 轻量 overlay 机制(模板管结构;overlay/运行时管纪律与粒度下限)— done,`configs/overlays/`,`TestRDevWorkspaceWritesOverlay` +- R2/R3 专用确定性门禁落地 — done,`TestRDevQualityGates` +- 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 ccd52048..e539820e 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,18 +1,47 @@ # 当前焦点 -**更新**: 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-19 +**production_ready**: yes(门禁 B1–B4 已签;dev schema 53,test/main schema 52。不把 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. **2026-09-19 审计整改批 1–4 done**(合入 `844f757` / `08f4e0b` / `a2a49a6` / `d73bc79`)。dev 已 rebuild 到 schema 53。余项见 [long-term](./long-term/2026-09-19-audit-remainder.md)。未晋升 test/main。 +2. **Control Center 已移出本仓库**:项目进度由独立统一管理系统承担;本仓库恢复为智能体系统。见 [任务](./short-term/2026-09-19-remove-control-center.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 | |-------|------|--------|----------|-------|--------| +| Cursor | audit-b1-data-consistency | fix/audit-b1-data-consistency | - | docgov+workflow+chat | done | +| Cursor | audit-b2-authz-hardening | fix/audit-b2-authz-hardening | - | authz | done | +| Cursor | audit-b3-sse-events | fix/audit-b3-sse-events | - | chat+events | done | +| Cursor | audit-b4-frontend | fix/audit-b4-frontend | - | web | done | +| Cursor-Auto | 2026-09-19-audit-remainder-closeout | fix/audit-remainder-closeout | - | audit-remainder | done | +| Antigravity | 2026-09-20-dev-deep-test-and-promotion | feature/dev-deep-test-and-promotion | - | integration | in_progress | +| Cursor-Auto | 2026-09-19-remove-control-center | fix/remove-control-center | - | controlcenter | done | +| Cursor-Auto | 2026-09-17-control-center-server | feature/control-center-server | - | controlcenter | cancelled | +| 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 | +| Cursor-Auto | 2026-09-17-engineering-dashboard | feature/engineering-dashboard | .worktrees/feature-engineering-dashboard | engineering-dashboard | done | | Cursor | 2026-09-17-promote-null-fk-to-test | feature/promote-null-fk-to-test | - | infra | done | | Cursor | 2026-09-17-docgov-optional-duty-null-fk | fix/docgov-optional-duty-null-fk | - | docgov | done | | Cursor | 2026-09-17-leftover-hygiene | chore/leftover-hygiene-2026-09-17 | - | ops | done | @@ -121,7 +150,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | - | +| Antigravity | merging | 2026-09-20 | 合并 fix/audit-remainder-closeout 至 dev,全量深度测试,晋升至 test 并重启 test 系统 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` @@ -131,9 +160,9 @@ | 环境 | 端口 | schema | 状态 | |------|------|--------|------| -| dev | 8080 | **48** | healthy(`pi-agent` typed-result-v1;events worker `:8088`;notify/advance/projection live、fav off;health+ready PASS;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 | **53** | healthy(2026-09-19 `fix/audit-remainder-closeout` rebuild;goose `00053` `document_content_projections`;入口 `index-DOO0-Yh7.js`;`pi-agent`;events `:8088`) | +| 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 @@ -144,7 +173,9 @@ | 发布缺口 | [gap-matrix](../remediation/gap-matrix.md)(`production_ready=yes`;Wave 0 IAM 只读未发现旁路) | | 连续性审计/计划 | [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/) | +| dev→test 晋升前复核 | [research](./research/2026-09-19-pre-test-promotion-review.md)(2026-09-19 只读;静态门禁过,容器未 rebuild,暂不合并) | | 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/long-term/2026-09-19-audit-remainder.md b/.doc/ops/long-term/2026-09-19-audit-remainder.md new file mode 100644 index 00000000..805f456b --- /dev/null +++ b/.doc/ops/long-term/2026-09-19-audit-remainder.md @@ -0,0 +1,85 @@ +--- +title: "2026-09-19 审计剩余项(medium/low,不挡已合入批 1–4)" +status: planned +created: 2026-09-19 +updated: 2026-09-19 +--- + +# 2026-09-19 审计剩余项 + +## 动机 + +[系统深度体检](../../audit/2026-09-19-system-deep-audit.md) 的 C1–C4 与 H1–H10 已按四批合入 `dev`。下列 medium/low 仍真实存在,但不进入本轮发布回退。 + +## 成功标准 + +- 每条有独立 short-term、负例测试或明确关闭理由后再标 done +- 不把本清单自动写成 gap-matrix blocker + +## 依赖 + +- 批 1–4 已在 `dev`:`844f757`、`08f4e0b`、`a2a49a6`、`d73bc79` + +## 暂不启动原因 + +本轮范围只到 critical + high。下列项要么是加固、要么需要产品/合规决策,要么改动面大于收益。 + +## 进展(2026-09-19,`fix/audit-remainder-closeout`) + +| ID | 结果 | +|----|------| +| M1 | done。`nosniff` / `DENY` / `Referrer-Policy` / 本站 CSP。`TestP0_SecurityHeaders` | +| M2 | done。TTL 钳到人工 15 分钟、小队 30 分钟;人工租约前 `CheckHumanCanEdit`。`TestAcquireLeaseClampsHumanTTL` | +| M3 | done(本项可落地部分)。低置信度才做关键词改道;称呼取用户名。高置信度意图层改造不在本项把模板整段搬走 | +| M4 | done。`parseDecimalID` 拒绝 `u_sess_*`。`TestParseDecimalIDRejectsSessionTokens` | +| M5 | done。`Server.Start` 不再二次启动并吞错。`main` 仍调用 `Runtime.Start`,该方法本身已幂等 | +| M6 | done(上限)。硬上限 4096,淘汰最久未访问。会话 id 仍由客户端提供,因为历史恢复按该 id 查找;改成只接受服务端签发会打断现有对话协议 | +| M9 | done。dev compose 不再声明 `SEMANTIC_SUMMARY_REPLACE`。dev=`MiniMax-M2.7-highspeed`、test/main=`MiniMax-M3` 确认为有意分叉,已写在 compose 注释 | +| M10 | done(写明路径,不放开门闩)。`OpsCutoverPromotionPath`:dev 观察后用晋升回执写 test,再写 main。`ValidateOpsCutover` 仍拒绝 test/main | +| M13 | done。SQL 迁到 `deploy/postgres/archive/pre-goose/`。代码与脚本无引用 | +| M14 | done。冻结计划找不到职责行则报错;显式空三权仍为 0。`TestResolveDocTask_NoneDutiesStayZero` | +| L1 | done。健康检查 2 秒缓存 + 2 秒 DB 超时 | +| L2 | done。SSE marshal 失败写 `event: error` 帧 | +| L3 | done。`ReplayPlan.Digest` 含 `DryRun`;dry-run 摘要不能授权 apply | +| M15 | done。生产 `claimSQL` 与 `MemoryBus` 共用 `VersionClaimable`:版本 0 可领,版本化事件不能跳过 checkpoint。checkpoint 只前进。`TestVersionClaimableMatchesMemoryBusGap`、`TestGapDoesNotSkipVersion` | +| H3 负例 | done。`TestListChatSessions_CrossUserIs403` | +| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` | +| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 | +| M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 | +| FE App.tsx | 部分收口。登录壳不再静态引用重模块。本地缓存、登录同步、文档补水、服务端文档同步、生命周期、发送消息、块编辑、对话流/任务流重试、卡片动作、立项和成员写操作已分别抽出。文档栏在 `DocumentPane`,聊天栏在 `ChatColumn`,`AppShell` 与工作台弹窗在 `WorkbenchView`。`App.tsx` 仍持有会话状态,不再挂 `.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/research/2026-09-19-pre-test-promotion-review.md b/.doc/ops/research/2026-09-19-pre-test-promotion-review.md new file mode 100644 index 00000000..dc197ae5 --- /dev/null +++ b/.doc/ops/research/2026-09-19-pre-test-promotion-review.md @@ -0,0 +1,151 @@ +--- +title: "dev→test 晋升前复核:功能权限、合并可行性、已知缺陷" +created: 2026-09-19 +updated: 2026-09-19 +status: done +confidence: high +--- + +# 调研:dev→test 晋升前复核 + +只读复核。不改业务代码、不 rebuild 容器、不合入 `test`/`main`、不修复 [medium/low backlog](../long-term/2026-09-19-audit-remainder.md)。 + +## 结论 + +1. **规划内的功能、权限与流程已按声明范围接入。** [FEATURE-MATRIX](../../status/FEATURE-MATRIX.md) 主表里,除明确标成 `partial` / `deferred` 的行以外,其余能力均为 `done`,并有测试报告或 commit 证据。没有发现「规划了但完全未实现」的模块。未完成项是主动延后,且 [gap-matrix](../../remediation/gap-matrix.md) 已判定不挡首发。 +2. **`dev` 的静态质量达标,但现在不建议直接合并入 `test`。** `go vet` 与 `go test -short` 全绿,2026-09-19 深度审计的 critical/high 已合入 `dev`。合并前仍缺三件事:dev 容器 rebuild 冒烟、治理 PG 集成与前端构建、批 2 越权负例的自动化覆盖。 +3. **critical/high 已关闭;medium/low 仍在,且本轮不修。** 新发现的观察项是文档漂移和 `test` 分支上 9 个晋升提交,不是新的代码缺陷。 + +## 一、功能、权限、流程 + +对照 [FEATURE-MATRIX](../../status/FEATURE-MATRIX.md)(更新 2026-09-19)与 [gap-matrix](../../remediation/gap-matrix.md)(`production_ready=yes`,门禁 B1–B4 已签)。 + +### 已关闭(声明范围内 `done`) + +| 能力 | 接入情况 | +|------|----------| +| 意图识别 L1、多意图 U1–U8、多意图扩面 U9–U12 | 代码与报告齐全;`MULTI_INTENT_PLAN_TIER` 仍为 `legacy`,未默认升 `confirm`/`full` | +| 对话 / ctxmgr、语义摘要 D3、PI change-report 叙事 | 刷新恢复门禁已签;摘要与叙事 flag 默认 off,REPLACE 未启用 | +| 工作流治理 | 三正式工作流、动作级鉴权、真实 PI E2E | +| 文档治理、文档版本 baseline | 法定生命周期、租约、受控正文、运行时 UI | +| IAM / 鉴权 | 项目级 API + RBAC;路由清单见 [authorization-matrix-v3](../../audit/authorization-matrix-v3-full.md) | +| 前端工作台、PI Runner、三环境运维、CI/E2E、Artifact 同 digest、可靠事件核心、系统管理台 | 各自行有报告;系统管理台二期(Playwright、细粒度 `system:*`、设置审计)不在本行关闭条件内 | +| 文档编制人兼任(U1–U6) | 已合入 `dev`;总开关默认 off | + +2026-09-19 审计批 2 已把鉴权缺口接到主链:H1 给 dev/test legacy 兜底加了告警(未删除兜底)、H2 批注/认领 actor 取 principal 且读类端点校验项目成员、H3 会话查询跨用户 403、H4 登录限流、H8 compose 口令改为必填。证据:[批 2 报告](../../test-reports/2026-09-19-audit-b2-authz-hardening-test-report.md)。 + +### 明确未完成(不是遗漏) + +| 行 | 状态 | 剩余 | 是否挡首发 | +|----|------|------|------------| +| 研发设计控制(rdev) | partial | S2/S3、P2 前端、P3 三样本 E2E、P4 键名空间化 | 否 | +| 纯软件项目自举 | partial | SWBOOT-10 仍 fail-closed stub | 否 | +| Final Artifact Vault | partial | live 未放量;禁止整支 merge;`FAV_DEGRADED_READ=false` | 否 | +| 对话数据治理(D7) | deferred | `pending-compliance-signoff`;禁止按口径物理删除 | 否(违规则才升 blocker) | +| 首次加载性能 | partial(矩阵外专项) | U2 已验收;U1/U3–U7 未做 | 否 | + +红线与代码/compose 对齐(深度审计已核实,本轮未再改 flag):`MULTI_INTENT_PLAN_TIER=legacy`、`SEMANTIC_SUMMARY_REPLACE` 未接线、`FAV_DEGRADED_READ=false`、SWBOOT-10 fail-closed stub、项目 143 只读。 + +## 二、`dev` 能否合入 `test` + +**判断:静态门禁通过,运行态与回归门禁未补齐,暂不合并。** + +### 已满足 + +- `dev` 工作树在复核时干净;`dev` HEAD 为 `66421b1`(含审计批 1–4:`844f757`、`08f4e0b`、`a2a49a6`、`d73bc79`)。 +- `go vet ./...` 零告警。 +- `go test -short ./...` 全包 PASS(`internal/server` 本轮实测约 14s,非缓存)。 +- 三环境 `GET /api/v1/health` 均为 `status=ok`、`schema_version=52`、`schema_ready=true`。goose 最新文件为 `00052`。迁移版本本身不阻塞晋升。 +- `internal/`、`cmd/` 生产代码中无 `TODO`/`FIXME`/`XXX`。 + +### 合并前必须处理 + +1. **dev 容器仍是旧二进制。** 批 1–3 报告写明未执行 `./scripts/env-service.sh dev rebuild`。`:8080` 健康只证明旧镜像在跑,不能证明审计修复已部署。按 [AGENTS.md](../../../AGENTS.md) §6.3,合入共享环境前要占集成槽位并冒烟。 +2. **`dev` 领先 `test` 225 个 commit。** 差异覆盖 rdev 模板、系统管理台、docgov 加固、Control Center 移除等。`-short` 不够。晋升前至少再跑:`TEST_DATABASE_URL` 下治理 PG 集成,以及 `cd web && npm test && npm run build`。 +3. **批 2 的越权负例仍是人工兜底。** [批 2 报告](../../test-reports/2026-09-19-audit-b2-authz-hardening-test-report.md) 写明:非项目成员访问 docgov 应 403 的 HTTP/Playwright 负例未补,目前靠既有鉴权测试不回退加 code review。 + +### `test` 上多出来的 9 个提交 + +`git rev-list --count dev..test` = 9。这些提交是历次 `release-candidate` / 晋升文档同步(含 `rc-v1.0.0-beta.2` 至 `beta.4`),不是 `dev` 上缺失的产品代码。下次晋升应把 `dev` **merge 进** `test`,不要把 `test` reset 到 `dev`,否则这 9 个提交的晋升记录会丢。 + +`CURRENT.md` 文首仍写 test `schema_version=48`,同文「环境快照」表与本次 health 实测已是 52。这是文档漂移,不是运行时缺陷。更正留给下次晋升文档,本笔记不改那一行。 + +## 三、缺陷 + +### 已修复(2026-09-19 深度审计 critical/high) + +来源:[系统深度体检](../../audit/2026-09-19-system-deep-audit.md)。四批均已合入 `dev`。 + +| 批 | 项 | merge | +|----|----|-------| +| 1 数据一致性 | C1 docgov 吞错与 fencing 读回;C2 Session 竞态与跨项目污染;C4 计划保存事务与 `completed_by`;H10 请求 ctx | `844f757` | +| 2 鉴权 | H1–H4、H8、M8 | `08f4e0b` | +| 3 SSE/事件 | C3 心跳与写超时;H5 任务 SSE 幂等;H6 stale fencing 日志;H7 迁移注释(历史 SQL 不能补 down) | `a2a49a6` | +| 4 前端 | H9 占位符 XSS;未读回滚;Excel 预览上限 | `d73bc79` | + +H1 的 legacy user-id=1 兜底只加了日志,删除仍在 backlog(`H1-del`)。H7 的 00001–00009 无 down 不能改已应用文件,只加了注释。 + +### 已知未修(维持 backlog,本轮不启动) + +清单 SSOT:[2026-09-19-audit-remainder.md](../long-term/2026-09-19-audit-remainder.md)。均为 medium/low,不回写 gap-matrix。 + +| ID | 项 | +|----|----| +| M1 | 缺 `nosniff` / `X-Frame-Options` / `Referrer-Policy`,CSP 未收敛 | +| M2 | 租约 TTL 无上限;Acquire 前未校验编辑权 | +| M3 | 关键词改写意图;硬编码「张工」 | +| M4 | `u_sess_*` 解析失败得到 UserID=0 | +| M5 | `Runtime.Start` 双调用且吞错 | +| M6 | sessions 无硬上限 | +| M7 | 修订三阶段写无事务 | +| M9 | `SEMANTIC_SUMMARY_REPLACE` 只在 dev compose 声明;dev/test 默认模型不一致 | +| M10 | cutover 只允许 `dev` | +| M13 | `deploy/postgres/migrations/` 与 goose 重复 | +| M14 | ResolveDocTask 静默 0 值三权 | +| M15 | PG 投递允许乱序,MemoryBus 有 gap | +| L1 | health 每次两次 DB | +| L2 | SSE marshal 错误被吞 | +| L3 | ReplayPlan digest 不含 DryRun | +| H1-del | 删除 legacy user-id=1 兜底前须盘点调用方 | +| FE | `App.tsx` 巨石、localStorage 无版本、lucide 来源待确认 | + +M8(测试 flag 名 `FAV_PUBLISHER_ENABLE` 少一个 D)已在批 2 修复,不在上表。M11/M12 的未读回滚与 Excel 行数上限已在批 4 处理;批 4 报告写明浏览器端到端未覆盖这两条,单测已钉住 XSS 主路径。 + +### 本轮新观察(不单独立项) + +- 文首 schema 数字与实测不一致,见第二节。 +- `test` 独有 9 个晋升提交,合并时用 merge,不要 reset。 + +## 证据 + +复核时间:2026-09-19。工作区当时在 `docs/audit-remediation-backlog`(树与 `dev@66421b1` 一致),随后本笔记落在 `docs/pre-test-promotion-review`。 + +| 项 | 路径/命令 | 结果 | +|----|-----------|------| +| 工作树 | `git status` | clean | +| dev 领先 test | `git rev-list --count test..dev` | 225 | +| test 领先 dev | `git rev-list --count dev..test` | 9(晋升/文档提交) | +| 静态检查 | `go vet ./...` | 零告警,exit 0 | +| 短测 | `go test -short ./...` | 全包 PASS;`internal/server` 14.044s | +| dev health | `curl -s http://127.0.0.1:8080/api/v1/health` | `env=dev` `schema_version=52` `status=ok` | +| test health | `curl -s http://127.0.0.1:8081/api/v1/health` | `env=test` `schema_version=52` `status=ok` | +| main health | `curl -s http://127.0.0.1:8000/api/v1/health` | `env=main` `schema_version=52` `status=ok` | +| 迁移文件 | `deploy/postgres/goose/` | 52 个 SQL,最新 `00052_2026-09-17-document-duty-locked.sql` | +| worktree | `git worktree list` | 主仓 + `dev-rdev-merge` + `feature-initial-load-performance-plan` + `fix-dev-runtime-data-root-guard`(检出 `feature/final-artifact-vault`) | +| 待办标记 | `grep TODO\|FIXME\|XXX` 于 `internal/` `cmd/`(排除 `_test.go`) | 0 | +| 容器 | `docker ps` | dev/test/main app+events+pi-agent 均为 healthy;dev app 已运行约 5 小时,早于批 1–4 合入后的 rebuild(未做) | + +## 开放问题 + +- dev 与 test/main 默认模型不一致(M2.7-highspeed vs M3)是否刻意?深度审计已记,本轮无新证据。 +- `deploy/postgres/migrations/` 是否仍有外部工具依赖?无则再归档(M13)。 +- H2/H3 越权负例补到哪一层才算晋升门禁:HTTP 集成即可,还是必须 Playwright? + +## 建议动作 + +按顺序,全部另开任务,不在本笔记内执行: + +1. 占集成槽位,`./scripts/env-service.sh dev rebuild`,`curl -sf http://127.0.0.1:8080/health`(或 `/api/v1/health`)确认 schema 52 且二进制含批 1–4。 +2. `TEST_DATABASE_URL` 下跑治理 PG 集成;`cd web && npm test && npm run build`。 +3. 为 H2/H3 补至少一条跨用户/非成员 403 负例。 +4. 通过后再把 `dev` merge 入 `test`(保留 test 上 9 个晋升提交),打 rc tag,并顺手更正 `CURRENT.md` 文首的 test schema 表述。 diff --git a/.doc/ops/short-term/2026-09-17-initial-load-perf-u2.md b/.doc/ops/short-term/2026-09-17-initial-load-perf-u2.md new file mode 100644 index 00000000..66c8188f --- /dev/null +++ b/.doc/ops/short-term/2026-09-17-initial-load-perf-u2.md @@ -0,0 +1,53 @@ +--- +title: "首次加载 U2:字体自托管与静态资源交付" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Cursor +branch: feature/initial-load-perf-u2 +worktree: "-" +mutex: web + static-delivery +--- + +# 首次加载 U2:字体自托管与静态资源交付 + +## 目标 + +登录页不再依赖 Google Fonts 阻塞 CSS;8080/8081 静态资源支持 gzip/br 协商与分层缓存;缺失 `/assets`/`/fonts` 返回 404 而非 HTML。 + +## 范围 + +- 自托管 Inter / JetBrains Mono(latin + latin-ext 可变 WOFF2) +- 构建期预压缩 `.br` / `.gz` +- Go `serveSPA` 压缩协商、Cache-Control、Vary、assets 404 +- 单元测试与 rebuild 后 curl/浏览器验收 + +## 非目标 + +- U1 完整 Playwright 性能套件 +- U3 拆包懒加载 / katex 入口 CSS +- U4–U6 初始化扇出、认证、SQL +- U7 完整重启归因(本轮仅借 rebuild 采集起点证据) + +## 步骤 + +1. [x] 诊断/方案文档落盘并合入 `dev` +2. [x] 自托管字体 + 移除外链 +3. [x] 预压缩脚本接入 build +4. [x] Go 静态交付修复 +5. [x] 测试与 rebuild 验收 +6. [x] 测试报告与文档同步 + +## 验收标准 + +- [x] 登录页无 `fonts.googleapis.com` / `fonts.gstatic.com` 请求 +- [x] 主 JS 响应带 `Content-Encoding: br` 或 `gzip`,体积显著下降 +- [x] `/assets/missing.js` → 404 且非 HTML +- [x] HTML `Cache-Control: no-cache`;哈希资源 `immutable` +- [x] SSE / API 缓存行为未回归 + +## 相关链接 + +- 方案: [../../remediation/2026-09-17-initial-load-performance-plan.md](../../remediation/2026-09-17-initial-load-performance-plan.md) +- 审计: [../../audit/2026-09-17-initial-load-performance-audit.md](../../audit/2026-09-17-initial-load-performance-audit.md) +- Test report: [../../test-reports/2026-09-17-initial-load-perf-u2-test-report.md](../../test-reports/2026-09-17-initial-load-perf-u2-test-report.md) diff --git a/.doc/ops/short-term/2026-09-17-system-admin-console.md b/.doc/ops/short-term/2026-09-17-system-admin-console.md new file mode 100644 index 00000000..9a561f62 --- /dev/null +++ b/.doc/ops/short-term/2026-09-17-system-admin-console.md @@ -0,0 +1,58 @@ +--- +title: "系统管理台(System Admin Console)一期" +status: done +created: 2026-09-17 +updated: 2026-09-17 +owner: Kimi +branch: feature/system-admin-console +worktree: "-" +mutex: web + sysadmin +--- + +# 系统管理台(System Admin Console)一期 + +## 目标 + +为管理员提供独立的系统级管理界面(全屏视图,`workbench | admin` 顶层切换),覆盖总览状态、知识库、功能与特性开关、角色权限、工作流、用户审批、文档治理;吸收下线 AdminSettingsModal;系统设置只读为主 + SettingSpec 白名单少量可写。 + +## 范围 + +- 后端:新包 `internal/sysadmin`(薄聚合层,ports/settings/overview);迁移 `deploy/postgres/goose/00049`(`system_settings` 表);路由 `/api/v1/admin/system/{overview,kb,features,settings}`;`internal/server/admin_system_handlers.go`;`internal/app/app.go` 装配。 +- 前端:`web/src/api/systemAdmin.ts` + `web/src/types/systemAdmin.ts`;`web/src/components/admin/console/AdminConsoleView.tsx` + `sections/`;`App.tsx` 顶层视图切换;LeftRail 入口语义改为进入管理台;吸收 `AdminSettingsModal` 六 tab 并删除旧模态。 + +## 非目标 + +- 不做全量 DB feature-flag 层(env 开关保留,标注「需重启」)。 +- 不改项目级设置(`projectinstance.ProjectSettings` 与项目设置模态保持原样)。 +- 不引入 react-router / 新前端依赖;不改 `tokens.css` 之外的全局样式。 +- 不做 KB 重建/索引管理等运维动作(一期只读)。 + +## 步骤 + +1. [x] 后端 sysadmin 包 + goose 00049(commit `f1404d5`) +2. [x] 后端路由/handler/装配 + 单测(commit `f1404d5`) +3. [x] 前端 API 模块与类型(commit `0eaca61`) +4. [x] AdminConsoleView 骨架与 8 个分组 sections(commit `0eaca61`) +5. [x] 吸收 AdminSettingsModal 并下线(commit `0eaca61`) +6. [x] go vet / go test -short / web npm test + build(全绿) +7. [x] 占集成槽位:dev rebuild + /health + admin overview 冒烟(merge commit `dc7c31e`;schema 48→49;PASS) + +## 验收标准 + +- [x] admin 从 LeftRail 入口进入全屏管理台,八个分组可渲染(UI 人工抽检由 Playwright spec 兜底,见报告后续项 1) +- [x] 非管理员无入口且 `/api/v1/admin/system/*` 返回 403(server 单测真实 mux + 内存 IAM) +- [x] 白名单可写设置 PUT 后 GET 生效;env 项只读并标注需重启(写读回环 PASS) +- [x] `go vet ./...`、`go test -short ./...`、`cd web && npm test && npm run build` 全绿 +- [x] dev 容器冒烟 PASS + +## 相关链接 + +- 方案:会话计划(全屏独立视图 / 吸收旧模态 / 只读+少量可写,用户已批准) +- Test report: [2026-09-17-system-admin-console-test-report.md](../../test-reports/2026-09-17-system-admin-console-test-report.md) +- FEATURE-MATRIX 行:系统管理台 + +## 证据 / 备注 + +- commit:`f1404d5`(后端)、`0eaca61`(前端)、`dc7c31e`(merge dev) +- 冒烟:health `schema_version=49`;overview/kb/features(22)/settings(6) 200;PUT 写读回环 PASS;匿名 401、未知 key 400、非 admin 403(单测) +- 遗留(二期):Playwright 实跑、`system:*` 细粒度权限、设置审计日志、KB 运维动作、白名单项接消费方、403 自动退回 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..66947c5e --- /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(用户未要求) +- 不改动任何产品代码 +- 不覆盖产品手册范围外的工程内部工具(`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..d34e6a26 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave7-roadmap-orchestration.md @@ -0,0 +1,101 @@ +--- +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 / 定期审计)。触发后再开对应短期任务。本波只记录事实与建议,**不合并、不删除**该分支。 +- **后续裁决(2026-09-19)**:用户决定进度管理由独立项目承担。本仓库已删除 Control Center 代码与文档,不恢复 `engineering-dashboard`。见 [移除任务](./2026-09-19-remove-control-center.md)。 + +### 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 双实现冲突**:当时未合入。2026-09-19 用户裁决为移出本仓库,代码与专属文档已删除,不恢复旧工程中控台。见 [移除任务](./2026-09-19-remove-control-center.md)。 + +## 证据 / 备注 + +基线 tip:`a88ed85`(`merge: docs/wave6-doc-drift`)。mutex=`roadmap-orchestration`。零 Go/TS/SQL/compose 改动。FEATURE-MATRIX 状态列未改。 diff --git a/.doc/ops/short-term/2026-09-19-remove-control-center.md b/.doc/ops/short-term/2026-09-19-remove-control-center.md new file mode 100644 index 00000000..32b80dec --- /dev/null +++ b/.doc/ops/short-term/2026-09-19-remove-control-center.md @@ -0,0 +1,56 @@ +--- +title: "移除 Control Center,本仓库回到智能体系统" +status: done +created: 2026-09-19 +updated: 2026-09-19 +owner: Cursor-Auto +branch: fix/remove-control-center +worktree: "-" +mutex: controlcenter +--- + +# 移除 Control Center + +## 目标 + +项目进度管理已由独立项目承担。从本仓库删除 Control Center 的代码、部署、规范和专属文档,不恢复旧 `engineering-dashboard`。 + +## 范围 + +- 删除 `cmd/control-center`、`internal/controlcenter`、`control-center/web`、`deploy/control-center`、`scripts/control-center.sh` +- 删除专属设计、任务、测试报告与截图 +- 更新 AGENTS、FEATURE-MATRIX、CURRENT、模块边界与断链引用 +- 停掉 `control-center-db` 与 `127.0.0.1:8790` + +## 非目标 + +- 不恢复 `engineering-dashboard` +- 不 push +- 不改产品 `internal/server`、产品前端或三环境 compose + +## 步骤 + +1. [x] 从本地 dev 开 `fix/remove-control-center` +2. [x] 删除代码与文档并更新引用 +3. [x] 停运行时 +4. [x] `go vet` / `go test -short` / 产品前端 build / archtest +5. [x] 合入本地 dev 并做健康检查 +6. [x] 删除未跟踪的 `control-center/web/node_modules` 与本地分支 `feature/control-center-server` + +## 验收标准 + +- [x] 仓库内不再有 Control Center 源码或专属文档 +- [x] `go vet ./...`、`go test -short ./...`、`go test ./internal/archtest/`、`web` 的 `npm run build` 通过 +- [x] 本地 dev 健康检查 200 + +## 相关链接 + +- Checklist: +- Handoff: +- Test report: [../../test-reports/2026-09-19-remove-control-center-test-report.md](../../test-reports/2026-09-19-remove-control-center-test-report.md) + +## 证据 / 备注 + +用户确认:只移除 Control Center,副本已在别处实现,合入本地 dev。 + +2026-09-19 复查:产品代码、模块边界、AGENTS 规范、容器与端口均无残留。另删除未跟踪目录 `control-center/`(仅 `node_modules`)和本地分支 `feature/control-center-server`(`55fa8dd`)。未删除 `origin/feature/control-center-server`。`go build ./...` 通过。 diff --git a/.doc/ops/short-term/2026-09-19-system-deep-audit.md b/.doc/ops/short-term/2026-09-19-system-deep-audit.md new file mode 100644 index 00000000..a020db76 --- /dev/null +++ b/.doc/ops/short-term/2026-09-19-system-deep-audit.md @@ -0,0 +1,50 @@ +--- +title: "系统深度体检审计(独立审查潜在缺陷与未完善点)" +status: done +created: 2026-09-19 +updated: 2026-09-19 +owner: Cursor +branch: docs/audit-remediation-backlog +worktree: "-" +mutex: audit +--- + +# 系统深度体检审计(2026-09-19) + +## 目标 + +在不改业务代码的前提下,对当前 dev(e8ed33b)做一次独立深度审查,输出 `.doc/audit/` 审计笔记,列出潜在 bug 与未完善项并给出严重度分级。 + +## 范围 + +- Go 静态验证:`go vet ./...` + `go test -short ./...` +- 后端:server 路由/chat SSE/docgov/events 错误处理与并发风险 +- 前端 web:错误处理、SSE 重连、竞态 +- 配置:flag 默认值一致性、死代码、环境差异(dev/test schema 漂移) +- 安全:IAM 旁路、密钥泄露面 +- DB:goose 迁移一致性与回滚风险 + +## 非目标 + +- 不修改业务代码(docs-only 分支) +- 不验证已明确 deferred 的事项本身(FAV live 放量、D7 签字、SWBOOT-10)——仅核对声明与代码是否一致 + +## 步骤 + +1. [x] 创建 docs 分支、登记 CURRENT +2. [x] go vet + go test -short +3. [x] 并行子代理深潜后端/前端/配置/DB/安全 +4. [x] 汇总撰写 audit 笔记并提交 + +## 验收标准 + +- [x] 每个发现均有代码路径 + 行号证据 +- [x] 严重度分级(critical/high/medium/low) +- [x] 与 FEATURE-MATRIX 已声明的 partial/deferred 项对齐,不重复申报 + +## 证据 / 备注 + +- go vet 零告警;go test -short 55 包全 PASS(dev@e8ed33b) +- 审计笔记:[.doc/audit/2026-09-19-system-deep-audit.md](../../audit/2026-09-19-system-deep-audit.md) +- 五路并行只读深潜全部完成;四条红线核实一致;新发现 4 critical(C1 docgov 吞错 / C2 Session 竞态 / C3 SSE 超时 / C4 工作流竞态)+ 10 high +- 整改:critical/high 已分四批合入 `dev`(`844f757` / `08f4e0b` / `a2a49a6` / `d73bc79`)。medium/low 见 [long-term](../long-term/2026-09-19-audit-remainder.md)。 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-initial-load-performance-plan.md b/.doc/remediation/2026-09-17-initial-load-performance-plan.md new file mode 100644 index 00000000..17175ccb --- /dev/null +++ b/.doc/remediation/2026-09-17-initial-load-performance-plan.md @@ -0,0 +1,214 @@ +--- +title: "首次加载与工作台初始化性能改进方案" +status: planned +created: 2026-09-17 +updated: 2026-09-17 +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# 首次加载与工作台初始化性能改进方案 + +## Goal Capsule + +让用户首次访问就能快速看到并操作登录页,登录后优先恢复当前项目与会话,避免下载无关工具或等待所有项目初始化。 + +采用静态交付优化、按功能加载和请求去重,之后按测量结果优化认证与数据库读取(KTD1–KTD5)。本轮仅交付诊断和方案,业务代码尚未修改;用户授权实施后执行以下单元。 + +遵守 AGENTS.md/PROJECT.md、现有权限和文档治理契约。若优化要求改变授权、新增跨用户缓存、跳过职责正确性或删除历史数据,停止该单元并回报。每单元独立 feature/fix 分支与模块 mutex;合 dev/容器重建占集成槽。默认不推送,不晋升 test/main。 + +## Product Contract + +### Problem Frame + +[诊断](../audit/2026-09-17-initial-load-performance-audit.md) 已确认:8080 首屏 2.86 MB,CDP 10 Mbps 冷缓存登录可见 2.64 秒、2 Mbps 11.78 秒;再次访问约 60 ms。字体 CSS 外链阻塞首屏;3100 Vite 首访 207 请求/12.24 MB。登录后存在全项目成员扇出与会话重复同步,服务端认证、文档列表和会话统计有重复工作。 + +这些数字是单样本实验,不是用户实际公网体验或 p95。用户已确认 3100/8080/8081 在系统重启后首访约十数秒。现有容器启动到打印监听仅约 166/142 ms,不代表整机重启就绪;必须补齐重启矩阵再签优化收益。 + +### Requirements + +- R1. 首次登录页只加载认证与基础样式所需资源,外部字体不可阻塞登录表单。 +- R2. 登录后先显示可用工作台骨架,优先恢复当前项目/会话;其他项目成员、管理台、导出、文件预览在需要时加载。 +- R3. 相同身份和查询键的初始化请求去重;慢响应、快速切换项目、登出/换人不会写回旧状态。失败必须有局部错误与重试入口。 +- R4. 所有服务端授权和文档治理保持正确,权限撤销/停用不得因缓存继续放行。计数、摘要与正文沿用相同可见性语义。 +- R5. 发布后旧页面仍可打开延迟模块;缓存不得导致 HTML 长期陈旧,也不得泄露用户 API 数据;SSE 流式延迟不能回退。 +- R6. 优化收益用可复现冷/热缓存、慢网、字体不可达、多项目和大历史场景证明,结果与镜像/资源 hash 绑定。 + +### Success Criteria + +以下是**拟定验收预算**,不是当前已达标数据。表中静态资源体积及绝对首屏时间预算适用于 8080/8081 构建入口;3100 保留 Vite 开发模式,不套构建产物压缩体积预算,其保留/空依赖缓存与预热状态分别由 U1 固定加载时间预算,U7 检查重启改善;U1 固定机器、Chromium 版本、样本与网络条件,预算不得在看到失败后悄悄放宽。 + +| 指标 | 目标/口径 | +|---|---| +| 未登录关键 JS 总传输量 | 开启压缩后 ≤250 KiB;按首屏实际请求总和而非只看入口文件 | +| 普通工作台首次可用前 JS 总量 | ≤600 KiB;不含用户明确打开的文档预览/导出模块 | +| 冷缓存登录可操作 | 10 Mbps / 80 ms、CPU 4 倍减速下 p75 ≤1.5 s;2 Mbps 同配置 p75 ≤3 s | +| 登录态热刷新 | 10 Mbps / 80 ms、CPU 4 倍减速下工作台可操作 p75 ≤1.5 s;前提必要 API 达下行预算 | +| 登录态冷刷新/登录提交成功后恢复 | 冷刷新自 navigationStart 起、登录自成功响应起,当前会话历史及输入区就绪 p75 ≤3 s;仅骨架不算完成 | +| 必要首屏 API | 同宿主 app↔DB 网络下 p95 ≤300 ms;列表大数据场景 p95 ≤500 ms,接口错误率不增加 | +| 首屏初始化请求 | 项目成员初始化从 N 次降为当前项目最多 1 次;静止初始化每个会话列表查询键最多 1 个在途请求且成功后不被成员更新重触发 | +| 字体故障 | Google 域名完全阻断或延迟 10 秒时,仍满足登录预算,不发首屏外部字体 CSS 请求 | +| 无回归 | 同数据/设备各阶段 p95 不劣化超过 10%;无新增跨用户暴露、错误循环、重复写任务 | + +浏览器每场景 ≥20 次,报告 p50/p75/p95 与原始样本;接口每场景 ≥100 请求、并发 1/10/30。浏览器 p95 仅小样本诊断,不能冒充线上 SLO;稳定发布后再累计真实用户分位数。 + +### Scope Boundaries + +包括浏览器入口、静态资源交付、当前工作台数据初始化、与首屏相关的查询。没有业务语义变更,不调整 PI 模型、Prompt、文档租约时长、授权范围或工作流门禁。 + +Deferred to Follow-Up Work:Redis/CDN、新 SSR 框架、全面 App.tsx 重写、全站统一数据框架、数据库分库、文档全文缓存。只有 U1 的证据表明必要时另开任务;目前不需要先增加基础设施。 + +### Outstanding Questions + +- 待补测:已知端口 3100/8080/8081,重启涵盖宿主机/应用/Vite 的具体范围、慢发生阶段、真实网络与设备;不会阻止可独立证明的 U2/U3,但阻止宣称用户现场问题已经彻底解决。 +- 待补测:登录态瀑布、真实数据规模、SQL/连接池等待;U6 的查询改写/索引必须由这些证据选择。 + +## Planning Contract + +### Key Technical Decisions + +- KTD1. 保留 React/Vite + Go 架构。正式体验使用构建产物入口;3100 保留开发热更新用途,不把开发模块请求数量当生产目标。先确认真实反代 upstream,不盲改 FRP。 +- KTD2. 首屏用系统字体栈;需要保留 Inter/JetBrains Mono 时仅选必需 WOFF2 并同源部署、按需加载。保留 KaTeX 渲染所需字体,但不要求登录页加载数学资源。 +- KTD3. 按功能边界动态 import,登录壳与工作台隔离;管理台、DOCX/XLSX 导出、Word/Excel 预览、Mermaid 按使用加载。仅 manualChunks 拆文件不能减少关键路径下载,必须验证依赖图没有静态回引。 +- KTD4. 现有 Go serveSPA 作为默认静态交付修复点,构建期生成 gzip/br(若工具链已支持)而非每请求高压缩。若真实统一代理已负责压缩,则由它单层处理并记录;不得重复编码。哈希资源 `public,max-age=31536000,immutable`;HTML `no-cache`;压缩变体 `Vary: Accept-Encoding`;用户 API 不共享缓存。保留 identity、HEAD、条件请求、Range 正确性。 +- KTD5. 将请求状态按身份/项目/会话/过滤器键管理,先用局部 hook/loader 和在途合并解决,不强制引入新数据框架。身份切换清空缓存与取消在途请求,成员更新不作为会话获取的依赖。未知成员/权限不靠全量成员预取推导:使用服务端明确的当前用户权限摘要,未就绪保持 fail-closed。 +- KTD6. 请求内复用已认证 principal,授权不跨请求长期缓存。减少 session 续期写入须明确 idle TTL/绝对过期/撤销语义后再落地,保留停用与撤销实时检查。 +- KTD7. 文档列表先保留治理正确性;先补齐所有写路径与历史修复,再移出 GET 同步 heal。轮询保持原有租约新鲜度,优先复用 SSE/可见性控制与单请求在途,不能直接延长租约或无限延迟 busy 显示。 + +### High-Level Technical Design + +```mermaid +flowchart LR + A[HTML与同源基础样式] --> B[轻量认证壳] + B --> C[认证结果与工作台模块并行准备] + C --> D[当前用户可见项目与会话] + D --> E[当前项目权限与聊天窗口] + E --> F[可操作工作台] + F --> G[按需管理台/导出/预览] +``` + +有保存会话时,可在校验期间预加载不含用户数据的工作台代码;受保护 API 与用户内容显示仍由认证门禁控制。无会话时不加载工作台代码。项目/会话列表在认证后并行,解析有效当前项目后再发其范围内请求;不是让全站等待所有项目成员。 + +```mermaid +sequenceDiagram + participant UI as 浏览器 + participant API as Go API + participant DB as PostgreSQL + UI->>API: 首屏必要请求(按查询键去重) + API->>DB: 验证当前会话/用户状态 + API->>API: 请求内复用 principal / 权限解析 + API->>DB: 当前页/当前项目数据 + API-->>UI: 权限范围内的结果 + UI->>UI: 核对身份与查询代次后更新 +``` + +## Implementation Units + +### U1. 固定完整性能基线(P0,约 0.5–1 天) + +**Requirements:** R6;后续全部单元的依赖。 + +**Files:** 新增 `web/e2e/initial-load-performance.spec.ts`、诊断指标辅助模块;更新 `.doc/test-reports/`。本轮探针与结果可作为起点。 + +**Approach:** 固定真实入口、部署 hash、登录角色、项目数/历史量,捕获资源瀑布、FCP/LCP、长任务、输入区与历史就绪、必要 API TTFB。用专用验收账号/夹具测登录,不在真实会话发送诊断消息。先检查 GET 副作用,文档列表暂有 heal,数据库分析使用只读事务和 statement_timeout。分别测空缓存、热缓存、10/2 Mbps、CPU 4x、字体失联、1/30/100 项目与大历史。本轮不重启共享服务;实施时 U7 在隔离环境重现应用/依赖/宿主启动过程,记录 ready 与第一次导航的时间关系。 + +**Test scenarios:** 新浏览器、保存有效/过期会话、无项目、普通成员与管理员、慢 API/失败、项目快速切换;每份结果保留参数与原始样本但无 token/用户正文。 + +**Verification:** 形成基线报告,能回答耗时在哪一阶段,并确认性能指标可重复。登录态测量缺失不得用未登录测试代替。 + +### U2. 修复字体与静态资源交付(P0,约 0.5–1 天) — **done 2026-09-17** + +**Dependencies:** U1 的未登录基线;**Requirements:** R1、R5、R6;**Files:** `web/index.html`、`web/src/styles/index.css`、`web/vite.config.ts`、`internal/server/server.go`、新增 `internal/server/static_assets_test.go`;必要时实际 Docker 构建文件。 + +**Approach:** 按 KTD2/KTD4 去除外部阻塞 CSS、提供压缩变体与明确缓存头。缺失 `/assets/*` 返回 404,不回退 HTML;客户端路由仍允许 SPA fallback。发布资源保留至少当前与上一版本并明确旧标签页支持窗口;窗口外加载失败提示用户保留编辑后刷新,不自动刷新丢失草稿。 + +**Test scenarios:** gzip/br/identity 协商、q=0 禁用、Vary、GET/HEAD、304、Range、未知资源 404、客户端路由回退;字体域名黑洞仍出现表单;SSE 首事件/持续事件不缓冲;受保护 API 无 public 缓存;部署后旧标签页首次打开尚未加载的模块。 + +**Verification:** curl 与浏览器确认真正压缩响应,JS 传输量下降;HTML 不被长期缓存。此单元不宣称已达到拆包预算。 + +**交付证据:** [U2 测试报告](../test-reports/2026-09-17-initial-load-perf-u2-test-report.md);`dev` merge `feature/initial-load-perf-u2`;8080 主 JS br 传输 623,898 B;登录冷缓存可见 ~205 ms。U1/U3–U7 仍未完成。 + +### U3. 登录壳与重功能按需加载(P0,约 1–2 天) + +**Dependencies:** U1;可与 U2 分开开发,合入仍串行。**Requirements:** R1、R2、R5;**Files:** `web/src/main.tsx`、`web/src/App.tsx`、新增认证壳组件、`web/src/components/doc/preview/FilePreviewPanel.tsx`、`web/src/features/replyExport/download.ts`、`web/src/engine/docxRenderer.ts`、`web/src/components/doc/blocks/MermaidBlockView.tsx`、U1 浏览器用例。 + +**Approach:** 按 KTD3 建立真实 lazy 边界;将工作台 hooks/缓存初始化移入工作台挂载周期。导出按钮点击才 import 引擎;预览根据文件类型加载;Mermaid 只在存在图块时加载。每边界提供局部加载/失败重试与错误边界。限定此重构服务于加载,保留既有文档编辑状态机。 + +**Test scenarios:** 未登录资源中不出现导出/预览/管理台/图表重模块;登录成功恢复当前会话;失效会话不展示缓存敏感数据;首次导出 DOCX/XLSX 内容有效;Word/Excel 预览、数学公式与 Mermaid 正确;动态 chunk 失败可以重试且不重复提交业务动作;首次打开功能耗时同时记录。 + +**Verification:** 满足 R1 首屏预算及功能按需请求证据,不把延迟全部转移成首次按钮不可用。检查 gzip 后所有首屏 chunks 总和。 + +### U4. 收敛初始化扇出与重复同步(P1,约 1–2 天) + +**Dependencies:** U1、U3;**Requirements:** R2、R3、R4;**Files:** `web/src/App.tsx`、`web/src/api/projectWorkflow.ts`、`web/src/api/chatSessions.ts`、`web/src/data/storage.ts`、新增初始化 loader/hook 及其测试;必要时项目列表 DTO/服务端 handler 补当前用户成员摘要。 + +**Approach:** 当前项目成员优先,其他完整成员按打开面板加载;会话 fetch 依赖稳定身份/查询键,项目名称映射与网络获取分离;查询代次 + AbortController + 在途合并;每次成员响应不再全量写 projects/持久化。只有实测长任务证明需要时增加去重/批量保存,不把权威服务器文档写入延迟缓存作为成功。 + +**Test scenarios:** 1/30/100 项目首次请求数量稳定;成员结果乱序不会重复请求会话;快速切项目/登出/换用户不串状态;普通成员看到的项目/会话范围与改动前一致;错误后手动重试;成员增删后局部失效刷新;离线缓存不绕过权限验证。 + +**Verification:** 达到初始化去重预算,记录必要 API 次数与工作台就绪时间;跨用户负例、当前会话恢复、文档编辑刷新持久性通过。 + +### U5. 认证请求去重与续期写入收敛(P1,约 0.5–1 天) + +**Dependencies:** U1 的认证测量;**Requirements:** R3、R4、R6;**Files:** `internal/server/auth_handlers.go`、`internal/server/auth_middleware.go`、`internal/auth/service.go`、`internal/auth/pg_store.go` 及相邻测试。 + +**Approach:** `/auth/me` 复用 middleware principal;合并会话及用户读取,避免重复查同一用户。先度量 session UPDATE 锁/连接等待;只有证明其成本且明确既有过期语义后采用有界续期/last_seen 写入策略。鉴权失败不缓存成成功,撤销/停用每请求生效。 + +**Test scenarios:** 同 token 1/10/30 并发;me 请求中只验证一次;撤销/停用后的下一请求拒绝;idle 边界/绝对过期/并发续期;DB 异常 fail-closed;匿名静态资源仍可访问。 + +**Verification:** 展示改前/后 DB 操作数、锁等待和 p95;身份安全回归通过。若节流会改变过期语义,则只交付请求内复用,将节流留待独立决定。 + +### U6. 按测量优化列表与租约读取(P2,约 2–4 天;按证据选子项) + +**Dependencies:** U1、U4;**Requirements:** R2、R4、R6;**Files:** `internal/docgov/project_docs.go`、`internal/docduty/store.go`、成员/职责写路径、`internal/ctxmgr/chat_history.go`、`internal/projectworkflow/store.go`、`web/src/App.tsx` 及相邻 PG/浏览器测试;确需索引时新增 Goose migration。 + +**Approach:** (a) 先验证成员创建/转交/退出/撤销各路径事务内同步职责,并以显式受控任务修复历史,之后去掉列表读时 heal;(b) 租约查询避免每次全量文档修复,单请求在途、隐藏页暂停并在可见时立即同步,复用现有 SSE 失效事件;(c) 会话先按原可见性/排序选页,再仅统计页内数据,项目列表视数据量分页。维持 COUNT/latest 内容授权规则与排序稳定性,不能把当前全量总计改为页内总计却不告知调用方。 + +**Test scenarios:** 文档 GET 零 UPDATE/advisory lock,重复读不变 updated_at;成员变动职责立即正确;两标签 busy/释放不晚于原可见性基线、断 SSE 可恢复;慢查询轮询不重叠;分页无重复/丢项且计数不泄漏;EXPLAIN 对照现有索引与扫描行数;项目 143 不做写入修复测试。 + +**Verification:** PG 真库夹具 + 真实页面操作证明治理与编辑/刷新链路无回归;SQL 扫描、锁等待和 p95 确实改善。没有对应瓶颈时不为“优化”增加索引/摘要/新接口。 + +### U7. 重启后首访链路与开发预热(P0 诊断 / 按证据修复,约 0.5–1.5 天) + +**Dependencies:** U1;**Requirements:** R1、R2、R6;**Files:** `web/vite.config.ts`、`cmd/server/main.go`、`internal/app/app.go`、`internal/bizdev/service.go`、`scripts/env-service.sh`、实际入口健康检查配置与启动测试。 + +**Approach:** 重启矩阵覆盖:仅 Vite 重启(保留/空依赖缓存)、仅 app 重启、依赖服务一起启动。每项再分浏览器保留缓存/清缓存与保存登录态/未登录;记录启动命令、DB healthy、app listen、ready、首次 HTML、首 API、UI ready。共享重启须占槽并避开正在执行任务,在隔离环境优先验证。Go 初始化分段计时;Vite 确认无缓存编译主导后,使用锁定 Vite 版本支持的 server.warmup/clientFiles 预热关键入口并保留 node_modules/.vite 缓存,禁止为暖机触发聊天或文档生成。 + +若 DB/迁移/seed 才是主耗时,先将重复扫描与种子写入改为有版本依据的幂等增量;必需迁移和授权数据未完成时 ready 必须失败。不要为看起来快而提前假报就绪。若是健康探测/代理等待,优化实际就绪探测与放流时序,不能把 start_period 直接当固定延时删除。对于后端暂时不可用,登录壳给出服务准备中和有限重试,不能无提示白屏或把瞬时不可达误当身份失效清空会话。 + +**Test scenarios:** 同版本无迁移重启、首次/有迁移启动、DB延迟就绪/不可用、依赖缓存失效、warmup 失败、过期登录态、保存有效登录态但网络暂时中断;多环境隔离保持。 + +**Verification:** 无待执行迁移且 DB 已就绪时 app restart→ready p95 ≤3 s;完整依赖启动单列,不承诺未测硬件的整机开机时间。8080/8081 ready 后首次访问须符合 Success Criteria 绝对预算;3100 分别符合 U1 在实施前固定的 Vite 冷/暖预算,不套生产压缩体积预算。三个入口在各自匹配的真实重启场景首访耗时均较基线下降至少 50%,同时记录从启动命令到 UI ready 总耗时,防止仅把等待搬到后台预热而宣称消失。若实测无法达到,保留未达标项与原因,不伪报完成。 + +## Verification Contract + +- 前端:`cd web && npm test && npm run build`;单独运行 U1 的 Playwright 性能用例与现有文档修订/历史恢复相关浏览器回归。检查构建依赖与实际请求,不能只检查构建日志大小。 +- Go:`go vet ./...`、`go test -short ./...`;认证/文档/会话修改补相关真实 PG 集成,覆盖撤销、跨用户、分页和职责同步。 +- 合入/运行验证:占 CURRENT 集成槽,在主仓 merge 后 `./scripts/env-service.sh dev rebuild`,验证 `/health`、`/ready` 与资源 hash;健康不代表性能通过。禁止 worktree 启停共享服务。 +- 真实 UI:登录、刷新当前会话、切项目、首开预览、首导出、文档编辑保存后刷新/重开、双标签 busy。涉及文档治理改变时按 PROJECT 补真实 PI + MiniMax 全链路,stub 不可替代交付。 +- 证据:报告记录 commit/image/资源 hash、浏览器与硬件、网络/CPU 设置、缓存状态、数据规模、样本数/分位数、失败样本、请求数和 SQL 变化。脱敏,不保存登录 token、会话正文或原始授权头。 + +## Definition of Done + +1. R1–R6 与性能预算有前后对比和真实 UI 证据;未覆盖项明确列残余,不因 health PASS 关闭。 +2. 字体不可达仍可操作,首次登录无重功能模块;初始化不随全部项目成员返回重复发会话请求。 +3. 授权/文档职责/租约/保存持久性不回退,分页可见性一致,SSE 不缓冲,旧版本 chunk 失效有恢复路径。 +4. 更新任务、CURRENT、FEATURE-MATRIX、test-report,完成后任务归档;明确本地分支/提交、是否合 dev、是否推送。 + +## Rollout and Rollback + +顺序:U1 → U2/U3 与 U7 → U4/U5 → 按数据选择 U6,每波独立复测并归档,预计核心 U1–U5 约 3.5–7 工程日,U7 另需 0.5–1.5 日,U6 另需 2–4 日(估算,含回归,非交付承诺)。先 dev,再按项目既有授权晋升 test/main。 + +压缩/缓存只作用静态资产,发现编码/缓存问题可回退独立提交;保留对应完整 HTML+资源集,避免仅回滚 HTML。拆包可回滚组件边界,但禁止强制刷新正在编辑的用户。认证和查询优化各独立提交;职责修复迁移必须可重入,停用新读路径时保留既有安全修复,不删除数据/索引作为临时回滚。若 p95 回退超过 10%、401/403 异常、文档职责偏差或租约不可见,停止晋升并回退相应单元。 + +## Appendix + +- [审计与数值证据](../audit/2026-09-17-initial-load-performance-audit.md) +- [诊断测量报告](../test-reports/2026-09-17-initial-load-performance-baseline.md) +- HTTP 压缩协商与 `Vary`:[MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Compression)。缓存版本化资源与 HTML 策略:[MDN HTTP caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching)。 +- 外部字体与阻塞 CSS:[web.dev 字体实践](https://web.dev/articles/font-best-practices)。Vite 开发请求瀑布:[Vite performance](https://vite.dev/guide/performance)。这些资料用于原则核验;具体配置以仓库锁定的 Vite 7.1.x 为准,不照搬官网当前版本构建 API。 + +## Plan Review + +已完成同模型的 coherence、feasibility、design、security、scope、adversarial 六视角只读复核(3 个代理分别承担两视角,不作为6份模型独立验证)。修正1项:明确3100开发入口与8080/8081构建入口使用不同资源预算;保留三入口真实重启与总耗时验证。未进行外部跨模型复核,不声称具有跨模型验证。置信度:资源/字体机制高,用户重启现场主因中等,须U1/U7闭环。 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..cc4044bb --- /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 整改。T4:首次加载性能 U1/U3–U7。T5/T6/T8:backlog 类按收益触发。T2(Control Center)已于 2026-09-19 移出本仓库 | **done**(2026-09-18):[决策记录](../ops/short-term/2026-09-18-wave7-roadmap-orchestration.md)。六项编排确认已记录;T2 后来由用户裁决为独立项目,本仓库不再保留 Control Center;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 1d29c217..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)) | @@ -31,3 +33,5 @@ | [2026-09-12-three-workflows-strict-governance-remediation.md](./2026-09-12-three-workflows-strict-governance-remediation.md) | 三工作流严格治理 | 审计入口:[../audit/INDEX.md](../audit/INDEX.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 0e890054..818584da 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -1,6 +1,6 @@ # 模块与功能完成度(SSOT) -**更新**: 2026-09-17(W3 PI 叙事 done;W4 多意图扩面 done) +**更新**: 2026-09-19(审计整改批 1–4 已合入 dev;medium/low 见 [long-term](../ops/long-term/2026-09-19-audit-remainder.md)) **production_ready**: yes(权威来源:[gap-matrix](../remediation/gap-matrix.md);本表不推导发布门禁) **发布缺口**: [../remediation/gap-matrix.md](../remediation/gap-matrix.md) **协作焦点**: [../ops/CURRENT.md](../ops/CURRENT.md) @@ -8,6 +8,8 @@ **整改计划**: [../remediation/2026-09-16-feature-matrix-production-readiness-plan.md](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md) > 本表描述**能力成熟度**;上线 blocker 见 gap-matrix。二者正交:能力行不必全部 `done` 才能发布;`partial`/`deferred` 不得自动回退 `production_ready`。 +> +> 2026-09-19 深度审计的 critical/high 已分四批合入 `dev`(数据一致性、鉴权、SSE/事件、前端 XSS)。不改 `production_ready`。未做项在 [long-term](../ops/long-term/2026-09-19-audit-remainder.md)。 --- @@ -16,7 +18,7 @@ | 字段 | 含义 | |------|------| | **声明范围** | 本行冻结的能力边界;`done` 只对该边界负责 | -| **状态** | `done` \| `partial` \| `planned` \| `deferred` \| `blocked` | +| **状态** | `done` \| `partial` \| `planned` \| `deferred` \| `blocked` \| `removed`(已下线,仅留历史证据) | | **发布关系** | 与当前首发/已签门禁的关系:`non_blocker` / `补证` / `加固` / `deferred_enhancement` | | **关闭条件** | 何时可将本行升为 `done`(或关闭 `partial`);stub/SKIP/fallback 不得冒充 | | **证据** | 可追溯到报告/commit/环境/schema;代码或 artifact 变化后须重验 | @@ -55,6 +57,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)。剩余:S3 overlay 与 R2/R3 已接。P2 名称匹配、阶段折叠、批量审批已接。选中 rdev 时显示与服务端相同的 CDO 前置说明(`workflowPrerequisite.test.ts`)。P4 键名空间化已接:发布清单用 `bd_`/`pm_`/`tm_`/`sw_`,旧键仍走同一套步骤和模板,旧 doc_id 仍可查(`keyspace_test.go`)。P3 三样本已在 dev 真 PI 上跑过(项目 6533):`rd_sw_plan` 规划期关闭;`rd_proposal` completed 但未对齐模板标题;`rd_planning_review` 当时质量门禁 score 20,是把同节后一张法规表算进问题行。门禁已只读第一张表;保存的终稿不再报「问题行缺少」。建议书标题仍未对齐模板。没有新的真 PI。见 [P3 报告](../test-reports/2026-09-19-rdev-p3-real-pi-test-report.md)。nightly 未扩成四工作流。 | `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` | @@ -67,6 +70,8 @@ | Artifact 同 digest 晋升 | 候选 commit 一次构建、按 digest 晋升、promotion receipt | done | 加固 | [计划 PU5B](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md);[Wave2B](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md);[Wave2C](../test-reports/2026-09-16-wave2c-same-image-digest-test-report.md) | **关闭**:same-image pin(`candidate-images-*` + `PINNED_IMAGE_MODE` + promote `--require-digests`)。**声明**:单机 retag/digest pin;远程 registry 推送另立任务 | `infra` | | 可靠事件核心 | PG dispatcher、notify/advance/projection live、生产闭包 hook 退出、Phase2 选择性解耦已合入 | done | non_blocker | [E0A–E2](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md) 等;[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[shared live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[phase2 closeout](../test-reports/2026-09-15-phase2-closeout-test-report.md) | **before**:与 FAV off 混成 partial → **after**:FAV 独立行。选择性后续解耦按收益触发 | `architecture-docs` / `events` | | Final Artifact Vault(FAV) | 不可变终态制品库;`FAV_DEGRADED_READ=false` | partial | deferred_enhancement | [Track E 复盘](../ops/research/2026-09-15-track-e-retrospective.md);[U7 报告](../test-reports/2026-09-16-fav-u7-incremental-test-report.md);[W6 shadow](../test-reports/2026-09-17-w6-fav-subscriber-shadow-test-report.md);[short-term](../ops/short-term/2026-09-17-w6-fav-subscriber-shadow.md) | **after W6**:subscriber `ObserveShadow` 只计算 intended outcome,不写副作用。仍**禁止**整支 merge;`FAV_DEGRADED_READ` 默认 false;**live 未放量**(staging→artifact→publication 各 ≥7 天观察窗) | `events` | +| 系统管理台 | 全屏系统级管理视图(总览/KB/特性开关/系统设置/工作流/角色/用户/文档 8 分组);`/api/v1/admin/system/*`;`system_settings` 白名单可写;吸收下线 AdminSettingsModal | done | non_blocker | [short-term](../ops/short-term/2026-09-17-system-admin-console.md);[测试报告](../test-reports/2026-09-17-system-admin-console-test-report.md);commit `f1404d5`/`0eaca61`、merge `dc7c31e`(dev);dev 冒烟 schema 49 + 四端点 + 写读回环 PASS | 二期:Playwright 实跑、细粒度 `system:*` 权限、设置审计日志、KB 运维动作、白名单项接消费方 | `web` + `sysadmin` | +| 个人工程中控台(已移出) | 原本地只读看板与后来的 Control Center 都不再属于本仓库 | removed | 进度管理改由独立项目承担 | [历史设计](../archive/design/2026-09-17-personal-engineering-control-center-plan.md);[历史报告](../test-reports/2026-09-17-engineering-dashboard-test-report.md) | 2026-09-19 删除 `cmd/control-center`、`internal/controlcenter`、`control-center/web`、`deploy/control-center`。不恢复 `engineering-dashboard` | — | --- @@ -77,7 +82,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) | --- @@ -88,3 +93,11 @@ 2. 同一 `mutex` 同时仅一个 Agent `in_progress`(见 CURRENT) 3. 新能力行:复制 [../ops/templates/feature-matrix-row.md](../ops/templates/feature-matrix-row.md) 4. 拆行/收口须保留 before/after 与义务迁移(见上表);禁止缩口径假完成 + +## 2026-09-17 首次加载性能专项 + +首次加载与重启首访性能优化:**partial**(U2 静态交付已验收;U3 只做了登录壳拆包,未关闭)。[审计](../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。U3 进展(`fix/audit-remainder-closeout`):导出、预览、管理台、块编辑器、Mermaid/公式改为点击或出现时再加载。入口包 `index-*.js` 由 2.83 MB / br 626 KB 降到 737 kB / br 170 KB(`index-j0BIrjIR.js`,包内无 katex)。公式在气泡或正文里出现时才加载,富文本复制同样延后。U3 的浏览器资源瀑布还没测,本行不关。U5:`/auth/me` 复用本请求已核过的用户,不再第二次解析会话;会话与用户状态合成一次 JOIN。没有锁等待证据,所以不节流 `last_seen`。`TestAuthMe_LogoutNextRequestIs401`。U1、U6、U7 未做。U4 进展:登录不再按项目数拉成员名册,只同步当前项目;其他项目先种入当前用户以保持会话可见,完整名册在打开成员面板时取。会话列表不再因项目数组变化而重拉,迟到响应用代次丢弃,同一项目的在途成员请求合并。`src/data/initFanout.test.ts`。2026-09-19 浏览器证据(`e2e/initial-load-performance.spec.ts`,n=20,本仓 Vite `:3100` 代理 `:8080`):每次登录成员项目数 1、会话列表 GET 1,匿名与登录后资源路径均无 katex/mermaid/docx。同一探针在重建后的 `:8080`(`index-DOO0-Yh7.js`,schema 53,n=5)成员项目 1、会话列表 GET 1,匿名 JS 传输 172,417 B,首屏路径无 katex/mermaid/docx。重建前旧包 n=1 曾是 704/30。慢网 n=3、CPU 4 倍:10 Mbps 表单可见 558–604 ms,2 Mbps 1279–1296 ms,样本不足 20,U1 不关。U6 不加索引:测到的瓶颈是已消失的成员扇出,没有慢 SQL 证据。U7 已测未关:[报告](../test-reports/2026-09-19-initial-load-u7-restart-test-report.md)。Vite 监听约 0.3 s;314 个模块冷/热 1.45/1.11 s,不是历史十几秒。postgres+app 到 `/ready` 1.0–1.2 s。无头浏览器缺系统库,登录表单可见未测;不宣称较基线下降 50%。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/README.md b/.doc/system-design/README.md index 4b91de9b..0ef1fddd 100644 --- a/.doc/system-design/README.md +++ b/.doc/system-design/README.md @@ -138,6 +138,12 @@ --- +### 8. 个人中控台(已移出本仓库) + +项目开发进度由独立的统一管理系统负责,不再是本仓库功能。本仓库只保留智能体系统。旧只读工程中控台的历史设计仍在 [../archive/design/2026-09-17-personal-engineering-control-center-plan.md](../archive/design/2026-09-17-personal-engineering-control-center-plan.md),代码不恢复。 + +--- + ## 🏗️ 在整体架构中的位置 ``` diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 440ae23e..02352fd5 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -90,6 +90,7 @@ imports: - taskrun - workflowdef - workspace + - sysadmin app/chatflow: - coordinator - ctxmgr @@ -147,6 +148,7 @@ imports: httpx: [] intent: - maturity + - squad kb: - config maturity: [] @@ -202,6 +204,7 @@ imports: - workflowdef - workspace - artifactvault + - sysadmin squad: - agent - config @@ -209,6 +212,7 @@ imports: - stream - template - workspace + - workflowdef storage: [] stream: [] stream/transport: @@ -223,7 +227,8 @@ imports: - projectworkflow - stream - workspace - template: [] + template: + - workflowdef upload: [] workflow: [] workflow/runtime: @@ -244,6 +249,8 @@ imports: dataset: - artifactvault - app/uow + sysadmin: + - config table_owners: document_records: @@ -326,6 +333,8 @@ table_owners: - app/intentflow intent_plan_shadow: - app/intentflow + system_settings: + - sysadmin path_owners: output.md: 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-engineering-dashboard-test-report.md b/.doc/test-reports/2026-09-17-engineering-dashboard-test-report.md new file mode 100644 index 00000000..4fd49747 --- /dev/null +++ b/.doc/test-reports/2026-09-17-engineering-dashboard-test-report.md @@ -0,0 +1,109 @@ +# 个人工程中控台(engineering-dashboard)测试报告 + +## 一、 测试背景 (Background) + +仓库需要独立于产品后台的本地只读工程中控台,避免把文档 `done` 误读为已合并或已部署。方案见 [设计](../archive/design/2026-09-17-personal-engineering-control-center-plan.md)。本报告验收一期 `serve` / `export` 实现。 + +## 二、 测试目标 (Test Objectives) + +- 实时 HTML、离线 HTML、JSON 共用 `generation_id` / `revision_id` +- 计划 / Git 实施 / 验证 / 实装四轴独立 +- rdev、dev ahead、main events 分叉样本(单元夹具)按方案输出 +- 单源失败可 degraded;loopback / token / Host / 只读方法防护生效 +- 导出 HTML `0600`、可离线阅读 Markdown、不嵌 assets + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux;Go module `chat-agent-system` +- 工作树:`.worktrees/feature-engineering-dashboard`(`feature/engineering-dashboard`) +- 层级:包单元/集成测试 + CLI 真实 `serve`/`export` 冒烟(只读观察,未启停共享 compose) + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| 模型四轴 | doc done 不改 Git/部署 facets | +| rdev 夹具 | planned / unclaimed / 实装 unknown | +| dev ahead | ahead>0 且备注含未确认远端 | +| main fork | readiness ok + containers partial + conflict | +| HTTP | POST 405;无 token 401;坏 Host 403;非 loopback bind 拒绝 | +| export | 权限 0600;含 identity;无 script breakout | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 单元与包测试 + +- **测试输入**: `go test ./internal/engineeringdashboard/ ./cmd/engineering-dashboard/ -count=1` +- **实际输出**: +```text +ok chat-agent-system/internal/engineeringdashboard 0.009s +? chat-agent-system/cmd/engineering-dashboard [no test files] +``` +- **判定**: PASS +- **耗时**: ~0.01s(包测) + +### 5.2 go vet + +- **测试输入**: `go vet ./internal/engineeringdashboard/... ./cmd/engineering-dashboard/...` +- **实际输出**: (无输出) +- **判定**: PASS + +### 5.3 真实 export + +- **测试输入**: `go run ./cmd/engineering-dashboard export --repo --output /tmp/engdash-export.html` +- **实际输出**: 文件权限 `600`;体积可离线打开;含 CSP 与 snapshot identity +- **判定**: PASS(`stat -c '%a'` → `600`) + +### 5.4 真实 serve 冒烟 + +- **测试输入**: `engineering-dashboard serve --repo --addr 127.0.0.1:8799`,随后 curl +- **实际输出**: +```text +{"ok":true} +generation gen_1 +revision rev_3378ee42f645c4ab1af69cf691c00103 +tasks 50 +docs 452 +sources [('docs', 'ok', 'fresh'), ('git', 'ok', 'fresh'), ('runtime', 'ok', 'fresh')] +POST_reject HTTPError 405 +no_token=401 +bad_host=403 +``` +- **判定**: PASS(HTML/JSON 同源 identity;安全负例符合预期) +- **说明**: 工作树上尚未合入未跟踪的 rdev 文档树;rdev/dev-ahead/main-events **样本契约由包内 fixture 单测覆盖**(`TestModel_*` / `TestReconcile_*`)。 + +### 5.5 夹具验收样本(单元) + +| 样本 | 测试 | 判定 | +|------|------|------| +| rdev 已计划未实施 | `TestReconcile_RDevPlannedUnclaimed` / `TestModel_AcceptanceSamplesAxes` | PASS | +| local ahead origin | `TestReconcile_DevAheadSample` / `TestGit_AheadDoesNotClaimRemoteAvailable` | PASS | +| main ready vs events restarting | `TestReconcile_MainEventsForkConflict` / `TestRuntime_ReadyOKAndEventsRestarting` | PASS | +| Herdr vs 产品 PI Runner | `TestModel_ClaimedWithoutRuntimeKeepsActivityUnknown` | PASS | + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +对本工具包: + +```text +go test ./internal/engineeringdashboard/ ./cmd/engineering-dashboard/ -count=1 +# ok engineeringdashboard +go test -short ./internal/engineeringdashboard/... ./cmd/engineering-dashboard/... -count=1 +# ok +go vet ./internal/engineeringdashboard/... ./cmd/engineering-dashboard/... +# clean +git diff --check +# clean +``` + +全仓 `go test -short ./...` 在合入 `dev` 占槽时于主仓执行(本工具未改产品包)。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- **结论**: 一期达到合并标准(独立 CLI、同源快照、四轴、安全边界、导出)。 +- **后续**: 跨快照历史、远端 CI/Gitea、受限 Markdown/Mermaid、Agent 查询 API(设计附录已列,不在本期)。 + +## 相关链接 + +- 任务: [../archive/ops/short-term/2026-09-17-engineering-dashboard.md](../archive/ops/short-term/2026-09-17-engineering-dashboard.md) +- 设计: [../archive/design/2026-09-17-personal-engineering-control-center-plan.md](../archive/design/2026-09-17-personal-engineering-control-center-plan.md) diff --git a/.doc/test-reports/2026-09-17-initial-load-perf-u2-test-report.md b/.doc/test-reports/2026-09-17-initial-load-perf-u2-test-report.md new file mode 100644 index 00000000..e2465c9e --- /dev/null +++ b/.doc/test-reports/2026-09-17-initial-load-perf-u2-test-report.md @@ -0,0 +1,113 @@ +# 首次加载 U2:字体自托管与静态资源交付 测试报告 + +## 一、 测试背景 (Background) + +诊断确认 8080 登录页下载约 2.82 MB 未压缩 JS,且 Google Fonts 外链 CSS 可阻塞首屏;Go `serveSPA` 无压缩协商、无明确缓存头,且缺失 `/assets` 会回退 HTML。本轮实施 U2(自托管字体、构建期预压缩、静态交付协商与 404 修复),不包含 U3 拆包。 + +## 二、 测试目标 (Test Objectives) + +- 登录页无 `fonts.googleapis.com` / `fonts.gstatic.com` 请求;阻断该域名仍可出现登录表单 +- 主 JS 响应带 `Content-Encoding: br` 或 `gzip`,传输体积显著下降 +- `/assets/missing.js` → 404 且 body 非 HTML +- HTML `Cache-Control: no-cache`;`/assets`/`/fonts` `immutable`;API 无 public 长缓存 +- 单元测试与 `go test -short` / `npm test` 通过 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;Go 1.27.1;dev 容器 `shuimu-agent-app-dev`(镜像 `agent-dev-app@sha256:2d74fe18…`) +- 入口:`http://127.0.0.1:8080`(构建产物) +- 层级:Go 单元测试(`static_assets_test.go`)、前端断言、curl 协商验收、Playwright Docker(`mcr.microsoft.com/playwright:v1.63.0-noble`)浏览器探针 +- 代码:`dev@bc01781`(merge `feature/initial-load-perf-u2`);无缓存重建后镜像含 `/fonts` 与 `.br/.gz` + +## 四、 预期结果 (Expected Results) + +| 场景 | 预期 | +|------|------| +| `Accept-Encoding: gzip, br` | `Content-Encoding: br`,主 JS ~624 KiB | +| 仅 `gzip` | `Content-Encoding: gzip` | +| identity / `gzip;q=0` | 无 `Content-Encoding`,原始体积 | +| `/assets/missing-hash.js` | 404,非 HTML | +| HTML / SPA fallback | `Cache-Control: no-cache` | +| 字体域名阻断 | 登录输入框仍可见 | +| API 未认证 | 401,无 `immutable` | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 单元测试 + +- **Input**: `go test -short ./internal/server/ -run 'TestStaticAssets_|TestParseAcceptEncoding'` +- **Actual**: `ok` +- **Verdict**: PASS +- **Duration**: ~0.66 s + +- **Input**: `npx tsx --test src/styles/noExternalFonts.test.ts`;`npm test` +- **Actual**: 字体断言 2/2 PASS;前端套件 380 + vitest 32 PASS +- **Verdict**: PASS + +### 5.2 curl 协商(8080,无缓存重建后) + +证据:`.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/after-curl-nocache.txt` + +| 检查项 | 实际 | 判定 | +|--------|------|------| +| HTML | `Cache-Control: no-cache`,`Vary: Accept-Encoding`,无 Google Fonts | PASS | +| JS + `gzip, br` | `Content-Encoding: br`,下载 **623,898 B**(原 2,820,055 B,−77.9%) | PASS | +| JS + `gzip` | `Content-Encoding: gzip`,`immutable` | PASS | +| JS identity | 无 encoding,`Content-Length: 2820055`,仍有 `Vary` | PASS | +| `/assets/missing-hash.js` | **404** `404 page not found` | PASS | +| `/fonts/inter-v20-latin.woff2` | 200,`immutable`,48256 B,无 Content-Encoding | PASS | +| `/sessions/demo` | 200 + index,`no-cache` | PASS | +| `/api/v1/chat/sessions` | 401 JSON,无 immutable | PASS | + +### 5.3 浏览器探针 + +证据:`assets/2026-09-17-initial-load-perf-u2/browser-u2.json` + +| 场景 | 登录可见 | transferBytes | 外链字体请求 | 判定 | +|------|----------:|------------:|-------------:|------| +| login-cold | **205 ms** | **681,964** | 0 | PASS | +| login-font-domain-blocked | **213 ms** | 681,964 | 0 | PASS | + +对比诊断 before(同机冷缓存登录页):可见 ~662 ms,transferSize 2,857,761 B;主 JS 未压缩 2,820,055 B。本轮未登录首屏传输量下降约 **76%**。 + +主 JS Resource Timing:`transferSize=624198`,`decodedBodySize=2820055`(确认浏览器实际收到 br 变体)。 + +### 5.4 重启时间线(U7 起点证据,非 U7 完成) + +| 事件 | 时刻 (UTC) | +|------|------------| +| 容器 StartedAt | 2026-09-17T08:22:48.548Z | +| goose no migrations / kb online | 08:22:48.660–.676 | +| 日志「监听地址」 | ~08:22:48.7 | +| health=200 | ≤08:22:50(重建后首次探测) | + +app restart → listen ≈ **150–200 ms**(DB 已就绪、无迁移)。**不能**据此宣称用户「整机重启十几秒」已解决。 + +首次 `env-service.sh dev rebuild` 曾因并行 Agent 切走工作树导致镜像装入旧 `web/`;随后在 `dev` 上 `docker compose build --no-cache app` 后验收通过。证据见 `rebuild-nocache-log.txt`。 + +### 5.5 SSE + +未认证 `GET /api/v1/chat/stream` 立即 401 JSON;静态压缩路径不触及 API/SSE。PASS(静态层无缓冲回归迹象)。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```text +go test -short ./internal/server/ -run 'TestStaticAssets_|TestParseAcceptEncoding' → ok +npm test (web) → 380 + 32 PASS +npm run build → precompress 49 .br / 49 .gz +``` + +未在本报告将 stub 输出当作交付证据;浏览器与 curl 均打真实 8080 容器。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- **U2 达到合并/dev 验收标准**:字体去阻塞、压缩协商、缓存头、assets 404 均有真实证据。 +- **不宣称** Success Criteria 全表达标(U1 登录态套件未建;U3 拆包未做,首屏 JS 解码体仍约 2.8 MB)。 +- 后续:U3 懒加载;U1/U7 补齐重启与登录态预算;并行 Agent 占用主仓时,合入容器重建须 `--no-cache` 或锁定工作树以免 BuildKit 读到脏上下文。 + +### 提交与环境 + +- 分支:`feature/initial-load-perf-u2` → 已 `merge --no-ff` 入 `dev@bc01781` +- 镜像:`agent-dev-app` sha256:2d74fe184585ec0d0d24a051cd6bbc0cfb5b2ef050a376a32027c8e442703cd1 +- 资源:`/assets/index-Bu0CqZB_.js` + `.br` +- 未推送、未晋升 test/main diff --git a/.doc/test-reports/2026-09-17-initial-load-performance-baseline.md b/.doc/test-reports/2026-09-17-initial-load-performance-baseline.md new file mode 100644 index 00000000..1cc3bd94 --- /dev/null +++ b/.doc/test-reports/2026-09-17-initial-load-performance-baseline.md @@ -0,0 +1,49 @@ +--- +title: "首次加载性能诊断测量(非优化验收)" +status: done +created: 2026-09-17 +updated: 2026-09-17 +--- + +# 诊断测量报告 + +## 范围与结论 + +只读未登录浏览器基线、静态资源响应与受控网络实验完成;没有业务代码修改,不是优化 PASS 或完整重启验收。用户补充现象为 3100/8080/8081 系统重启后第一次进入约十数秒。 + +[审计](../audit/2026-09-17-initial-load-performance-audit.md) 包含数值、因果链、源码定位和限制;[方案](../remediation/2026-09-17-initial-load-performance-plan.md) 含重启矩阵、性能预算与治理回归。 + +## 环境与步骤 + +- 2026-09-17;源码读取主仓 `feature/rdev-core@52f0fbc`,文档 worktree 基线 `dev@d0ef696`。 +- 实际 app-dev image `sha256:355ec1af39c5233ac7e710c6c4300779681edd96d5da85258a9dde71a1698af2`;app-test `sha256:ee7df995c59eb0faf8e26160113d36fcdb57844b4e96989709f8b6edc2f00395`。未证明这些镜像与源码 commit 相同。 +- 主 JS hash 分别为 8080 `index-BLnmtk_3.js`、8081 `index-Dk3TnkZv.js`、8000 `index-BT80OB_W.js`。 +- 浏览器采用现有 `mcr.microsoft.com/playwright:v1.63.0-noble`、headless Chromium;不登录、不调用写接口、无截图/正文保存。基线未加 CPU 限速。 +- 宿主 Playwright 因缺 libatk 启动失败;隔离浏览器容器成功,不安装/修改宿主库。 +- 新 context 冷缓存 → 同 context 第二次导航;页面 input 可见后等 1.5 秒采集 Resource Timing。每场景 n=1,未报告分位数。 +- 受控场景包括 CDP 10/2 Mbps + 80ms、字体立即失败、字体延迟 5 秒后失败。CDP document RTT 未精确达到设置值,因此是带宽敏感性实验,不是公网仿真认证。 +- 3199 隔离 Vite 使用主仓源码、独立 `/tmp` 空 cacheDir,不清理原有 3100 依赖缓存,不更改 Vite 配置;结束后停止临时实例。 +- 日志仅提取启动时间和固定事件标记,不保存原始日志。现有 app start→打印监听 dev 166 ms/test 142 ms,不等同真正 ready 或整机重启耗时。 + +## 结果 + +| 检查 | 结果 | 限制 | +|---|---|---| +| 三容器主 JS 未压缩 | 确认,约 2.80–2.82 MB | 用户上游代理压缩未知 | +| 8080 冷/热差异 | 662/62 ms | n=1,本机未登录 | +| 带宽敏感性 | 10 Mbps 2.64 s;2 Mbps 11.78 s | 模拟,不是用户网络 | +| 外部 CSS 阻塞 | 人为延迟5s → 登录5.16s | 不能称自然延迟5s | +| 3100资源成本 | 207 请求/12.24 MB | 当时 Vite 服务已运行 | +| 隔离 Vite 空缓存 | 首访1.58s/再次171ms,首访14.45MB | 启动早于浏览器连接,非完整冷启动总耗时 | +| 登录态/整机重启 | 未覆盖 | U1/U7必须补齐 | +| 功能/真实PI/优化收益验收 | 未执行 | 本轮无实现 | + +## 原始数据与复现 + +证据目录:[assets](assets/2026-09-17-initial-load-performance/)。`browser.json`、`controlled.json`、`vite-cold.json` 为浏览器结果;`assets.json` 为静态头/体积摘要。对应 `*.cjs` 是诊断探针,不是业务测试。 + +复现普通浏览器基线可使用已有 Playwright 镜像,挂载本仓到 `/repo:ro`、证据临时输出目录到 `/out`,执行 `/out/probe.cjs`。脚本假设 `/repo/web/node_modules/playwright` 存在;运行前复制脚本到临时输出目录,不覆盖既有证据。字体拦截会禁用该 context 缓存,不得拿其 warm 结果与正常 warm 作性能比较。 + +## 交付状态 + +方案及证据仅在独立本地 worktree,未合 dev、未 push;无共享服务重启/镜像重建。后续实施按 CURRENT 认领与集成槽规约执行。 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-17-system-admin-console-test-report.md b/.doc/test-reports/2026-09-17-system-admin-console-test-report.md new file mode 100644 index 00000000..288ac621 --- /dev/null +++ b/.doc/test-reports/2026-09-17-system-admin-console-test-report.md @@ -0,0 +1,87 @@ +# 系统管理台(System Admin Console)一期 测试报告 + +## 一、 测试背景 (Background) + +系统级管理能力此前散落在 `AdminSettingsModal`(用户/审批/策略/角色/工作流/状态六 tab 模态框),且系统状态、KB、特性开关无统一查看入口;feature flag 全部 env 驱动、无 DB 持久化设置。本任务落地独立的系统管理界面:后端新包 `internal/sysadmin`(薄聚合层)+ `/api/v1/admin/system/*` + `system_settings` 表(goose 00049);前端全屏 `AdminConsoleView`(八分组),吸收并下线旧模态。 + +- 分支:`feature/system-admin-console`;merge commit:`dc7c31e`(dev) +- 代码 commit:`f1404d5`(后端)、`0eaca61`(前端) + +## 二、 测试目标 (Test Objectives) + +1. admin 可从 LeftRail 入口进入全屏管理台,八分组可用; +2. `/api/v1/admin/system/{overview,kb,features,settings}` 契约正确、脱敏、仅 admin 可访问; +3. 白名单设置 PUT 写后读生效,未知 key 400,匿名 401; +4. goose 00049 在 dev 容器自动迁移(schema_version 48→49); +5. `go vet` / `go test -short` / archtest / `web npm test` / `npm run build` 全绿。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 环境:Linux,dev 容器栈(`env-service.sh dev rebuild` 后),app `agent-backend=pi-agent`(真实 PI Runner 容器在线),PG `:5433`,共享 KB `:5432` +- 层级: + - Go 单元/集成:`internal/sysadmin`(读优先级、写校验、聚合、脱敏)+ `internal/server`(真实 mux + 内存 IAM 的 401/403/200/400/503) + - 前端:`npm test`(node:test 378 + vitest 32)+ `npm run build` + - 容器冒烟:health、四个 GET 端点、PUT 写读回环、负例(匿名 401、未知 key 400) +- 架构约束:`go test ./internal/archtest/...`(module-boundaries.yaml 已登记 sysadmin) + +## 四、 预期结果 (Expected Results) + +- health `schema_version=49`;overview 字段 camelCase 且与 health 信号一致;kb 响应不含任何密码字段;features 覆盖主要功能开关;settings 6 项白名单;PUT 后 GET 持久化;匿名 401;非 admin 403(单测覆盖);未知 key 400。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:dev 容器冒烟(rebuild 后真实环境) + +- **Input**:`./scripts/env-service.sh dev rebuild` → `curl /health` +- **Actual Output**: + ```json + {"status":"ok","env":"dev","agent_backend":"pi-agent","kb_ready":true,"database_ready":true,"schema_version":49,"schema_ready":true,"task_store_ready":true} + ``` +- **Verdict**:PASS(goose 00049 实跑,48→49) + +### 用例 2:admin 四端点契约 + +- **Input**:admin 登录(compose 默认种子账号)→ Bearer 调四端点 +- **Actual Output**: + ``` + overview: {"status":"ok","env":"dev","agentBackend":"pi-agent","databaseReady":true,"schemaVersion":49,"taskStoreReady":true,"kbOnline":true,"uptimeSeconds":170} + kb: {"online":true,"host":"shuimu-kb-shared","port":5432,"database":"regulations","user":"regkb","embeddingModel":"bge-m3","rerankerEnabled":true,"sslMode":"disable"} + features: 22 项;groups=[agent, artifact-vault, auth, ctxmgr, docrevision, intent, intentflow, system] + settings: 6 项 [ui.maintenance_notice, ui.read_only_mode, chat.default_session_role, tasks.default_auto_repair, kb.default_search_limit, notifications.stage_advance_reminder] + ``` +- **Verdict**:PASS(kb 无密码字段,单测另断言响应体不含 "assword") + +### 用例 3:设置写读回环 + 负例 + +- **Input**:`PUT settings/ui.maintenance_notice {"value":"slot-smoke-2026-09-17"}` → GET → `PUT no.such.key` → 匿名 GET overview → 复位为空 +- **Actual Output**: + ``` + PUT 200 → value= slot-smoke-2026-09-17 updatedBy= 9 + GET 持久化 → persisted value= slot-smoke-2026-09-17 + PUT no.such.key → 400 + 匿名 overview → 401 + 复位 → reset-ok + ``` +- **Verdict**:PASS + +### 用例 4:非 admin 403 + +- **Actual**:`internal/server/admin_system_handlers_test.go` 真实 mux + 内存 IAM:普通注册用户访问全部 5 端点 → 403;未认证 → 401。PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `go vet ./...`:通过 +- `go test -short ./...`:全部 ok(含 `internal/sysadmin` 12 用例、`internal/server` 新增 6 用例;PG 用例 TEST_DATABASE_URL 守卫 skip) +- `go test ./internal/archtest/...`:ok +- `cd web && npm test`:node:test 378/378 + vitest 32/32 通过 +- `cd web && npm run build`:✓ built in 7.58s(仅既有 chunk 体积警告) + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +一期达到合并/验收标准:管理台骨架与八个分组、system_settings 写读机制、权限门禁、迁移实跑均验证通过。 + +后续(二期候选): +1. Playwright 冒烟 `web/e2e/admin-console.spec.ts` 需真实 dev 栈 + `RUN_UI_ACCEPTANCE=1` + ADMIN 凭据执行(本次未跑,默认 skip); +2. 细粒度 `system:view`/`system:manage` 权限与设置变更审计日志; +3. KB 运维动作(连接测试/重建索引);白名单设置项接入真实消费方; +4. 403 自动退回工作台(当前为提示 + 手动返回,便于验收截图)。 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..9e6a47ad --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave7-roadmap-orchestration-test-report.md @@ -0,0 +1,67 @@ +# 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 签字)或收益认领(T4 U1、T5、T6、T8),不在本波实施。未 push。 + +2026-09-19 补充:Control Center 取舍已关闭,模块已移出本仓库。本报告 §5.3 只记录当时未合并的事实。 diff --git a/.doc/test-reports/2026-09-19-audit-b1-data-consistency-test-report.md b/.doc/test-reports/2026-09-19-audit-b1-data-consistency-test-report.md new file mode 100644 index 00000000..ed8c66a2 --- /dev/null +++ b/.doc/test-reports/2026-09-19-audit-b1-data-consistency-test-report.md @@ -0,0 +1,77 @@ +# 审计整改批 1(后端数据一致性)测试报告 + +## 一、 测试背景 (Background) + +2026-09-19 系统深度审计([.doc/audit/2026-09-19-system-deep-audit.md](../audit/2026-09-19-system-deep-audit.md))发现 4 项后端数据一致性 critical/high: +- C1 docgov 租约/审计持久化 `_, _ = pool.Exec` 静默吞错 + `lease_fencing_token` 列未读回 +- C2 Session 全字段数据竞态 + ProjectID 跨项目污染 +- C4 工作流 SaveWorkflowPlan upsert/bump 分裂事务竞态 + completed_by 错填为 activated_by +- H10 `context.Background()` 脱离请求生命周期 + +## 二、 测试目标 (Test Objectives) + +- 修复后 `go vet` 零告警、`go test -short ./...` 全量 PASS +- 受影响三包 `go test -race -short` PASS(chat / docgov / projectinstance) +- 不改变既有 API 行为契约(除 completed_by 归属修正) + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 环境:linux 7.0,Go 1.x,分支 `fix/audit-b1-data-consistency` → 合入 `dev`(worktree `.worktrees/dev-rdev-merge`,merge commit `844f757`) +- 层级:单元测试(受影响包)+ 全量回归 + race 检测 +- dev 服务在线 `:8080`(合入前旧二进制,schema 52,health ok) + +## 四、 预期结果 (Expected Results) + +- docgov:租约持久化失败返回 error 并回滚内存状态;审计失败 slog.Error 可观测;重启后 fencing token 从 DB 读回 +- chat:Session 字段访问全部经 mu 保护;`-race` 不报 data race +- projectinstance:SavePlanWithRevision 单事务原子;bump 冲突时 plan 不落库;completed_by = 推进操作者 +- server:getOrCreateSession 内 DB 调用有 5s 超时 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:go vet 全量 +- 输入:`go vet ./...` +- 输出:(空,零告警) +- 判定:PASS + +### 用例 2:全量短测 +- 输入:`go test -short ./...` +- 输出:55 包 ok,无 FAIL/panic +- 判定:PASS + +### 用例 3:race 检测(受影响包) +- 输入:`go test -race -short ./internal/chat/ ./internal/docgov/ ./internal/projectinstance/` +- 输出: +``` +ok chat-agent-system/internal/chat 1.054s +ok chat-agent-system/internal/docgov 1.038s +ok chat-agent-system/internal/projectinstance 1.015s +``` +- 判定:PASS + +### 用例 4:dev 冒烟(合入后 worktree) +- 输入:`.worktrees/dev-rdev-merge` 上 `go vet ./...` + `go test -short ./...` +- 输出:VET_OK,无 FAIL +- 判定:PASS + +### 用例 5:dev 服务健康 +- 输入:`curl http://127.0.0.1:8080/api/v1/health` +- 输出:`{"status":"ok","env":"dev","schema_version":52,"schema_ready":true,...}` +- 判定:PASS(注:跑的是合入前二进制,容器 rebuild 未执行,见七) + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `go vet ./...` 零告警 +- `go test -short ./...` 55 包全 PASS +- `go test -race -short`(chat/docgov/projectinstance)全 PASS + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 批 1 四个子项全部修复并合入 dev(merge `844f757`),质量门禁全绿,达到合入标准。 +- **遗留**:dev 容器二进制仍为合入前版本(health 为旧码);如需真实链路验证 C2 竞态修复,应 `./scripts/env-service.sh dev rebuild` 后复跑。此为长操作,未在本批执行,留给集成槽位下一次 holder 或批 2 合入时一并 rebuild。 +- 批 2(鉴权与越权)继续沿用本槽位流程。 + +## 证据 + +- merge commit:`844f757`(dev worktree) +- 批 1 三个 fix commit:`11f375f`(docgov)、`c7eab1f`(chat race)、`e2b1340`(projectinstance)、`a81db16`(ctx threading) diff --git a/.doc/test-reports/2026-09-19-audit-b2-authz-hardening-test-report.md b/.doc/test-reports/2026-09-19-audit-b2-authz-hardening-test-report.md new file mode 100644 index 00000000..38fda837 --- /dev/null +++ b/.doc/test-reports/2026-09-19-audit-b2-authz-hardening-test-report.md @@ -0,0 +1,67 @@ +# 审计整改批 2(鉴权与越权)测试报告 + +## 一、 测试背景 (Background) + +2026-09-19 审计([.doc/audit/2026-09-19-system-deep-audit.md](../audit/2026-09-19-system-deep-audit.md))安全面发现:H1 dev/test legacy 兜底静默、H2 docgov 批注身份伪造与读类端点越权、H3 会话查询水平越权、H4 登录无限流、H8 compose 弱口令兜底、M8 测试 flag 名笔误。 + +## 二、 测试目标 (Test Objectives) + +- H2/H3 修复后既有鉴权测试不回退(server 包全 PASS) +- H4 限流器三态(放行/阻断/窗口过期/重置)有单测覆盖 +- H8 compose 在无 `.env` 时 `:?required` 硬拦截 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 分支 `fix/audit-b2-authz-hardening` → dev(merge `08f4e0b`) +- 层级:单元(限流器)+ server 包回归 + compose 插值验证 + +## 四、 预期结果 (Expected Results) + +- 批注/认领的 actor 一律取 principal;读类端点非项目成员 403 +- `handleGetChatTurn`/`ListChatSessions`/`GetUserContext` 跨用户 403 +- 登录窗口内第 11 次尝试 429;成功后计数清零 +- 无 `.env` 时 `docker compose config` 报 required missing + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:限流器单测 +- `go test -run TestLoginRateLimiter -v ./internal/server/` +``` +--- PASS: TestLoginRateLimiter_BlocksAfterMaxAttempts (0.00s) +--- PASS: TestLoginRateLimiter_ResetClearsWindow (0.00s) +--- PASS: TestLoginRateLimiter_WindowExpiry (0.02s) +``` +判定:PASS + +### 用例 2:server 包回归(含既有鉴权测试) +- `go test -short ./internal/server/` → `ok 13.917s` +判定:PASS(`security_p0_test.go`/`security_penetration_test.go` 等不回退) + +### 用例 3:compose required 拦截(无 .env 目录) +- `env -i docker compose -f cc-test.yml config` +``` +error while interpolating services.app.environment.ADMIN_PASSWORD: required variable ADMIN_PASSWORD is missing a value: required +error while interpolating services.app.environment.APP_DB_DSN: required variable SYSTEM_DB_PASSWORD is missing a value: required +``` +判定:PASS + +### 用例 4:全量回归 +- `go vet ./...` 零告警;`go test -short ./...` 无 FAIL +判定:PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `go vet ./...` 零告警 +- `go test -short ./...` 55 包全 PASS +- 新增 `login_rate_limit_test.go` 3 个用例 PASS + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 批 2 六个子项全部修复并合入 dev(merge `08f4e0b`),达到合入标准。 +- **待验证**:H2/H3 的越权负例(非项目成员访问 docgov 文档应 403)建议补 Playwright/HTTP 负例测试进批 3 或独立 hardening 批;当前由人工 code review + 既有鉴权测试不回退兜底。 +- H1 仅加告警未删除兜底(删除留 backlog,见审计报告开放问题)。 + +## 证据 + +- merge commit:`08f4e0b`(dev worktree) +- fix commits:`d2903a1`(H1-H3)、`2dd8f46`(H4/H8/M8) diff --git a/.doc/test-reports/2026-09-19-audit-b3-sse-events-test-report.md b/.doc/test-reports/2026-09-19-audit-b3-sse-events-test-report.md new file mode 100644 index 00000000..53aa5552 --- /dev/null +++ b/.doc/test-reports/2026-09-19-audit-b3-sse-events-test-report.md @@ -0,0 +1,61 @@ +# 审计整改批 3(SSE 与事件可靠性)测试报告 + +## 一、 测试背景 (Background) + +2026-09-19 审计 C3/H5/H6/H7:聊天 SSE 心跳 goroutine 在 handler 返回后仍可能写连接;同步 `/tasks/run` 受全局 `WriteTimeout=120s` 截断;任务 SSE 缺幂等复用;stale fencing ack 无日志;00001–00009 无 down、00012 seed DELETE 无守卫说明。 + +## 二、 测试目标 (Test Objectives) + +- 改动后 `go vet` 零告警、`go test -short` 受影响包与全量无 FAIL +- 不改已应用 goose 校验和语义(仅加注释) + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 分支 `fix/audit-b3-sse-events` → dev(merge `a2a49a6`) +- 层级:`internal/server`、`internal/events`、`internal/db` 短测 + 全量短测 + +## 四、 预期结果 (Expected Results) + +- 心跳 goroutine 在 `close(heartbeatDone)` 后 `WaitGroup` 退出 +- `/tasks/run` 在执行前把写期限延长到 75 分钟 +- 同幂等键且归属一致时 SSE 复用已有 run,不二次 `CreateAndStart` +- `ErrStaleFencing` ack 路径 `slog.Warn("events_stale_fencing_ack")` +- 00001/00012 头部注明不可改已应用文件 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:受影响包短测 +- 输入:`go test -short ./internal/server/ ./internal/events/ ./internal/db/` +- 输出: +``` +ok chat-agent-system/internal/server 13.985s +ok chat-agent-system/internal/events 0.004s +ok chat-agent-system/internal/db 0.003s +``` +- 判定:PASS + +### 用例 2:全量短测 +- 输入:`go test -short ./...` +- 输出:无 FAIL/panic +- 判定:PASS + +### 用例 3:vet +- 输入:`go vet ./internal/server/ ./internal/events/` +- 输出:零告警 +- 判定:PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `go build ./...` 通过 +- `go test -short ./...` 无 FAIL + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 批 3 已合入 dev(merge `a2a49a6`)。 +- 已应用迁移不能补 down(goose 校验和);00001–00009 的 down 缺口只记注释,不改历史 SQL。 +- 容器 rebuild 未做;下一 holder 可在批 4 后统一 rebuild。 + +## 证据 + +- merge:`a2a49a6` +- commits:`0531462`、`b65e8fb` diff --git a/.doc/test-reports/2026-09-19-audit-b4-frontend-test-report.md b/.doc/test-reports/2026-09-19-audit-b4-frontend-test-report.md new file mode 100644 index 00000000..a02a09bb --- /dev/null +++ b/.doc/test-reports/2026-09-19-audit-b4-frontend-test-report.md @@ -0,0 +1,52 @@ +# 审计整改批 4(前端)测试报告 + +## 一、 测试背景 (Background) + +审计 H9:KaTeX 失败回退把未转义公式放进占位符,还原发生在 `escapeHtml` 之后。另有未读乐观更新失败不回滚、会话切换后滚动基线不重置、Excel 全量进内存。 + +## 二、 测试目标 (Test Objectives) + +- `npm test` 全绿(含伪造占位符用例) +- `npm run build` 通过 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 分支 `fix/audit-b4-frontend` → dev(merge `d73bc79`) +- 命令:`cd web && npm test && npm run build` +- 未在浏览器复现 KaTeX catch 与万行 Excel(依赖构造输入);XSS 主路径由单测钉住 + +## 四、 预期结果 (Expected Results) + +- 伪造 `XMATHTOKENX0XENDX` 不会被还原成 HTML +- catch 分支公式经 `escapeHtml` +- 未读 POST 失败回滚本地集合 +- `sessionId` 变化时 `prevMsgCountRef` 归零 +- Excel 预览最多 1000 行并提示 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:前端测试 +- 输入:`npm test` +- 输出:`tests 406 pass`,vitest `3 files / 32 tests passed`,fail 0 +- 判定:PASS + +### 用例 2:生产构建 +- 输入:`npm run build` +- 输出:`tsc && vite build` 成功,`precompress: wrote 49 .br and 49 .gz` +- 判定:PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- node:test 406 + vitest 32,全部 PASS +- `tsc` 无错误 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 批 4 已合入 dev(`d73bc79`)。 +- 浏览器端到端未覆盖 Excel 截断与未读回滚;需要时用真实文件和断网点测。 +- 容器仍是合入前二进制,前端 dist 由 Go 服务托管时需 rebuild 才进 `:8080`。 + +## 证据 + +- merge:`d73bc79` +- commit:`6c28fca` diff --git a/.doc/test-reports/2026-09-19-initial-load-u1-baseline-test-report.md b/.doc/test-reports/2026-09-19-initial-load-u1-baseline-test-report.md new file mode 100644 index 00000000..66ef722d --- /dev/null +++ b/.doc/test-reports/2026-09-19-initial-load-u1-baseline-test-report.md @@ -0,0 +1,72 @@ +# 首次加载 U1 登录态基线 测试报告 + +## 一、 测试背景 (Background) + +[首次加载方案](../remediation/2026-09-17-initial-load-performance-plan.md) 的 U1 要求登录态瀑布,不能用未登录测量代替。此前只完成未登录诊断。本轮在 `fix/audit-remainder-closeout` 补登录探针,核对 U4 成员扇出与 U3 重模块是否出现在首屏。 + +## 二、 测试目标 (Test Objectives) + +- 登录后记录工作台就绪时间、资源摘要、成员 GET 与会话列表 GET。 +- 证据去掉查询串,不保存令牌和正文。 +- 不把 n=1 或本机延迟写成预算达标。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;浏览器:`mcr.microsoft.com/playwright:v1.63.0-noble` headless Chromium,`--network host`。 +- 入口 A:已部署 dev `http://127.0.0.1:8080`(容器内旧包,schema 52)。 +- 入口 B:本仓 Vite `http://127.0.0.1:3100`,API 代理到 8080。 +- 账号:容器环境变量中的管理员,不写入报告。 +- 用例:`web/e2e/initial-load-performance.spec.ts`。每次新 context,不发送聊天消息。 +- 重建后补测:8080 慢网 n=20 见第五节。仍未覆盖:热缓存对照、字体黑洞单独对照、3100/8081、U7 的 Vite 与依赖一起启动。 + +## 四、 预期结果 (Expected Results) + +- 当前源码:登录成员同步只打当前项目,会话列表每个查询键成功后不再被成员更新重拉。 +- 已部署旧包:预期仍按项目数扇出。该结果只说明容器落后,不说明源码回退。 +- 匿名首屏资源路径不应包含 katex、mermaid、docx。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +- **测试输入 (Input)**:`RUN_INITIAL_LOAD_BASELINE=1`,Vite `INITIAL_LOAD_SAMPLES=20` 且 `EXPECT_INIT_MEMBER_PROJECTS=1`;8080 同样探针 n=1。 +- **实际输出 (Actual Output)**: + +```text +vite-3100.json samples 20 +member projects 1 1 +session gets 1 1 +ready ms 118 152 +anon visible ms 148 194 +heavy [] +anonymous summary requestCount 170 transferBytes 6918643 jsTransferBytes 2349690 jsCount 8 + +deployed-8080.json samples 1 +memberGetCount 704 +distinctMemberProjects 704 +sessionListGetCount 30 +anonymous jsTransferBytes 626281 path /assets/index-DhyNmxJE.js +workbenchReadyMs 181 +``` + +- **评价判定 (Evaluation / Verdict)**:PASS(探针与 U4 源码行为)。Vite 20/20 为成员项目 1、会话列表 1,重模块未进首屏。FAIL(关账):8080 旧包仍 704/30;慢网与重启未测;Vite 170 请求 / 6.9 MB 是开发模式,不套生产体积预算。 +- **性能与耗时 (Duration & Latency)**:20 次 Vite 登录工作台就绪 118–152 ms(本机、无带宽限制,不是预算验收)。重建后 8080 慢网 n=20、CPU 4 倍、阻断 Google 字体:10 Mbps 登录表单可见 p75 559 ms(p95 570);2 Mbps p75 1279 ms(p95 1289)。登录点击到工作台 p75 约 334–340 ms。这两项低于拟定 1.5 s / 3 s,但是本机 CDP 抽样。仅 app 容器重启 5 次到 `/health` 为 0.433–0.463 s,不是完整 U7 矩阵。 + +证据:[assets/2026-09-19-initial-load-u1/](assets/2026-09-19-initial-load-u1/)。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +相关单元测试: + +```text +✔ freeform document actions live in one hook +✔ redactResourceUrl drops query and origin +✔ summarizeResources flags deferred heavy chunks +✔ countInitRequests separates member fanout from session lists +✔ percentile uses nearest-rank and stays null on empty input +ℹ pass 5 fail 0 +``` + +Playwright:上述基线 1 passed。本报告不把未改动的 `go test ./...` 写成这次探针的验收。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +登录态基线已经能回答「耗时之后请求打到哪里」:已部署容器的主成本是 704 次成员 GET,当前源码已收成 1 次,但要占槽重建后在 8080 复测才能关 U4。U6 不在这次加索引:测到的瓶颈是未部署的扇出,不是一条慢 SQL。下一步是慢网/CPU 样本、U7 隔离重启,以及 dev 容器重建使 schema 升到 00053。 diff --git a/.doc/test-reports/2026-09-19-initial-load-u7-restart-test-report.md b/.doc/test-reports/2026-09-19-initial-load-u7-restart-test-report.md new file mode 100644 index 00000000..c5df6118 --- /dev/null +++ b/.doc/test-reports/2026-09-19-initial-load-u7-restart-test-report.md @@ -0,0 +1,66 @@ +# 首次加载 U7 重启矩阵测试报告 + +## 一、 测试背景 (Background) + +[方案 U7](../remediation/2026-09-17-initial-load-performance-plan.md) 要求分别记录 Vite 冷/热启动、仅 app 重启、以及数据库和应用一起启动,直到 ready 与首次页面。此前只有 app 容器重启到 `/health` 的 0.433–0.463 秒,没有 Vite,也没有 postgres 与 app 一起重启。 + +## 二、 测试目标 (Test Objectives) + +- 无待执行迁移、数据库已在卷里时,app 与 postgres 一起重启后 `/ready` 的耗时。 +- Vite 保留依赖缓存与清空 `node_modules/.vite` 之后,从进程启动到入口模块图取完的耗时。 +- 不把监听成功写成登录表单已经可见。无头浏览器打不开时明确写未测。 +- 达不到「较历史十几秒基线下降 50%」就不关闭 U7。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:linux。分支:`fix/audit-remainder-closeout`。计时开始时 commit `6bd338e`。 +- dev:`docker compose -f docker-compose.dev.yml restart system-db app`,n=3。探测 `shuimu-context-pg-dev` 的 Docker health、`GET /api/v1/health`、`GET /ready`、`GET /`。 +- Vite:`npx vite --host 127.0.0.1 --port 3100 --strictPort`。热启动保留 `.vite`,冷启动先删除。先记 HTML 200,再用无浏览器的模块图抓取从 `/src/main.tsx` 跟完 import(314 个模块,队列清空)。 +- 未重启 test/main,未 rebuild 镜像,未晋升。 +- 数字:`.doc/test-reports/assets/2026-09-19-initial-load-u7/timings.json`。 + +## 四、 预期结果 (Expected Results) + +- `/ready` p95 ≤ 3 秒(方案对「无迁移且 DB 已就绪」的 app 重启预算;本次是 postgres 与 app 一起重启,单列,不拿去承诺整机开机)。 +- Vite 冷启动若明显慢于热启动,才考虑 `server.warmup`。总耗时没有下降就不能把等待挪到预热里宣称消失。 +- 8081 与浏览器登录表单不在本次能测范围里就保持未关。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 postgres + app 一起重启 + +- **测试输入**:连续 3 次 `restart system-db app`。卷上已有 schema 53,没有新迁移。 +- **实际输出**:`/api/v1/health` 与 `/ready` 均为 200,`status=ok schema_version=53 schema_ready=true`。耗时 1.027 / 1.178 / 1.005 秒。Docker 把数据库标成 healthy 是 5.490 / 5.633 / 5.450 秒,间隔是 healthcheck 的 5 秒,晚于 HTTP ready。重启后首页 HTML 200,TTFB < 2 ms。结束后两个容器都是 healthy。 +- **评价判定**:PASS(这条预算)。p95 约 1.2 秒,低于 3 秒。不外推到宿主机整机重启,也不覆盖 test 的 8081。 +- **性能与耗时**:见上。三次合计约 16 秒。 + +### 5.2 Vite 监听与模块图 + +- **测试输入**:热启动 n=3(只测 HTML)与 n=2(模块图);冷依赖缓存 n=2。 +- **实际输出**:Vite 日志 `ready in` 71–76 ms。从拉起到 HTML 200:热 0.342–0.344 秒,冷 0.345–0.352 秒。314 个模块抓完:热 1.069–1.117 秒,冷 1.433–1.453 秒。从进程启动到图抓完:热 1.29–1.43 秒,冷 1.71–1.73 秒。 +- **评价判定**:冷缓存不是主因(大约多 0.35 秒)。不改 `vite.config.ts` 做 warmup,避免只把同样的等待挪到启动阶段。历史诊断里的 207 请求 / 12 秒和「重启后十几秒」这次没有复现。 +- **性能与耗时**:见上。 + +### 5.3 登录表单可见 + +- **测试输入**:Playwright Chromium。 +- **实际输出**:`chrome-headless-shell` 与完整 `chrome` 都缺 `libatk-1.0.so.0` 等系统库,进程退出码 127。没有登录框可见时间,也没有请求瀑布。 +- **评价判定**:未测。U7 因此不关。不能写成首访已经比基线快 50%。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +本次是重启计时,不是 `go test`。相关回归: + +```text +go test ./internal/squad/ -count=1 -run TestRDevQualityGates +# ok +``` + +该测试与 U7 无关,是同一轮里评审门禁误伤的回归,不拿它代替重启证据。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- postgres 与 app 一起重启到 `/ready` 约 1.0–1.2 秒,满足 3 秒预算。Docker health 的 5 秒是探测间隔,不是数据库没起来。 +- Vite 冷/热差约 0.35 秒。不加 warmup。 +- 登录表单、8081、以及相对「十几秒」基线的 50% 下降都没有证据。U7 保持未关。 +- 测完后 dev 的 app 与 postgres 均为 healthy,Vite 仍听在 `127.0.0.1:3100`。未晋升 test/main。 diff --git a/.doc/test-reports/2026-09-19-rdev-p3-real-pi-test-report.md b/.doc/test-reports/2026-09-19-rdev-p3-real-pi-test-report.md new file mode 100644 index 00000000..91eb87a7 --- /dev/null +++ b/.doc/test-reports/2026-09-19-rdev-p3-real-pi-test-report.md @@ -0,0 +1,79 @@ +# 研发工作流 P3 三样本真 PI 测试报告 + +## 一、 测试背景 (Background) + +设计计划 P3 要求在真实产品 PI Agent 与 MiniMax 上抽三份样本:`rd_proposal`(document)、`rd_planning_review`(review_minutes),以及 `rd_sw_plan` 在激活规划期关闭(N/A)。此前只有 `rd_planning_review` 与 `rd_design_vv` 的抽样,没有这组三样本。 + +## 二、 测试目标 (Test Objectives) + +- 主流程用 stub 跑完并批准后,才能激活 rdev。 +- `rd_proposal`、`rd_planning_review` 必须走 `backend=pi-agent`,不能用 stub 充当验收。 +- `rd_sw_plan` 在规划里保持关闭。 +- 记录真实状态、字数、是否仍含模板占位句,以及评审质量门禁结果。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:linux。分支:`fix/audit-remainder-closeout`。 +- 入口:dev `http://127.0.0.1:8080`,容器 `shuimu-agent-app-dev`、`shuimu-pi-agent-dev`(MiniMax-M2.7-highspeed)。 +- 项目 `6533`。主流程只启用 `bd_intake`(旧键 `intake` 已不再出现在默认规划里),stub 生成后跨用户审批准,阶段推进到 `completed`,再保存并激活 rdev。 +- rdev 只启用 `rd_proposal` 与 `rd_planning_review`。脚本:`.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/run_p3_real_pi.py`。 +- 未改 nightly「PI 三工作流」门禁,也未把 rdev 加进 `.github/workflows/nightly-real-pi.yml`。PR CI 仍不跑真 PI。 + +## 四、 预期结果 (Expected Results) + +- 激活回执里 `rd_sw_plan.enabled=false`,两个抽样任务为 true。 +- `rd_proposal` 终态为 `completed` 或 `pending_author_confirmation`,正文不含「按本阶段专业结构写」。 +- `rd_planning_review` 同样由真 PI 写出,并含「评审范围与对象 / 问题与决议 / 评审结论」。质量门禁要求「问题与决议」表的每一数据行都有问题、决议、责任人、期限。 +- 建议书批准后才能离开 `RD01_planning`。未批准时下一阶段任务应被拒绝。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 `rd_sw_plan` 规划期关闭 + +- **测试输入**:rdev 规划只保留 `rd_proposal`、`rd_planning_review`。 +- **实际输出**:`[13:57:41] plan flags proposal=True review=True sw_plan=False`,随后 `activate rdev http=200`。 +- **评价判定**:PASS。不适用语义是规划期关闭,不是再生成一份不适用页。 +- **性能与耗时**:激活本身小于 1 秒。 + +### 5.2 `rd_proposal`(document) + +- **测试输入**:项目 6533,`backend=pi-agent`,材料为无创神经电刺激器立项简报。 +- **实际输出**:`[14:04:51] done rd_proposal status=completed elapsed=430s`,`proposal chars=11550 placeholder=False`。终稿 [rd_proposal-final.md](./assets/2026-09-19-rdev-p3-real-pi/rd_proposal-final.md)。章节是模型自拟的「产品定义与范围 / 风险清单」等,不是模板里的「项目背景与立项依据」「产品定义与范围」「主要风险与对策概览」。 +- **评价判定**:生成 PASS(真 PI、已完成、无占位句)。模板标题对齐 FAIL,不把这份终稿写成模板符合。 +- **性能与耗时**:430 秒。 + +### 5.3 未批准时不能进入评审阶段 + +- **测试输入**:建议书仍是 `drafting` 时推进 `RD01_planning`。 +- **实际输出**:`advance RD01_planning http=409`,正文为 `STAGE_GATE_NOT_READY`。接着启动 `rd_planning_review` 得到 `403 DOC_TASK_NOT_IN_ACTIVE_STAGE`。 +- **评价判定**:PASS(负例符合阶段门禁)。随后对 `doc_proj6533_rd_proposal` 认领、送审、审核、批准均为 http 200,再推进 `RD01_planning` 为 200。 +- **性能与耗时**:审批链约 5 秒。 + +### 5.4 `rd_planning_review`(review_minutes) + +- **测试输入**:阶段已到 `RD02_planning_review` 后,`backend=pi-agent`。 +- **实际输出**:约 580 秒后运行状态变为 `quality_failed`。终稿 13225 字,含「## 评审范围与对象」「## 问题与决议」「## 评审结论」,不含占位句。工作区 `manifest.json` 的质量报告:`passed=false`,`score=20`,四次「问题行缺少问题、决议、责任人或期限」。主表六列是齐的;同级标题下还有一张「法规/标准」表,那些行没有责任人与期限,被算进问题行。修复轮 `repair_1`、`repair_2` 之后仍是这个结果。终稿 [rd_planning_review-final.md](./assets/2026-09-19-rdev-p3-real-pi/rd_planning_review-final.md)。 +- **评价判定**:真 PI 抽样已发生,质量门禁 FAIL。不能写成评审样本通过。 +- **性能与耗时**:从 `14:11:29` 启动到轮询见到 `quality_failed` 为 580 秒。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +本报告的验收数据来自上述真 PI 控制台,不是 `go test` 的 stub。相关结构测试: + +```text +cd web && npx tsx --test src/components/layout/WorkbenchView.test.ts src/components/chat/ChatColumn.test.ts src/components/doc/DocumentPane.test.ts +# 3 pass +``` + +未把 `go test -short ./...` 的既有 `TestArchitectureBoundaries` 失败算进这次 P3 判定。nightly 真 PI 工作流没有修改。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 三样本都在 dev 真 PI 上跑过:软件计划 N/A 关闭 PASS;建议书生成完成但未对齐模板标题;评审记录写出了规定章节,质量门禁 20 分,FAIL。 +- 因此 P3 还不能标成干净通过,也不能据此晋升 test/main。 +- 轮询原先不把 `quality_failed` 当终态,会空转到超时。脚本已把 `quality_failed` 与 `finalizing_failed` 视为结束。 +- 红线未动:不写真实软件仓、不合入 FAV、不物理删除 D7。 + +## 八、 后续(不改写上面的当次判定) + +2026-09-19 同一分支补了门禁:`markdownTable` 在第一张表结束后停止,不再把「问题与决议」后面的法规对照表算成缺责任人的问题行。`TestRDevQualityGates` 用保存的终稿复读,不再出现「问题行缺少」。这不是新的真 PI,建议书标题对齐仍然失败。 diff --git a/.doc/test-reports/2026-09-19-remove-control-center-test-report.md b/.doc/test-reports/2026-09-19-remove-control-center-test-report.md new file mode 100644 index 00000000..0ef39355 --- /dev/null +++ b/.doc/test-reports/2026-09-19-remove-control-center-test-report.md @@ -0,0 +1,64 @@ +# 移除 Control Center 测试报告 + +## 一、 测试背景 (Background) + +项目进度管理已由独立项目承担。本仓库曾合入 Control Center,需要删除其代码、部署、规范和专属文档,并确认产品智能体系统仍能编译、测试和启动。不恢复已下线的 `engineering-dashboard`。 + +## 二、 测试目标 (Test Objectives) + +- 仓库内不再存在 Control Center 源码、compose、脚本和专属文档。 +- `go vet`、`go build`、`go test -short`、`internal/archtest`、产品前端 `npm run build` 全部通过。 +- 本地 `dev` 容器重建后健康检查为 200。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux。分支 `fix/remove-control-center`,基线为本地 `dev@2641045`。 +- 层级:删除后的编译与短测;模块边界契约测试;产品前端构建。容器冒烟在合入本地 `dev` 之后执行。 +- 证据:`.doc/test-reports/assets/2026-09-19-remove-control-center/verify.txt`。 + +## 四、 预期结果 (Expected Results) + +- `cmd/control-center`、`internal/controlcenter`、`control-center/`、`deploy/control-center` 不存在。 +- 上述命令退出码均为 0。 +- `GET http://127.0.0.1:8080/health` 返回 `env=dev`、`schema_version=52`。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 源码与运行时 + +- **输入**:删除 Control Center 目录后检查路径与端口。 +- **实际输出**:源码目录已不在索引中。`control-center` 进程已停。`docker rm -f control-center-db` 与 `docker volume rm control-center_cc-db-data` 成功。 +- **判定**:PASS。 +- **耗时**:容器删除 < 1s。 + +### 5.2 编译与测试 + +- **输入**:`go vet ./...`、`go build ./...`、`go test -short -count=1 ./...`、`go test -count=1 ./internal/archtest/`、`cd web && npm run build`。 +- **实际输出**(摘自 `verify.txt`): + +```text +vet=0 +build=0 +short=0 +arch=0 +web=0 +``` + +- **判定**:PASS。 +- **耗时**:整段验证约 33s。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +`go test -short -count=1 ./...` 退出码 0,无 FAIL。`internal/archtest` 单独再跑一次,退出码 0。完整输出见 `verify.txt`。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +达到合入本地 `dev` 的标准。本仓库不再包含 Control Center。不 push。 + +合入后 `./scripts/env-service.sh dev rebuild` 退出码 0。`GET http://127.0.0.1:8080/health` 返回 200: + +```text +{"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} +``` + +复查残留:`control-center/` 未跟踪目录已删除;本地分支 `feature/control-center-server` 已删除(`git branch -D`,原 tip `55fa8dd`)。`go build ./...` 退出码 0。`origin/feature/control-center-server` 未动。历史文档中的名称提及按用户要求保留。 diff --git a/.doc/test-reports/2026-09-20-dev-deep-test-and-test-promotion-test-report.md b/.doc/test-reports/2026-09-20-dev-deep-test-and-test-promotion-test-report.md new file mode 100644 index 00000000..78b975bb --- /dev/null +++ b/.doc/test-reports/2026-09-20-dev-deep-test-and-test-promotion-test-report.md @@ -0,0 +1,124 @@ +# 开发分支全量深度测试与 test 环境晋升测试报告 + +## 一、 测试背景 (Background) + +本轮测试背景为收口并合并完成的开发子分支 `fix/audit-remainder-closeout` 至 `dev` 分支,对系统执行全量深度测试(包含后端 Go 静态代码与架构边界检查、短单元测试、前端测试与生产构建、数据库迁移与集成测试、容器重新构建与真实运行时端点冒烟),并在验证全部通过后,将 `dev` 分支晋升合并至 `test` 分支并重启测试环境服务,供用户进行在线实测。 + +## 二、 测试目标 (Test Objectives) + +1. **子分支合并**:将已完成的 `fix/audit-remainder-closeout` 分支干净合入 `dev` 分支(使用 `--no-ff`)。 +2. **后端全量静态与单元测试**:`go vet ./...` 零告警,`go test -short ./...` 全部通过,架构边界检查 `TestArchitectureBoundaries` 无回归。 +3. **前端完整性测试与构建**:`cd web && npm test && npm run build` 全部 PASS,产物打包与预压缩(brotli/gzip)成功。 +4. **数据库与权限集成测试**:在 PostgreSQL 5433 环境下运行关键 E2E 治理、会话鉴权越权防范、文档修订等测试套件。 +5. **容器构建与运行态验证**:开发环境(`dev`)容器服务重建(`app`、`events`、`pi-agent`),验证服务健康、安全响应头(CSP、nosniff、X-Frame-Options、Referrer-Policy)及认证链路正常。 +6. **环境晋升与重启**:将 `dev` 合入 `test` 分支,重建 `test` 环境容器并验证服务健康与 schema 53 对齐。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 维度 | 配置/参数 | +|------|-----------| +| 操作系统 | Linux (x86_64) | +| Go 版本 | Go 1.22+ | +| Node/NPM | Node.js 20+ / Vite | +| 开发环境 (dev) | HTTP: 8080, DB: 5433 (PostgreSQL 16 + pgvector), Events: 8088, Pi-Agent: 18865 | +| 测试环境 (test) | HTTP: 8081, DB: 5434 (PostgreSQL 16 + pgvector), Events: 8089, Pi-Agent: 18866 | +| 共享知识库 (shared) | DB: 5432 (PostgreSQL 16 + pgvector) | +| 数据库迁移版本 | Goose Schema 53 (`00053_2026-09-19-document-content-projection.sql`) | + +## 四、 预期结果 (Expected Results) + +1. `git merge --no-ff fix/audit-remainder-closeout` 顺利完成无代码冲突。 +2. `go vet ./...` 返回 0;`go test -short ./...` 全部包 PASS。 +3. `cd web && npm test && npm run build` 零报错完成,产出预压缩文件。 +4. `TestE2E_WorkflowGovernance` 及各项越权负例测试(`TestListChatSessions_CrossUserIs403` 等)全部 PASS。 +5. `./scripts/env-service.sh dev rebuild` 成功,`curl http://127.0.0.1:8080/api/v1/health` 输出 `schema_version=53`、`status=ok`。 +6. 合并至 `test` 分支后重建 `test` 服务,`curl http://127.0.0.1:8081/api/v1/health` 正常输出 `schema_version=53`。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 子分支合并结果 +- **测试输入 (Input)**:`git merge --no-ff fix/audit-remainder-closeout -m "..."` +- **实际输出 (Actual Output)**: + ```text + Merge made by the 'ort' strategy. + ... + 36 files changed, 543 insertions(+), 189 deletions(-) + ``` +- **评价判定 (Evaluation / Verdict)**:PASS。合并成功,保留完整 commit 历史。 + +### 5.2 后端静态质量与架构边界测试 +- **测试输入 (Input)**:`go vet ./... && go test -short ./...` +- **实际输出 (Actual Output)**: + - `go vet ./...`:零告警输出,退出码 0。 + - `TestArchitectureBoundaries`:在将合法的新增依赖(`intent` -> `squad`,`squad`/`template` -> `workflowdef`)补充至 `.doc/system-design/module-boundaries.yaml` 许可清单后,全量短测通过: + ```text + === RUN TestArchitectureBoundaries + --- PASS: TestArchitectureBoundaries (0.19s) + PASS + ok chat-agent-system/internal/archtest 0.198s + ... 全量包 PASS ... + ``` +- **评价判定 (Evaluation / Verdict)**:PASS。 + +### 5.3 前端测试与构建 +- **测试输入 (Input)**:`cd web && npm test && npm run build` +- **实际输出 (Actual Output)**: + - 单元测试套件全部通过(包含 `ChatColumn.test.ts`、`DocumentPane.test.ts`、`WorkbenchView.test.ts`、`initFanout.test.ts`、`workflowPrerequisite.test.ts` 等)。 + - Vite 打包成功,入口 bundle `index-C5YTF8ik.js` 约 749 kB(br 173 kB),完成 66 个静态产物的 brotli 与 gzip 预压缩。 +- **评价判定 (Evaluation / Verdict)**:PASS。 + +### 5.4 数据库与权限集成测试 +- **测试输入 (Input)**: + `TEST_DATABASE_URL="postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" go test -v ./internal/server/... -run 'TestE2E_WorkflowGovernance|TestDocgovCommentAuditNonMember|TestAuthMe_|TestListChatSessions_CrossUserIs403|TestDocRevision_'` +- **实际输出 (Actual Output)**: + ```text + === RUN TestDocRevision_UnauthenticatedAndUnknownOrphan + --- PASS: TestDocRevision_UnauthenticatedAndUnknownOrphan (0.12s) + === RUN TestDocRevision_ControlledDocumentRoutes + --- PASS: TestDocRevision_ControlledDocumentRoutes (0.12s) + === RUN TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit + --- PASS: TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit (0.12s) + === RUN TestAuthMe_LogoutNextRequestIs401 + --- PASS: TestAuthMe_LogoutNextRequestIs401 (0.13s) + === RUN TestListChatSessions_CrossUserIs403 + --- PASS: TestListChatSessions_CrossUserIs403 (0.50s) + === RUN TestE2E_WorkflowGovernance_CompleteAndReplan + --- PASS: TestE2E_WorkflowGovernance_CompleteAndReplan (0.23s) + PASS + ok chat-agent-system/internal/server 2.202s + ``` +- **评价判定 (Evaluation / Verdict)**:PASS。 + +### 5.5 开发环境容器重建与运行时冒烟 +- **测试输入 (Input)**:`./scripts/env-service.sh dev rebuild` +- **实际输出 (Actual Output)**: + - 容器更新:`shuimu-context-pg-dev` (healthy), `shuimu-pi-agent-dev` (healthy), `shuimu-agent-app-dev` (healthy), `shuimu-agent-events-dev` (healthy)。 + - 健康检查 (`GET /api/v1/health`): + ```json + {"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":53,"schema_ready":true,"task_store_ready":true} + ``` + - 安全响应头 (`curl -I http://127.0.0.1:8080/api/v1/health`): + - `X-Content-Type-Options: nosniff` + - `X-Frame-Options: DENY` + - `Referrer-Policy: strict-origin-when-cross-origin` + - `Content-Security-Policy: default-src 'self'; ...` + - 登录与认证端点: + - 未认证请求 `/api/v1/workflows/catalog` 返回 `401 ERR_UNAUTHENTICATED`。 + - 管理员登录 `POST /api/v1/auth/login` 成功签发 Bearer Token。 + - 携带 Token 请求 `/api/v1/auth/me` 及 `/api/v1/workflows/catalog` 均返回 200 正常数据。 +- **评价判定 (Evaluation / Verdict)**:PASS。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 测试类型 | 执行命令 | 耗时 | 结果 | +|----------|----------|------|------| +| Go 静态检查 | `go vet ./...` | ~3s | PASS | +| Go 短单元测试 | `go test -short ./...` | ~16s | 46 个包全部 PASS | +| 前端测试与构建 | `cd web && npm test && npm run build` | ~18s | PASS (生成 br/gz 压缩包) | +| 治理与权限 PG 集成 | `TEST_DATABASE_URL=... go test ...` | ~2.2s | 全部测试用例 PASS | +| 运行时服务健康与安全头 | `curl -sf http://127.0.0.1:8080/...` | <1s | PASS (`schema_version=53`) | + +## 七、 测试结论与后续建议 (Conclusion & Next Steps) + +1. **结论**:开发分支(`dev`)已完整合入审计剩余修复项、工作台模块化拆分、rdev 文档匹配及 Schema 53 迁移,所有深度测试全绿,具备晋升至测试环境(`test`)的标准。 +2. **晋升操作**:执行 `dev` 向 `test` 分支的 `--no-ff` 合并,并触发 `test` 环境服务重建(`./scripts/env-service.sh test rebuild`),供在线实测。 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-initial-load-perf-u2/after-curl-nocache.txt b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/after-curl-nocache.txt new file mode 100644 index 00000000..935c9d81 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/after-curl-nocache.txt @@ -0,0 +1,115 @@ +=== AFTER nocache rebuild curl 2026-09-17T08:23:03Z === +--- HTML headers --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Cache-Control: no-cache +Content-Length: 715 +Content-Type: text/html; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 08:21:43 GMT +Vary: Accept-Encoding +Date: Thu, 17 Sep 2026 08:23:03 GMT + +--- HTML body (identity) --- + + + + + + + + 水木东方 · 医疗器械现代 AI 协同工作台 + + + + + +
+JS=/assets/index-Bu0CqZB_.js +--- JS Accept-Encoding: gzip, br --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Cache-Control: public, max-age=31536000, immutable +Content-Encoding: br +Content-Type: text/javascript; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 08:21:43 GMT +Vary: Accept-Encoding +Date: Thu, 17 Sep 2026 08:23:03 GMT + +--- JS gzip only --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Cache-Control: public, max-age=31536000, immutable +Content-Encoding: gzip +Content-Type: text/javascript; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 08:21:43 GMT +Vary: Accept-Encoding +Date: Thu, 17 Sep 2026 08:23:03 GMT + +--- JS identity --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Cache-Control: public, max-age=31536000, immutable +Content-Length: 2820055 +Content-Type: text/javascript; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 08:21:43 GMT +Vary: Accept-Encoding +Date: Thu, 17 Sep 2026 08:23:03 GMT + +--- missing asset --- +HTTP/1.1 404 Not Found +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff +Date: Thu, 17 Sep 2026 08:23:03 GMT +Content-Length: 19 + +body: 404 page not found + +--- font --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Cache-Control: public, max-age=31536000, immutable +Content-Length: 48256 +Content-Type: font/woff2 +Last-Modified: Thu, 17 Sep 2026 08:21:42 GMT +Date: Thu, 17 Sep 2026 08:23:03 GMT + +--- spa --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Cache-Control: no-cache +Content-Length: 715 +Content-Type: text/html; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 08:21:43 GMT +Vary: Accept-Encoding +Date: Thu, 17 Sep 2026 08:23:03 GMT + +--- api --- +HTTP/1.1 401 Unauthorized +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Type: application/json; charset=utf-8 +Date: Thu, 17 Sep 2026 08:23:03 GMT +Content-Length: 62 + +--- google fonts domain in html? --- +NONE diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/after-curl.txt b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/after-curl.txt new file mode 100644 index 00000000..196c475b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/after-curl.txt @@ -0,0 +1,119 @@ +=== AFTER rebuild curl === +--- HTML --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Length: 892 +Content-Type: text/html; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 07:00:36 GMT +Date: Thu, 17 Sep 2026 08:19:24 GMT + +--- HTML body head --- + + + + + + + + 水木东方 · 医疗器械现代 AI 协同工作台 + + + + + + + +
+ + +JS=/assets/index-BLnmtk_3.js +--- JS br --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Length: 2820055 +Content-Type: text/javascript; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 07:00:36 GMT +Date: Thu, 17 Sep 2026 08:19:24 GMT + +--- JS gzip only --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Length: 2820055 +Content-Type: text/javascript; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 07:00:36 GMT +Date: Thu, 17 Sep 2026 08:19:24 GMT + +--- JS identity --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Length: 2820055 +Content-Type: text/javascript; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 07:00:36 GMT +Date: Thu, 17 Sep 2026 08:19:24 GMT + +--- JS gzip;q=0 --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Length: 2820055 +Content-Type: text/javascript; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 07:00:36 GMT +Date: Thu, 17 Sep 2026 08:19:24 GMT + +--- missing asset --- +HTTP/1.1 200 OK +Accept-Ranges: bytes +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Length: 892 +Content-Type: text/html; charset=utf-8 +Last-Modified: Thu, 17 Sep 2026 07:00:36 GMT +Date: Thu, 17 Sep 2026 08:19:24 GMT + +--- missing body --- + + + + + + { + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); + const rows = []; + + async function measure(label, { blockGoogleFonts = false } = {}) { + const context = await browser.newContext(); + const page = await context.newPage(); + const externalFontHits = []; + page.on('request', (req) => { + const u = req.url(); + if (/fonts\.(googleapis|gstatic)\.com/.test(u)) externalFontHits.push(u.split('?')[0]); + }); + if (blockGoogleFonts) { + await page.route(/fonts\.(googleapis|gstatic)\.com/, (r) => r.abort()); + } + const started = Date.now(); + let error = null; + try { + await page.goto('http://127.0.0.1:8080/', { waitUntil: 'domcontentloaded', timeout: 45000 }); + await page.locator('input').first().waitFor({ state: 'visible', timeout: 15000 }); + } catch (e) { + error = String(e.message || e).slice(0, 200); + } + const visibleMs = Date.now() - started; + await page.waitForTimeout(800); + const perf = await page.evaluate(() => ({ + paint: performance.getEntriesByType('paint').map((p) => ({ name: p.name, ms: p.startTime })), + resources: performance.getEntriesByType('resource').map((r) => ({ + url: r.name.split('?')[0], + bytes: r.transferSize, + decoded: r.decodedBodySize, + ms: Math.round(r.duration), + })), + })); + const html = await page.content(); + const row = { + label, + visibleMs, + error, + externalFontHits, + hasGoogleInDOM: /fonts\.googleapis\.com|fonts\.gstatic\.com/.test(html), + paint: perf.paint, + requests: perf.resources.length, + transferBytes: perf.resources.reduce((s, r) => s + (r.bytes || 0), 0), + jsAssets: perf.resources.filter((r) => r.url.includes('/assets/') && r.url.endsWith('.js')), + fontAssets: perf.resources.filter((r) => r.url.includes('/fonts/')), + }; + rows.push(row); + console.log(JSON.stringify(row)); + await context.close(); + } + + await measure('login-cold'); + await measure('login-font-domain-blocked', { blockGoogleFonts: true }); + fs.writeFileSync('/out/browser-u2.json', JSON.stringify(rows, null, 2)); + await browser.close(); +})(); diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/browser-u2.json b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/browser-u2.json new file mode 100644 index 00000000..b4d5370e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/browser-u2.json @@ -0,0 +1,72 @@ +[ + { + "label": "login-cold", + "visibleMs": 205, + "error": null, + "externalFontHits": [], + "hasGoogleInDOM": false, + "paint": [ + { + "name": "first-paint", + "ms": 32 + }, + { + "name": "first-contentful-paint", + "ms": 204 + } + ], + "requests": 4, + "transferBytes": 681964, + "jsAssets": [ + { + "url": "http://127.0.0.1:8080/assets/index-Bu0CqZB_.js", + "bytes": 624198, + "decoded": 2820055, + "ms": 13 + } + ], + "fontAssets": [ + { + "url": "http://127.0.0.1:8080/fonts/inter-v20-latin.woff2", + "bytes": 48556, + "decoded": 48256, + "ms": 5 + } + ] + }, + { + "label": "login-font-domain-blocked", + "visibleMs": 213, + "error": null, + "externalFontHits": [], + "hasGoogleInDOM": false, + "paint": [ + { + "name": "first-paint", + "ms": 32 + }, + { + "name": "first-contentful-paint", + "ms": 216 + } + ], + "requests": 4, + "transferBytes": 681964, + "jsAssets": [ + { + "url": "http://127.0.0.1:8080/assets/index-Bu0CqZB_.js", + "bytes": 624198, + "decoded": 2820055, + "ms": 14 + } + ], + "fontAssets": [ + { + "url": "http://127.0.0.1:8080/fonts/inter-v20-latin.woff2", + "bytes": 48556, + "decoded": 48256, + "ms": 3 + } + ] + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/rebuild-log.txt b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/rebuild-log.txt new file mode 100644 index 00000000..9499651c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/rebuild-log.txt @@ -0,0 +1,478 @@ +==> [dev] 重建镜像并重建容器... + Image agent-dev-events Building + Image shuimu-pi-agent:dev Building + Image agent-dev-app Building +#1 [internal] load local bake definitions +#1 reading from stdin 1.44kB 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 [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 ... + +#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 6/11] COPY deploy/pi-agent/extensions/ /app/extensions/ +#12 CACHED + +#13 [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/* +#13 CACHED + +#14 [pi-agent 3/11] RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 --omit=dev +#14 CACHED + +#15 [pi-agent 4/11] RUN pi --version +#15 CACHED + +#16 [pi-agent 7/11] COPY deploy/pi-agent/runner.mjs /app/runner.mjs +#16 CACHED + +#17 [pi-agent 9/11] COPY deploy/pi-agent/settle-policy.mjs /app/settle-policy.mjs +#17 CACHED + +#18 [pi-agent 10/11] COPY deploy/pi-agent/entrypoint.sh /app/entrypoint.sh +#18 CACHED + +#19 [pi-agent 5/11] WORKDIR /app +#19 CACHED + +#20 [pi-agent 8/11] COPY deploy/pi-agent/result-v1.mjs /app/result-v1.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:612907871e23ecbd43acd81e1be6fdce19d23acd16d7c1b659e0345a17497d4a done +#22 exporting manifest list sha256:f6f48df4362784021b66f99a181a20e455087c5b9f016be85f6147426dcf520d 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 + +#7 [events internal] load metadata for docker.io/library/node:22-bookworm +#7 DONE 0.4s + +#24 [app internal] load metadata for docker.io/library/golang:1.23-bookworm +#24 DONE 2.2s + +#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 [app 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: 105.56MB 0.8s done +#28 DONE 0.8s + +#29 [events go-builder 2/7] WORKDIR /src +#29 CACHED + +#30 [events go-builder 3/7] COPY go.mod go.sum ./ +#30 CACHED + +#31 [app go-builder 4/7] RUN go mod download +#31 CACHED + +#32 [app web-builder 2/6] WORKDIR /web +#32 CACHED + +#33 [app web-builder 3/6] COPY web/package.json web/package-lock.json* ./ +#33 DONE 0.1s + +#34 [events web-builder 4/6] RUN npm ci +#34 ... + +#35 [events go-builder 5/7] COPY . . +#35 DONE 0.3s + +#36 [events go-builder 6/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server +#36 DONE 6.1s + +#37 [events go-builder 7/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /events ./cmd/events +#37 DONE 0.4s + +#34 [events web-builder 4/6] RUN npm ci +#34 ... + +#38 [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/* +#38 CACHED + +#39 [events stage-2 3/9] WORKDIR /app +#39 CACHED + +#38 [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/* +#38 CACHED + +#39 [app stage-2 3/9] WORKDIR /app +#39 CACHED + +#40 [app stage-2 4/9] COPY --from=go-builder /server /app/server +#40 DONE 0.0s + +#41 [app stage-2 5/9] COPY --from=go-builder /events /app/events +#41 DONE 0.0s + +#34 [events web-builder 4/6] RUN npm ci +#34 12.13 +#34 12.13 added 245 packages, and audited 246 packages in 12s +#34 12.13 +#34 12.13 21 packages are looking for funding +#34 12.13 run `npm fund` for details +#34 12.13 +#34 12.13 1 high severity vulnerability +#34 12.13 +#34 12.13 Some issues need review, and may require choosing +#34 12.13 a different dependency. +#34 12.13 +#34 12.13 Run `npm audit` for details. +#34 12.13 npm notice +#34 12.13 npm notice New major version of npm available! 10.9.8 -> 12.0.2 +#34 12.13 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2 +#34 12.13 npm notice To update run: npm install -g npm@12.0.2 +#34 12.13 npm notice +#34 DONE 12.2s + +#42 [app web-builder 5/6] COPY web/ ./ +#42 DONE 0.1s + +#43 [events web-builder 6/6] RUN npm run build +#43 0.178 +#43 0.178 > shuimu-workbench@1.0.0 build +#43 0.178 > tsc && vite build && node scripts/precompress-dist.mjs +#43 0.178 +#43 3.830 vite v7.3.6 building client environment for production... +#43 3.861 transforming... +#43 11.14 ✓ 4122 modules transformed. +#43 11.70 rendering chunks... +#43 12.12 computing gzip size... +#43 12.17 dist/index.html 0.72 kB │ gzip: 0.45 kB +#43 12.17 dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB +#43 12.17 dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB +#43 12.17 dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB +#43 12.17 dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB +#43 12.17 dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB +#43 12.17 dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB +#43 12.17 dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB +#43 12.17 dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB +#43 12.17 dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB +#43 12.17 dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB +#43 12.17 dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB +#43 12.17 dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB +#43 12.17 dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB +#43 12.17 dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB +#43 12.17 dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB +#43 12.17 dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB +#43 12.17 dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB +#43 12.17 dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB +#43 12.17 dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB +#43 12.17 dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB +#43 12.17 dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB +#43 12.17 dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB +#43 12.17 dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB +#43 12.17 dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB +#43 12.17 dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB +#43 12.17 dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB +#43 12.17 dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB +#43 12.17 dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB +#43 12.17 dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB +#43 12.17 dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB +#43 12.17 dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB +#43 12.17 dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB +#43 12.17 dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB +#43 12.17 dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB +#43 12.17 dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB +#43 12.17 dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB +#43 12.17 dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB +#43 12.17 dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB +#43 12.17 dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB +#43 12.17 dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB +#43 12.17 dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB +#43 12.17 dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB +#43 12.17 dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB +#43 12.17 dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB +#43 12.17 dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB +#43 12.17 dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB +#43 12.17 dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB +#43 12.17 dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB +#43 12.17 dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB +#43 12.17 dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB +#43 12.17 dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB +#43 12.17 dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB +#43 12.17 dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB +#43 12.17 dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB +#43 12.17 dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB +#43 12.17 dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB +#43 12.17 dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB +#43 12.17 dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB +#43 12.17 dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB +#43 12.17 dist/assets/index-BYHTgAJJ.css 36.82 kB │ gzip: 10.03 kB +#43 12.17 dist/assets/channel-DNIEng0q.js 0.11 kB │ gzip: 0.12 kB +#43 12.17 dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB +#43 12.17 dist/assets/chunk-2Q5K7J3B-BZMLpKO7.js 0.19 kB │ gzip: 0.16 kB +#43 12.17 dist/assets/chunk-JWPE2WC7-3XwVDd7R.js 0.22 kB │ gzip: 0.17 kB +#43 12.17 dist/assets/chunk-XXDRQBXY-OG-34K3d.js 0.22 kB │ gzip: 0.20 kB +#43 12.17 dist/assets/chunk-5VM5RSS4-wf5e4flP.js 0.36 kB │ gzip: 0.27 kB +#43 12.17 dist/assets/stateDiagram-v2-MP3YSRHH-BuYCCE5b.js 0.39 kB │ gzip: 0.29 kB +#43 12.17 dist/assets/classDiagram-ZZMXUADV-CVdTQJSv.js 0.43 kB │ gzip: 0.30 kB +#43 12.17 dist/assets/classDiagram-v2-VYDZK3BY-CVdTQJSv.js 0.43 kB │ gzip: 0.30 kB +#43 12.17 dist/assets/chunk-POPQ4Y6H-CrVio1q7.js 0.50 kB │ gzip: 0.35 kB +#43 12.17 dist/assets/swimlanesDiagram-VR7AAH4N-2vwyB05c.js 0.51 kB │ gzip: 0.35 kB +#43 12.17 dist/assets/infoDiagram-27XIBGKW-BgI1KvQI.js 0.55 kB │ gzip: 0.38 kB +#43 12.17 dist/assets/sizeCapture-INFHLROL-BimZQ6a6.js 0.90 kB │ gzip: 0.51 kB +#43 12.17 dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB +#43 12.17 dist/assets/railroadDiagram-O6MQD6OU-ByBV9o8N.js 1.57 kB │ gzip: 0.74 kB +#43 12.17 dist/assets/abnfDiagram-VCTEODGH-DNWwdfvR.js 1.77 kB │ gzip: 0.87 kB +#43 12.17 dist/assets/chunk-F27PBJKO-B3la_lkX.js 1.88 kB │ gzip: 0.83 kB +#43 12.17 dist/assets/pegDiagram-XKGWAZYB-BG_KoFQ6.js 1.92 kB │ gzip: 0.91 kB +#43 12.17 dist/assets/ebnfDiagram-PWID7BFC-DWQineoQ.js 1.97 kB │ gzip: 0.86 kB +#43 12.17 dist/assets/arc-CJvvPhq7.js 3.43 kB │ gzip: 1.48 kB +#43 12.17 dist/assets/diagram-Z3DM3KII-RF1V7gzL.js 4.18 kB │ gzip: 1.82 kB +#43 12.17 dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB +#43 12.17 dist/assets/linear-BZ30uBwb.js 5.65 kB │ gzip: 2.30 kB +#43 12.17 dist/assets/diagram-UQ7AKVKN-DZi9K3zn.js 6.04 kB │ gzip: 2.60 kB +#43 12.17 dist/assets/pieDiagram-E7YTZNPT-In23DzJs.js 6.26 kB │ gzip: 2.61 kB +#43 12.17 dist/assets/diagram-S7CK7UJ4-D3XxuaTU.js 8.18 kB │ gzip: 3.66 kB +#43 12.17 dist/assets/cynefinDiagram-5FMLGOSQ-QrrhMhDL.js 10.06 kB │ gzip: 3.61 kB +#43 12.17 dist/assets/stateDiagram-D77RDMKH-C8R2qgBn.js 10.33 kB │ gzip: 3.61 kB +#43 12.17 dist/assets/dagre-GXQ25YYZ-CM6w0zzF.js 10.64 kB │ gzip: 4.01 kB +#43 12.17 dist/assets/diagram-VSXAHHWV-DHbN8ibS.js 10.68 kB │ gzip: 4.11 kB +#43 12.17 dist/assets/chunk-SVP7TREG-CI8Y-3D2.js 15.73 kB │ gzip: 4.41 kB +#43 12.17 dist/assets/diagram-VX7I27RA-BvQOHXue.js 15.92 kB │ gzip: 5.68 kB +#43 12.17 dist/assets/ishikawaDiagram-5VMMS53U-Czj8Qul3.js 17.43 kB │ gzip: 6.62 kB +#43 12.17 dist/assets/kanban-definition-UXKFOSKX-BoJUFrcC.js 20.26 kB │ gzip: 7.18 kB +#43 12.17 dist/assets/mindmap-definition-YA3MSWOX-DWqHssQa.js 23.14 kB │ gzip: 7.80 kB +#43 12.17 dist/assets/sankeyDiagram-P5KCCOFB-DbuNAAw9.js 23.16 kB │ gzip: 8.54 kB +#43 12.17 dist/assets/journeyDiagram-3NMN7TZE-BKcwxO6D.js 23.54 kB │ gzip: 8.33 kB +#43 12.17 dist/assets/wardleyDiagram-VM6X3IG4-CX23Vy_T.js 25.47 kB │ gzip: 6.77 kB +#43 12.17 dist/assets/gitGraphDiagram-WWUBYQGX-Dn_exaMU.js 29.38 kB │ gzip: 8.68 kB +#43 12.17 dist/assets/erDiagram-RLTQ6QDP-B5NGgjOU.js 30.76 kB │ gzip: 10.57 kB +#43 12.17 dist/assets/timeline-definition-24CTP7MA-WeBLss97.js 30.79 kB │ gzip: 10.13 kB +#43 12.17 dist/assets/requirementDiagram-BXWQKSXE-CGDVit9U.js 30.97 kB │ gzip: 9.71 kB +#43 12.17 dist/assets/quadrantDiagram-AXDQQJYC-DtIrN-kE.js 33.98 kB │ gzip: 9.96 kB +#43 12.17 dist/assets/layout-CrY6-gw1.js 35.43 kB │ gzip: 12.74 kB +#43 12.17 dist/assets/chunk-IMKFNOWR-DL2S1Ocn.js 38.07 kB │ gzip: 12.51 kB +#43 12.17 dist/assets/blockDiagram-I7D4REHJ-4pQkRsYE.js 41.72 kB │ gzip: 13.56 kB +#43 12.17 dist/assets/vennDiagram-4TSXK5OY-mjOdrwai.js 41.93 kB │ gzip: 15.67 kB +#43 12.17 dist/assets/xychartDiagram-S5SC5T6Z-BHknuwFK.js 44.47 kB │ gzip: 12.58 kB +#43 12.17 dist/assets/chunk-TICWLB2K-upuxjo4B.js 49.17 kB │ gzip: 15.74 kB +#43 12.17 dist/assets/flowDiagram-HODETNUW-ChE8AD7S.js 61.84 kB │ gzip: 19.87 kB +#43 12.17 dist/assets/c4Diagram-7LVT6UL2-AvTEBjbZ.js 65.44 kB │ gzip: 18.77 kB +#43 12.17 dist/assets/ganttDiagram-EL5Y4UJY-CelQZFDx.js 69.66 kB │ gzip: 23.54 kB +#43 12.17 dist/assets/cose-bilkent-JH36ORCC-C3HMHbNW.js 81.66 kB │ gzip: 22.46 kB +#43 12.17 dist/assets/swimlanes-42K2YHIH-DfsRAiF7.js 115.76 kB │ gzip: 42.49 kB +#43 12.17 dist/assets/sequenceDiagram-WJ2MYXX4-DjJuHVj7.js 116.93 kB │ gzip: 31.11 kB +#43 12.17 dist/assets/architectureDiagram-5GKGNRK7-BgFHVZr9.js 151.79 kB │ gzip: 43.13 kB +#43 12.17 dist/assets/katex-HP8lGamR.js 261.31 kB │ gzip: 77.57 kB +#43 12.17 dist/assets/cytoscape.esm-BB4DxJjf.js 443.04 kB │ gzip: 141.98 kB +#43 12.17 dist/assets/cynefin-OW5HDTMX-BO97kahj.js 687.91 kB │ gzip: 154.14 kB +#43 12.17 dist/assets/index-Bu0CqZB_.js 2,820.06 kB │ gzip: 803.51 kB +#43 12.17 +#43 12.17 (!) Some chunks are larger than 500 kB after minification. Consider: +#43 12.17 - Using dynamic import() to code-split the application +#43 12.17 - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +#43 12.17 - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +#43 12.17 ✓ built in 8.33s +#43 12.29 precompress: 49 candidate(s) under /web/dist +#43 12.31 br assets/abnfDiagram-VCTEODGH-DNWwdfvR.js 1774 → 802 (−54.8%) +#43 12.31 gzip assets/abnfDiagram-VCTEODGH-DNWwdfvR.js 1774 → 874 (−50.7%) +#43 12.31 br assets/arc-CJvvPhq7.js 3434 → 1284 (−62.6%) +#43 12.31 gzip assets/arc-CJvvPhq7.js 3434 → 1475 (−57.0%) +#43 12.45 br assets/architectureDiagram-5GKGNRK7-BgFHVZr9.js 151788 → 36874 (−75.7%) +#43 12.45 gzip assets/architectureDiagram-5GKGNRK7-BgFHVZr9.js 151788 → 42956 (−71.7%) +#43 12.48 br assets/blockDiagram-I7D4REHJ-4pQkRsYE.js 41722 → 11974 (−71.3%) +#43 12.48 gzip assets/blockDiagram-I7D4REHJ-4pQkRsYE.js 41722 → 13491 (−67.7%) +#43 12.53 br assets/c4Diagram-7LVT6UL2-AvTEBjbZ.js 65438 → 15134 (−76.9%) +#43 12.53 gzip assets/c4Diagram-7LVT6UL2-AvTEBjbZ.js 65438 → 18726 (−71.4%) +#43 12.53 br assets/chunk-F27PBJKO-B3la_lkX.js 1876 → 732 (−61.0%) +#43 12.53 gzip assets/chunk-F27PBJKO-B3la_lkX.js 1876 → 827 (−55.9%) +#43 12.56 br assets/chunk-IMKFNOWR-DL2S1Ocn.js 38069 → 10968 (−71.2%) +#43 12.56 gzip assets/chunk-IMKFNOWR-DL2S1Ocn.js 38069 → 12492 (−67.2%) +#43 12.57 br assets/chunk-SVP7TREG-CI8Y-3D2.js 15728 → 3961 (−74.8%) +#43 12.57 gzip assets/chunk-SVP7TREG-CI8Y-3D2.js 15728 → 4409 (−72.0%) +#43 12.60 br assets/chunk-TICWLB2K-upuxjo4B.js 49171 → 13030 (−73.5%) +#43 12.60 gzip assets/chunk-TICWLB2K-upuxjo4B.js 49171 → 15733 (−68.0%) +#43 12.66 br assets/cose-bilkent-JH36ORCC-C3HMHbNW.js 81662 → 19601 (−76.0%) +#43 12.66 gzip assets/cose-bilkent-JH36ORCC-C3HMHbNW.js 81662 → 22363 (−72.6%) +#43 13.31 br assets/cynefin-OW5HDTMX-BO97kahj.js 687914 → 124608 (−81.9%) +#43 13.31 gzip assets/cynefin-OW5HDTMX-BO97kahj.js 687914 → 153500 (−77.7%) +#43 13.31 br assets/cynefinDiagram-5FMLGOSQ-QrrhMhDL.js 10062 → 3227 (−67.9%) +#43 13.31 gzip assets/cynefinDiagram-5FMLGOSQ-QrrhMhDL.js 10062 → 3612 (−64.1%) +#43 13.79 br assets/cytoscape.esm-BB4DxJjf.js 443036 → 119602 (−73.0%) +#43 13.79 gzip assets/cytoscape.esm-BB4DxJjf.js 443036 → 141625 (−68.0%) +#43 13.80 br assets/dagre-GXQ25YYZ-CM6w0zzF.js 10641 → 3656 (−65.6%) +#43 13.80 gzip assets/dagre-GXQ25YYZ-CM6w0zzF.js 10641 → 4008 (−62.3%) +#43 13.80 br assets/defaultLocale-DX6XiGOO.js 4688 → 2012 (−57.1%) +#43 13.80 gzip assets/defaultLocale-DX6XiGOO.js 4688 → 2182 (−53.5%) +#43 13.80 br assets/diagram-S7CK7UJ4-D3XxuaTU.js 8182 → 3317 (−59.5%) +#43 13.80 gzip assets/diagram-S7CK7UJ4-D3XxuaTU.js 8182 → 3657 (−55.3%) +#43 13.81 br assets/diagram-UQ7AKVKN-DZi9K3zn.js 6044 → 2351 (−61.1%) +#43 13.81 gzip assets/diagram-UQ7AKVKN-DZi9K3zn.js 6044 → 2603 (−56.9%) +#43 13.82 br assets/diagram-VSXAHHWV-DHbN8ibS.js 10681 → 3768 (−64.7%) +#43 13.82 gzip assets/diagram-VSXAHHWV-DHbN8ibS.js 10681 → 4113 (−61.5%) +#43 13.83 br assets/diagram-VX7I27RA-BvQOHXue.js 15922 → 5149 (−67.7%) +#43 13.83 gzip assets/diagram-VX7I27RA-BvQOHXue.js 15922 → 5679 (−64.3%) +#43 13.83 br assets/diagram-Z3DM3KII-RF1V7gzL.js 4176 → 1631 (−60.9%) +#43 13.83 gzip assets/diagram-Z3DM3KII-RF1V7gzL.js 4176 → 1816 (−56.5%) +#43 13.83 br assets/ebnfDiagram-PWID7BFC-DWQineoQ.js 1970 → 767 (−61.1%) +#43 13.83 gzip assets/ebnfDiagram-PWID7BFC-DWQineoQ.js 1970 → 861 (−56.3%) +#43 13.85 br assets/erDiagram-RLTQ6QDP-B5NGgjOU.js 30755 → 9165 (−70.2%) +#43 13.85 gzip assets/erDiagram-RLTQ6QDP-B5NGgjOU.js 30755 → 10569 (−65.6%) +#43 13.90 br assets/flowDiagram-HODETNUW-ChE8AD7S.js 61840 → 16328 (−73.6%) +#43 13.90 gzip assets/flowDiagram-HODETNUW-ChE8AD7S.js 61840 → 19857 (−67.9%) +#43 13.95 br assets/ganttDiagram-EL5Y4UJY-CelQZFDx.js 69656 → 20680 (−70.3%) +#43 13.95 gzip assets/ganttDiagram-EL5Y4UJY-CelQZFDx.js 69656 → 23513 (−66.2%) +#43 13.97 br assets/gitGraphDiagram-WWUBYQGX-Dn_exaMU.js 29382 → 7774 (−73.5%) +#43 13.97 gzip assets/gitGraphDiagram-WWUBYQGX-Dn_exaMU.js 29382 → 8662 (−70.5%) +#43 14.00 br assets/index-BYHTgAJJ.css 36824 → 8557 (−76.8%) +#43 14.00 gzip assets/index-BYHTgAJJ.css 36824 → 9920 (−73.1%) +#43 16.82 br assets/index-Bu0CqZB_.js 2820055 → 623898 (−77.9%) +#43 16.82 gzip assets/index-Bu0CqZB_.js 2820055 → 800721 (−71.6%) +#43 16.83 br assets/ishikawaDiagram-5VMMS53U-Czj8Qul3.js 17434 → 5995 (−65.6%) +#43 16.83 gzip assets/ishikawaDiagram-5VMMS53U-Czj8Qul3.js 17434 → 6613 (−62.1%) +#43 16.85 br assets/journeyDiagram-3NMN7TZE-BKcwxO6D.js 23536 → 7490 (−68.2%) +#43 16.85 gzip assets/journeyDiagram-3NMN7TZE-BKcwxO6D.js 23536 → 8316 (−64.7%) +#43 16.86 br assets/kanban-definition-UXKFOSKX-BoJUFrcC.js 20256 → 6393 (−68.4%) +#43 16.86 gzip assets/kanban-definition-UXKFOSKX-BoJUFrcC.js 20256 → 7169 (−64.6%) +#43 17.11 br assets/katex-HP8lGamR.js 261313 → 63694 (−75.6%) +#43 17.11 gzip assets/katex-HP8lGamR.js 261313 → 77166 (−70.5%) +#43 17.13 br assets/layout-CrY6-gw1.js 35429 → 11568 (−67.3%) +#43 17.13 gzip assets/layout-CrY6-gw1.js 35429 → 12719 (−64.1%) +#43 17.14 br assets/linear-BZ30uBwb.js 5654 → 2114 (−62.6%) +#43 17.14 gzip assets/linear-BZ30uBwb.js 5654 → 2304 (−59.3%) +#43 17.15 br assets/mindmap-definition-YA3MSWOX-DWqHssQa.js 23140 → 6973 (−69.9%) +#43 17.15 gzip assets/mindmap-definition-YA3MSWOX-DWqHssQa.js 23140 → 7789 (−66.3%) +#43 17.15 br assets/ordinal-Cboi1Yqb.js 1189 → 497 (−58.2%) +#43 17.15 gzip assets/ordinal-Cboi1Yqb.js 1189 → 573 (−51.8%) +#43 17.15 br assets/pegDiagram-XKGWAZYB-BG_KoFQ6.js 1921 → 800 (−58.4%) +#43 17.15 gzip assets/pegDiagram-XKGWAZYB-BG_KoFQ6.js 1921 → 905 (−52.9%) +#43 17.16 br assets/pieDiagram-E7YTZNPT-In23DzJs.js 6259 → 2385 (−61.9%) +#43 17.16 gzip assets/pieDiagram-E7YTZNPT-In23DzJs.js 6259 → 2605 (−58.4%) +#43 17.18 br assets/quadrantDiagram-AXDQQJYC-DtIrN-kE.js 33975 → 8512 (−74.9%) +#43 17.18 gzip assets/quadrantDiagram-AXDQQJYC-DtIrN-kE.js 33975 → 9922 (−70.8%) +#43 17.18 br assets/railroadDiagram-O6MQD6OU-ByBV9o8N.js 1571 → 665 (−57.7%) +#43 17.18 gzip assets/railroadDiagram-O6MQD6OU-ByBV9o8N.js 1571 → 735 (−53.2%) +#43 17.21 br assets/requirementDiagram-BXWQKSXE-CGDVit9U.js 30965 → 8235 (−73.4%) +#43 17.21 gzip assets/requirementDiagram-BXWQKSXE-CGDVit9U.js 30965 → 9698 (−68.7%) +#43 17.22 br assets/sankeyDiagram-P5KCCOFB-DbuNAAw9.js 23163 → 7808 (−66.3%) +#43 17.22 gzip assets/sankeyDiagram-P5KCCOFB-DbuNAAw9.js 23163 → 8534 (−63.2%) +#43 17.31 br assets/sequenceDiagram-WJ2MYXX4-DjJuHVj7.js 116927 → 25728 (−78.0%) +#43 17.31 gzip assets/sequenceDiagram-WJ2MYXX4-DjJuHVj7.js 116927 → 31047 (−73.4%) +#43 17.32 br assets/stateDiagram-D77RDMKH-C8R2qgBn.js 10326 → 3308 (−68.0%) +#43 17.32 gzip assets/stateDiagram-D77RDMKH-C8R2qgBn.js 10326 → 3602 (−65.1%) +#43 17.42 br assets/swimlanes-42K2YHIH-DfsRAiF7.js 115756 → 36665 (−68.3%) +#43 17.42 gzip assets/swimlanes-42K2YHIH-DfsRAiF7.js 115756 → 42418 (−63.4%) +#43 17.44 br assets/timeline-definition-24CTP7MA-WeBLss97.js 30794 → 9104 (−70.4%) +#43 17.44 gzip assets/timeline-definition-24CTP7MA-WeBLss97.js 30794 → 10117 (−67.1%) +#43 17.47 br assets/vennDiagram-4TSXK5OY-mjOdrwai.js 41926 → 13976 (−66.7%) +#43 17.47 gzip assets/vennDiagram-4TSXK5OY-mjOdrwai.js 41926 → 15661 (−62.6%) +#43 17.49 br assets/wardleyDiagram-VM6X3IG4-CX23Vy_T.js 25467 → 6071 (−76.2%) +#43 17.49 gzip assets/wardleyDiagram-VM6X3IG4-CX23Vy_T.js 25467 → 6742 (−73.5%) +#43 17.52 br assets/xychartDiagram-S5SC5T6Z-BHknuwFK.js 44474 → 11037 (−75.2%) +#43 17.52 gzip assets/xychartDiagram-S5SC5T6Z-BHknuwFK.js 44474 → 12554 (−71.8%) +#43 17.52 br fonts/OFL.txt 4474 → 1437 (−67.9%) +#43 17.52 gzip fonts/OFL.txt 4474 → 1993 (−55.5%) +#43 17.52 precompress: wrote 49 .br and 49 .gz +#43 DONE 17.6s + +#44 [app stage-2 6/9] COPY --from=web-builder /web/dist /app/web/dist +#44 DONE 0.0s + +#45 [events 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:478f45e6785a3a54663d52f8bfbfd737acf4ba1bc08e61cefb8f50fc7f0d108c done +#48 exporting config sha256:012423a05ae9029c6359a62195ba9bfd94ae57195ff2c3c1d58c0813b4840805 done +#48 exporting attestation manifest sha256:99a8432c74582132f33a3d13e8d7018ce6c0cb28b1774eac2500c844ac44a1a0 done +#48 exporting manifest list sha256:7ffd2cd6a7980cac58cb8bb5bfbd60e31ec4a6d1bf772b806407144562a55e87 done +#48 naming to docker.io/library/agent-dev-app:latest done +#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:750ee7a2b3410dd4950559f5ac5d650b51a4afe24df2affb2cba579e934ccb21 done +#49 exporting config sha256:5472d5395c11a812621e1d9220c1b5ebd503abdf29a5d1e0f195821c939b9d94 done +#49 exporting attestation manifest sha256:3af9ce869fe078e177121a2f9ad9b8e5796a44877f016eb96842174b1f96ec3a done +#49 exporting manifest list sha256:6c279d3ef4b766c874134b52e1af280d1dc5a2e230f89a4bf7c913138b8cf3eb 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 [app] resolving provenance for metadata file +#50 DONE 0.0s + +#51 [events] resolving provenance for metadata file +#51 DONE 0.0s + Image shuimu-pi-agent:dev Built + Image agent-dev-app Built + Image agent-dev-events Built + Container shuimu-context-pg-dev Running + Container shuimu-cursor-agent-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-17-initial-load-perf-u2/rebuild-nocache-log.txt b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/rebuild-nocache-log.txt new file mode 100644 index 00000000..2d8d4a68 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/rebuild-nocache-log.txt @@ -0,0 +1,515 @@ + Image agent-dev-app Building +#1 [internal] load local bake definitions +#1 reading from stdin 549B done +#1 DONE 0.0s + +#2 [internal] load build definition from Dockerfile +#2 transferring dockerfile: 1.53kB done +#2 DONE 0.0s + +#3 [internal] load metadata for docker.io/library/node:22-bookworm +#3 DONE 0.1s + +#4 [internal] load metadata for docker.io/library/golang:1.23-bookworm +#4 DONE 0.1s + +#5 [internal] load metadata for docker.io/library/debian:bookworm-slim +#5 DONE 0.2s + +#6 [internal] load .dockerignore +#6 transferring context: 234B done +#6 DONE 0.0s + +#7 [stage-2 1/9] FROM docker.io/library/debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 +#7 resolve docker.io/library/debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 done +#7 CACHED + +#8 [web-builder 1/6] FROM docker.io/library/node:22-bookworm@sha256:8a34c4ab3ea2c5cd194f07e317b2a8f09461d3c8b05c4e34c8ccd56d56024c4d +#8 resolve docker.io/library/node:22-bookworm@sha256:8a34c4ab3ea2c5cd194f07e317b2a8f09461d3c8b05c4e34c8ccd56d56024c4d done +#8 DONE 0.0s + +#9 [web-builder 2/6] WORKDIR /web +#9 CACHED + +#10 [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 [go-builder 2/7] WORKDIR /src +#11 CACHED + +#12 [internal] load build context +#12 transferring context: 46.16MB 0.5s done +#12 DONE 0.5s + +#13 [go-builder 3/7] COPY go.mod go.sum ./ +#13 DONE 0.0s + +#14 [web-builder 3/6] COPY web/package.json web/package-lock.json* ./ +#14 DONE 0.0s + +#15 [go-builder 4/7] RUN go mod download +#15 0.132 go: downloading go1.27.1 (linux/amd64) +#15 ... + +#16 [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/* +#16 0.125 + apt-get update +#16 0.476 Get:1 http://deb.debian.org/debian bookworm InRelease [151 kB] +#16 1.038 Get:2 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB] +#16 1.252 Get:3 http://deb.debian.org/debian-security bookworm-security InRelease [34.8 kB] +#16 1.423 Get:4 http://deb.debian.org/debian bookworm/main amd64 Packages [8790 kB] +#16 10.05 Get:5 http://deb.debian.org/debian bookworm-updates/main amd64 Packages [6924 B] +#16 10.30 Get:6 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages [339 kB] +#16 ... + +#17 [web-builder 4/6] RUN npm ci +#17 ... + +#16 [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/* +#16 10.96 Fetched 9378 kB in 11s (868 kB/s) +#16 10.96 Reading package lists... +#16 11.24 + apt-get install -y --no-install-recommends ca-certificates curl +#16 11.24 Reading package lists... +#16 11.47 Building dependency tree... +#16 11.53 Reading state information... +#16 11.59 The following additional packages will be installed: +#16 11.59 libbrotli1 libcurl4 libgssapi-krb5-2 libk5crypto3 libkeyutils1 libkrb5-3 +#16 11.59 libkrb5support0 libldap-2.5-0 libnghttp2-14 libpsl5 librtmp1 libsasl2-2 +#16 11.59 libsasl2-modules-db libssh2-1 libssl3 openssl +#16 11.59 Suggested packages: +#16 11.59 krb5-doc krb5-user +#16 11.59 Recommended packages: +#16 11.59 krb5-locales libldap-common publicsuffix libsasl2-modules +#16 11.65 The following NEW packages will be installed: +#16 11.65 ca-certificates curl libbrotli1 libcurl4 libgssapi-krb5-2 libk5crypto3 +#16 11.65 libkeyutils1 libkrb5-3 libkrb5support0 libldap-2.5-0 libnghttp2-14 libpsl5 +#16 11.65 librtmp1 libsasl2-2 libsasl2-modules-db libssh2-1 libssl3 openssl +#16 12.04 0 upgraded, 18 newly installed, 0 to remove and 1 not upgraded. +#16 12.04 Need to get 5840 kB of archives. +#16 12.04 After this operation, 14.7 MB of additional disk space will be used. +#16 12.04 Get:1 http://deb.debian.org/debian bookworm/main amd64 libssl3 amd64 3.0.20-1~deb12u2 [2036 kB] +#16 13.44 Get:2 http://deb.debian.org/debian bookworm/main amd64 openssl amd64 3.0.20-1~deb12u2 [1439 kB] +#16 14.44 Get:3 http://deb.debian.org/debian-security bookworm-security/main amd64 ca-certificates all 20250419~deb12u1 [162 kB] +#16 14.52 Get:4 http://deb.debian.org/debian bookworm/main amd64 libbrotli1 amd64 1.0.9-2+b6 [275 kB] +#16 14.69 Get:5 http://deb.debian.org/debian bookworm/main amd64 libkrb5support0 amd64 1.20.1-2+deb12u5 [33.2 kB] +#16 14.74 Get:6 http://deb.debian.org/debian bookworm/main amd64 libk5crypto3 amd64 1.20.1-2+deb12u5 [79.7 kB] +#16 14.75 Get:7 http://deb.debian.org/debian bookworm/main amd64 libkeyutils1 amd64 1.6.3-2 [8808 B] +#16 14.76 Get:8 http://deb.debian.org/debian bookworm/main amd64 libkrb5-3 amd64 1.20.1-2+deb12u5 [332 kB] +#16 14.98 Get:9 http://deb.debian.org/debian bookworm/main amd64 libgssapi-krb5-2 amd64 1.20.1-2+deb12u5 [135 kB] +#16 15.12 Get:10 http://deb.debian.org/debian bookworm/main amd64 libsasl2-modules-db amd64 2.1.28+dfsg-10 [20.3 kB] +#16 15.12 Get:11 http://deb.debian.org/debian bookworm/main amd64 libsasl2-2 amd64 2.1.28+dfsg-10 [59.7 kB] +#16 15.13 Get:12 http://deb.debian.org/debian bookworm/main amd64 libldap-2.5-0 amd64 2.5.13+dfsg-5 [183 kB] +#16 15.25 Get:13 http://deb.debian.org/debian bookworm/main amd64 libnghttp2-14 amd64 1.52.0-1+deb12u3 [72.4 kB] +#16 15.33 Get:14 http://deb.debian.org/debian bookworm/main amd64 libpsl5 amd64 0.21.2-1 [58.7 kB] +#16 15.35 Get:15 http://deb.debian.org/debian bookworm/main amd64 librtmp1 amd64 2.4+20151223.gitfa8646d.1-2+b2 [60.8 kB] +#16 15.40 Get:16 http://deb.debian.org/debian-security bookworm-security/main amd64 libssh2-1 amd64 1.10.0-3+deb12u1 [176 kB] +#16 15.53 Get:17 http://deb.debian.org/debian bookworm/main amd64 libcurl4 amd64 7.88.1-10+deb12u15 [392 kB] +#16 15.90 Get:18 http://deb.debian.org/debian bookworm/main amd64 curl amd64 7.88.1-10+deb12u15 [316 kB] +#16 16.20 debconf: delaying package configuration, since apt-utils is not installed +#16 16.21 Fetched 5840 kB in 4s (1311 kB/s) +#16 16.24 Selecting previously unselected package libssl3:amd64. +#16 16.24 (Reading database ... (Reading database ... 5% (Reading database ... 10% (Reading database ... 15% (Reading database ... 20% (Reading database ... 25% (Reading database ... 30% (Reading database ... 35% (Reading database ... 40% (Reading database ... 45% (Reading database ... 50% (Reading database ... 55% (Reading database ... 60% (Reading database ... 65% (Reading database ... 70% (Reading database ... 75% (Reading database ... 80% (Reading database ... 85% (Reading database ... 90% (Reading database ... 95% (Reading database ... 100% (Reading database ... 6096 files and directories currently installed.) +#16 16.25 Preparing to unpack .../00-libssl3_3.0.20-1~deb12u2_amd64.deb ... +#16 16.25 Unpacking libssl3:amd64 (3.0.20-1~deb12u2) ... +#16 16.36 Selecting previously unselected package openssl. +#16 16.36 Preparing to unpack .../01-openssl_3.0.20-1~deb12u2_amd64.deb ... +#16 16.36 Unpacking openssl (3.0.20-1~deb12u2) ... +#16 16.45 Selecting previously unselected package ca-certificates. +#16 16.45 Preparing to unpack .../02-ca-certificates_20250419~deb12u1_all.deb ... +#16 16.45 Unpacking ca-certificates (20250419~deb12u1) ... +#16 16.47 Selecting previously unselected package libbrotli1:amd64. +#16 16.47 Preparing to unpack .../03-libbrotli1_1.0.9-2+b6_amd64.deb ... +#16 16.47 Unpacking libbrotli1:amd64 (1.0.9-2+b6) ... +#16 16.49 Selecting previously unselected package libkrb5support0:amd64. +#16 16.49 Preparing to unpack .../04-libkrb5support0_1.20.1-2+deb12u5_amd64.deb ... +#16 16.50 Unpacking libkrb5support0:amd64 (1.20.1-2+deb12u5) ... +#16 16.51 Selecting previously unselected package libk5crypto3:amd64. +#16 16.51 Preparing to unpack .../05-libk5crypto3_1.20.1-2+deb12u5_amd64.deb ... +#16 16.51 Unpacking libk5crypto3:amd64 (1.20.1-2+deb12u5) ... +#16 16.52 Selecting previously unselected package libkeyutils1:amd64. +#16 16.52 Preparing to unpack .../06-libkeyutils1_1.6.3-2_amd64.deb ... +#16 16.52 Unpacking libkeyutils1:amd64 (1.6.3-2) ... +#16 16.53 Selecting previously unselected package libkrb5-3:amd64. +#16 16.53 Preparing to unpack .../07-libkrb5-3_1.20.1-2+deb12u5_amd64.deb ... +#16 16.54 Unpacking libkrb5-3:amd64 (1.20.1-2+deb12u5) ... +#16 16.56 Selecting previously unselected package libgssapi-krb5-2:amd64. +#16 16.56 Preparing to unpack .../08-libgssapi-krb5-2_1.20.1-2+deb12u5_amd64.deb ... +#16 16.57 Unpacking libgssapi-krb5-2:amd64 (1.20.1-2+deb12u5) ... +#16 16.58 Selecting previously unselected package libsasl2-modules-db:amd64. +#16 16.58 Preparing to unpack .../09-libsasl2-modules-db_2.1.28+dfsg-10_amd64.deb ... +#16 16.58 Unpacking libsasl2-modules-db:amd64 (2.1.28+dfsg-10) ... +#16 16.59 Selecting previously unselected package libsasl2-2:amd64. +#16 16.59 Preparing to unpack .../10-libsasl2-2_2.1.28+dfsg-10_amd64.deb ... +#16 16.59 Unpacking libsasl2-2:amd64 (2.1.28+dfsg-10) ... +#16 16.61 Selecting previously unselected package libldap-2.5-0:amd64. +#16 16.61 Preparing to unpack .../11-libldap-2.5-0_2.5.13+dfsg-5_amd64.deb ... +#16 16.61 Unpacking libldap-2.5-0:amd64 (2.5.13+dfsg-5) ... +#16 16.63 Selecting previously unselected package libnghttp2-14:amd64. +#16 16.63 Preparing to unpack .../12-libnghttp2-14_1.52.0-1+deb12u3_amd64.deb ... +#16 16.63 Unpacking libnghttp2-14:amd64 (1.52.0-1+deb12u3) ... +#16 16.64 Selecting previously unselected package libpsl5:amd64. +#16 16.64 Preparing to unpack .../13-libpsl5_0.21.2-1_amd64.deb ... +#16 16.64 Unpacking libpsl5:amd64 (0.21.2-1) ... +#16 16.66 Selecting previously unselected package librtmp1:amd64. +#16 16.66 Preparing to unpack .../14-librtmp1_2.4+20151223.gitfa8646d.1-2+b2_amd64.deb ... +#16 16.66 Unpacking librtmp1:amd64 (2.4+20151223.gitfa8646d.1-2+b2) ... +#16 16.67 Selecting previously unselected package libssh2-1:amd64. +#16 16.67 Preparing to unpack .../15-libssh2-1_1.10.0-3+deb12u1_amd64.deb ... +#16 16.67 Unpacking libssh2-1:amd64 (1.10.0-3+deb12u1) ... +#16 16.69 Selecting previously unselected package libcurl4:amd64. +#16 16.69 Preparing to unpack .../16-libcurl4_7.88.1-10+deb12u15_amd64.deb ... +#16 16.69 Unpacking libcurl4:amd64 (7.88.1-10+deb12u15) ... +#16 16.72 Selecting previously unselected package curl. +#16 16.72 Preparing to unpack .../17-curl_7.88.1-10+deb12u15_amd64.deb ... +#16 16.72 Unpacking curl (7.88.1-10+deb12u15) ... +#16 16.75 Setting up libkeyutils1:amd64 (1.6.3-2) ... +#16 16.75 Setting up libpsl5:amd64 (0.21.2-1) ... +#16 16.75 Setting up libbrotli1:amd64 (1.0.9-2+b6) ... +#16 16.75 Setting up libssl3:amd64 (3.0.20-1~deb12u2) ... +#16 16.76 Setting up libnghttp2-14:amd64 (1.52.0-1+deb12u3) ... +#16 16.76 Setting up libkrb5support0:amd64 (1.20.1-2+deb12u5) ... +#16 16.76 Setting up libsasl2-modules-db:amd64 (2.1.28+dfsg-10) ... +#16 16.76 Setting up librtmp1:amd64 (2.4+20151223.gitfa8646d.1-2+b2) ... +#16 16.76 Setting up libk5crypto3:amd64 (1.20.1-2+deb12u5) ... +#16 16.77 Setting up libsasl2-2:amd64 (2.1.28+dfsg-10) ... +#16 16.77 Setting up libssh2-1:amd64 (1.10.0-3+deb12u1) ... +#16 16.77 Setting up libkrb5-3:amd64 (1.20.1-2+deb12u5) ... +#16 16.77 Setting up openssl (3.0.20-1~deb12u2) ... +#16 16.78 Setting up libldap-2.5-0:amd64 (2.5.13+dfsg-5) ... +#16 16.78 Setting up ca-certificates (20250419~deb12u1) ... +#16 16.83 debconf: unable to initialize frontend: Dialog +#16 16.83 debconf: (TERM is not set, so the dialog frontend is not usable.) +#16 16.83 debconf: falling back to frontend: Readline +#16 16.83 debconf: unable to initialize frontend: Readline +#16 16.83 debconf: (Can't locate Term/ReadLine.pm in @INC (you may need to install the Term::ReadLine module) (@INC contains: /etc/perl /usr/local/lib/x86_64-linux-gnu/perl/5.36.0 /usr/local/share/perl/5.36.0 /usr/lib/x86_64-linux-gnu/perl5/5.36 /usr/share/perl5 /usr/lib/x86_64-linux-gnu/perl-base /usr/lib/x86_64-linux-gnu/perl/5.36 /usr/share/perl/5.36 /usr/local/lib/site_perl) at /usr/share/perl5/Debconf/FrontEnd/Readline.pm line 7.) +#16 16.83 debconf: falling back to frontend: Teletype +#16 17.05 Updating certificates in /etc/ssl/certs... +#16 17.37 150 added, 0 removed; done. +#16 17.38 Setting up libgssapi-krb5-2:amd64 (1.20.1-2+deb12u5) ... +#16 17.38 Setting up libcurl4:amd64 (7.88.1-10+deb12u15) ... +#16 17.39 Setting up curl (7.88.1-10+deb12u15) ... +#16 17.39 Processing triggers for libc-bin (2.36-9+deb12u14) ... +#16 17.41 Processing triggers for ca-certificates (20250419~deb12u1) ... +#16 17.41 Updating certificates in /etc/ssl/certs... +#16 17.65 0 added, 0 removed; done. +#16 17.65 Running hooks in /etc/ca-certificates/update.d... +#16 17.65 done. +#16 17.66 + rm -rf /var/lib/apt/lists/auxfiles /var/lib/apt/lists/deb.debian.org_debian-security_dists_bookworm-security_InRelease /var/lib/apt/lists/deb.debian.org_debian-security_dists_bookworm-security_main_binary-amd64_Packages.lz4 /var/lib/apt/lists/deb.debian.org_debian_dists_bookworm-updates_InRelease /var/lib/apt/lists/deb.debian.org_debian_dists_bookworm-updates_main_binary-amd64_Packages.lz4 /var/lib/apt/lists/deb.debian.org_debian_dists_bookworm_InRelease /var/lib/apt/lists/deb.debian.org_debian_dists_bookworm_main_binary-amd64_Packages.lz4 /var/lib/apt/lists/lock /var/lib/apt/lists/partial +#16 DONE 17.8s + +#17 [web-builder 4/6] RUN npm ci +#17 ... + +#18 [stage-2 3/9] WORKDIR /app +#18 DONE 0.0s + +#17 [web-builder 4/6] RUN npm ci +#17 24.72 +#17 24.72 added 245 packages, and audited 246 packages in 25s +#17 24.73 +#17 24.73 21 packages are looking for funding +#17 24.73 run `npm fund` for details +#17 24.73 +#17 24.73 1 high severity vulnerability +#17 24.73 +#17 24.73 Some issues need review, and may require choosing +#17 24.73 a different dependency. +#17 24.73 +#17 24.73 Run `npm audit` for details. +#17 24.73 npm notice +#17 24.73 npm notice New major version of npm available! 10.9.8 -> 12.0.2 +#17 24.73 npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2 +#17 24.73 npm notice To update run: npm install -g npm@12.0.2 +#17 24.73 npm notice +#17 DONE 24.8s + +#19 [web-builder 5/6] COPY web/ ./ +#19 DONE 0.1s + +#15 [go-builder 4/7] RUN go mod download +#15 ... + +#20 [web-builder 6/6] RUN npm run build +#20 0.181 +#20 0.181 > shuimu-workbench@1.0.0 build +#20 0.181 > tsc && vite build && node scripts/precompress-dist.mjs +#20 0.181 +#20 3.826 vite v7.3.6 building client environment for production... +#20 3.858 transforming... +#20 10.86 ✓ 4122 modules transformed. +#20 11.42 rendering chunks... +#20 11.84 computing gzip size... +#20 11.89 dist/index.html 0.72 kB │ gzip: 0.45 kB +#20 11.89 dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB +#20 11.89 dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB +#20 11.89 dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB +#20 11.89 dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB +#20 11.89 dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB +#20 11.89 dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB +#20 11.89 dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB +#20 11.89 dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB +#20 11.89 dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB +#20 11.89 dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB +#20 11.89 dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB +#20 11.89 dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB +#20 11.89 dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB +#20 11.89 dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB +#20 11.89 dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB +#20 11.89 dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB +#20 11.89 dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB +#20 11.89 dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB +#20 11.89 dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB +#20 11.89 dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB +#20 11.89 dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB +#20 11.89 dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB +#20 11.89 dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB +#20 11.89 dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB +#20 11.89 dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB +#20 11.89 dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB +#20 11.89 dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB +#20 11.89 dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB +#20 11.89 dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB +#20 11.89 dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB +#20 11.89 dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB +#20 11.89 dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB +#20 11.89 dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB +#20 11.89 dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB +#20 11.89 dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB +#20 11.89 dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB +#20 11.89 dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB +#20 11.89 dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB +#20 11.89 dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB +#20 11.89 dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB +#20 11.89 dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB +#20 11.89 dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB +#20 11.89 dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB +#20 11.89 dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB +#20 11.89 dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB +#20 11.89 dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB +#20 11.89 dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB +#20 11.89 dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB +#20 11.89 dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB +#20 11.89 dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB +#20 11.89 dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB +#20 11.89 dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB +#20 11.89 dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB +#20 11.89 dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB +#20 11.89 dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB +#20 11.89 dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB +#20 11.89 dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB +#20 11.89 dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB +#20 11.89 dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB +#20 11.89 dist/assets/index-BYHTgAJJ.css 36.82 kB │ gzip: 10.03 kB +#20 11.89 dist/assets/channel-DNIEng0q.js 0.11 kB │ gzip: 0.12 kB +#20 11.89 dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB +#20 11.89 dist/assets/chunk-2Q5K7J3B-BZMLpKO7.js 0.19 kB │ gzip: 0.16 kB +#20 11.89 dist/assets/chunk-JWPE2WC7-3XwVDd7R.js 0.22 kB │ gzip: 0.17 kB +#20 11.89 dist/assets/chunk-XXDRQBXY-OG-34K3d.js 0.22 kB │ gzip: 0.20 kB +#20 11.89 dist/assets/chunk-5VM5RSS4-wf5e4flP.js 0.36 kB │ gzip: 0.27 kB +#20 11.89 dist/assets/stateDiagram-v2-MP3YSRHH-BuYCCE5b.js 0.39 kB │ gzip: 0.29 kB +#20 11.89 dist/assets/classDiagram-ZZMXUADV-CVdTQJSv.js 0.43 kB │ gzip: 0.30 kB +#20 11.89 dist/assets/classDiagram-v2-VYDZK3BY-CVdTQJSv.js 0.43 kB │ gzip: 0.30 kB +#20 11.89 dist/assets/chunk-POPQ4Y6H-CrVio1q7.js 0.50 kB │ gzip: 0.35 kB +#20 11.89 dist/assets/swimlanesDiagram-VR7AAH4N-2vwyB05c.js 0.51 kB │ gzip: 0.35 kB +#20 11.89 dist/assets/infoDiagram-27XIBGKW-BgI1KvQI.js 0.55 kB │ gzip: 0.38 kB +#20 11.89 dist/assets/sizeCapture-INFHLROL-BimZQ6a6.js 0.90 kB │ gzip: 0.51 kB +#20 11.89 dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB +#20 11.89 dist/assets/railroadDiagram-O6MQD6OU-ByBV9o8N.js 1.57 kB │ gzip: 0.74 kB +#20 11.89 dist/assets/abnfDiagram-VCTEODGH-DNWwdfvR.js 1.77 kB │ gzip: 0.87 kB +#20 11.89 dist/assets/chunk-F27PBJKO-B3la_lkX.js 1.88 kB │ gzip: 0.83 kB +#20 11.89 dist/assets/pegDiagram-XKGWAZYB-BG_KoFQ6.js 1.92 kB │ gzip: 0.91 kB +#20 11.89 dist/assets/ebnfDiagram-PWID7BFC-DWQineoQ.js 1.97 kB │ gzip: 0.86 kB +#20 11.89 dist/assets/arc-CJvvPhq7.js 3.43 kB │ gzip: 1.48 kB +#20 11.89 dist/assets/diagram-Z3DM3KII-RF1V7gzL.js 4.18 kB │ gzip: 1.82 kB +#20 11.89 dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB +#20 11.89 dist/assets/linear-BZ30uBwb.js 5.65 kB │ gzip: 2.30 kB +#20 11.89 dist/assets/diagram-UQ7AKVKN-DZi9K3zn.js 6.04 kB │ gzip: 2.60 kB +#20 11.89 dist/assets/pieDiagram-E7YTZNPT-In23DzJs.js 6.26 kB │ gzip: 2.61 kB +#20 11.89 dist/assets/diagram-S7CK7UJ4-D3XxuaTU.js 8.18 kB │ gzip: 3.66 kB +#20 11.89 dist/assets/cynefinDiagram-5FMLGOSQ-QrrhMhDL.js 10.06 kB │ gzip: 3.61 kB +#20 11.89 dist/assets/stateDiagram-D77RDMKH-C8R2qgBn.js 10.33 kB │ gzip: 3.61 kB +#20 11.89 dist/assets/dagre-GXQ25YYZ-CM6w0zzF.js 10.64 kB │ gzip: 4.01 kB +#20 11.89 dist/assets/diagram-VSXAHHWV-DHbN8ibS.js 10.68 kB │ gzip: 4.11 kB +#20 11.89 dist/assets/chunk-SVP7TREG-CI8Y-3D2.js 15.73 kB │ gzip: 4.41 kB +#20 11.89 dist/assets/diagram-VX7I27RA-BvQOHXue.js 15.92 kB │ gzip: 5.68 kB +#20 11.89 dist/assets/ishikawaDiagram-5VMMS53U-Czj8Qul3.js 17.43 kB │ gzip: 6.62 kB +#20 11.89 dist/assets/kanban-definition-UXKFOSKX-BoJUFrcC.js 20.26 kB │ gzip: 7.18 kB +#20 11.89 dist/assets/mindmap-definition-YA3MSWOX-DWqHssQa.js 23.14 kB │ gzip: 7.80 kB +#20 11.89 dist/assets/sankeyDiagram-P5KCCOFB-DbuNAAw9.js 23.16 kB │ gzip: 8.54 kB +#20 11.89 dist/assets/journeyDiagram-3NMN7TZE-BKcwxO6D.js 23.54 kB │ gzip: 8.33 kB +#20 11.89 dist/assets/wardleyDiagram-VM6X3IG4-CX23Vy_T.js 25.47 kB │ gzip: 6.77 kB +#20 11.89 dist/assets/gitGraphDiagram-WWUBYQGX-Dn_exaMU.js 29.38 kB │ gzip: 8.68 kB +#20 11.89 dist/assets/erDiagram-RLTQ6QDP-B5NGgjOU.js 30.76 kB │ gzip: 10.57 kB +#20 11.89 dist/assets/timeline-definition-24CTP7MA-WeBLss97.js 30.79 kB │ gzip: 10.13 kB +#20 11.89 dist/assets/requirementDiagram-BXWQKSXE-CGDVit9U.js 30.97 kB │ gzip: 9.71 kB +#20 11.89 dist/assets/quadrantDiagram-AXDQQJYC-DtIrN-kE.js 33.98 kB │ gzip: 9.96 kB +#20 11.89 dist/assets/layout-CrY6-gw1.js 35.43 kB │ gzip: 12.74 kB +#20 11.89 dist/assets/chunk-IMKFNOWR-DL2S1Ocn.js 38.07 kB │ gzip: 12.51 kB +#20 11.89 dist/assets/blockDiagram-I7D4REHJ-4pQkRsYE.js 41.72 kB │ gzip: 13.56 kB +#20 11.89 dist/assets/vennDiagram-4TSXK5OY-mjOdrwai.js 41.93 kB │ gzip: 15.67 kB +#20 11.89 dist/assets/xychartDiagram-S5SC5T6Z-BHknuwFK.js 44.47 kB │ gzip: 12.58 kB +#20 11.89 dist/assets/chunk-TICWLB2K-upuxjo4B.js 49.17 kB │ gzip: 15.74 kB +#20 11.89 dist/assets/flowDiagram-HODETNUW-ChE8AD7S.js 61.84 kB │ gzip: 19.87 kB +#20 11.89 dist/assets/c4Diagram-7LVT6UL2-AvTEBjbZ.js 65.44 kB │ gzip: 18.77 kB +#20 11.89 dist/assets/ganttDiagram-EL5Y4UJY-CelQZFDx.js 69.66 kB │ gzip: 23.54 kB +#20 11.89 dist/assets/cose-bilkent-JH36ORCC-C3HMHbNW.js 81.66 kB │ gzip: 22.46 kB +#20 11.89 dist/assets/swimlanes-42K2YHIH-DfsRAiF7.js 115.76 kB │ gzip: 42.49 kB +#20 11.89 dist/assets/sequenceDiagram-WJ2MYXX4-DjJuHVj7.js 116.93 kB │ gzip: 31.11 kB +#20 11.89 dist/assets/architectureDiagram-5GKGNRK7-BgFHVZr9.js 151.79 kB │ gzip: 43.13 kB +#20 11.89 dist/assets/katex-HP8lGamR.js 261.31 kB │ gzip: 77.57 kB +#20 11.89 dist/assets/cytoscape.esm-BB4DxJjf.js 443.04 kB │ gzip: 141.98 kB +#20 11.89 dist/assets/cynefin-OW5HDTMX-BO97kahj.js 687.91 kB │ gzip: 154.14 kB +#20 11.89 dist/assets/index-Bu0CqZB_.js 2,820.06 kB │ gzip: 803.51 kB +#20 11.89 +#20 11.89 (!) Some chunks are larger than 500 kB after minification. Consider: +#20 11.89 - Using dynamic import() to code-split the application +#20 11.89 - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +#20 11.89 - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +#20 11.89 ✓ built in 8.05s +#20 11.99 precompress: 49 candidate(s) under /web/dist +#20 11.99 br assets/abnfDiagram-VCTEODGH-DNWwdfvR.js 1774 → 802 (−54.8%) +#20 11.99 gzip assets/abnfDiagram-VCTEODGH-DNWwdfvR.js 1774 → 874 (−50.7%) +#20 11.99 br assets/arc-CJvvPhq7.js 3434 → 1284 (−62.6%) +#20 11.99 gzip assets/arc-CJvvPhq7.js 3434 → 1475 (−57.0%) +#20 12.12 br assets/architectureDiagram-5GKGNRK7-BgFHVZr9.js 151788 → 36874 (−75.7%) +#20 12.12 gzip assets/architectureDiagram-5GKGNRK7-BgFHVZr9.js 151788 → 42956 (−71.7%) +#20 12.15 br assets/blockDiagram-I7D4REHJ-4pQkRsYE.js 41722 → 11974 (−71.3%) +#20 12.15 gzip assets/blockDiagram-I7D4REHJ-4pQkRsYE.js 41722 → 13491 (−67.7%) +#20 12.20 br assets/c4Diagram-7LVT6UL2-AvTEBjbZ.js 65438 → 15134 (−76.9%) +#20 12.20 gzip assets/c4Diagram-7LVT6UL2-AvTEBjbZ.js 65438 → 18726 (−71.4%) +#20 12.20 br assets/chunk-F27PBJKO-B3la_lkX.js 1876 → 732 (−61.0%) +#20 12.20 gzip assets/chunk-F27PBJKO-B3la_lkX.js 1876 → 827 (−55.9%) +#20 12.23 br assets/chunk-IMKFNOWR-DL2S1Ocn.js 38069 → 10968 (−71.2%) +#20 12.23 gzip assets/chunk-IMKFNOWR-DL2S1Ocn.js 38069 → 12492 (−67.2%) +#20 12.24 br assets/chunk-SVP7TREG-CI8Y-3D2.js 15728 → 3961 (−74.8%) +#20 12.24 gzip assets/chunk-SVP7TREG-CI8Y-3D2.js 15728 → 4409 (−72.0%) +#20 12.27 br assets/chunk-TICWLB2K-upuxjo4B.js 49171 → 13030 (−73.5%) +#20 12.27 gzip assets/chunk-TICWLB2K-upuxjo4B.js 49171 → 15733 (−68.0%) +#20 12.33 br assets/cose-bilkent-JH36ORCC-C3HMHbNW.js 81662 → 19601 (−76.0%) +#20 12.33 gzip assets/cose-bilkent-JH36ORCC-C3HMHbNW.js 81662 → 22363 (−72.6%) +#20 12.97 br assets/cynefin-OW5HDTMX-BO97kahj.js 687914 → 124608 (−81.9%) +#20 12.97 gzip assets/cynefin-OW5HDTMX-BO97kahj.js 687914 → 153500 (−77.7%) +#20 12.98 br assets/cynefinDiagram-5FMLGOSQ-QrrhMhDL.js 10062 → 3227 (−67.9%) +#20 12.98 gzip assets/cynefinDiagram-5FMLGOSQ-QrrhMhDL.js 10062 → 3612 (−64.1%) +#20 13.45 br assets/cytoscape.esm-BB4DxJjf.js 443036 → 119602 (−73.0%) +#20 13.45 gzip assets/cytoscape.esm-BB4DxJjf.js 443036 → 141625 (−68.0%) +#20 13.46 br assets/dagre-GXQ25YYZ-CM6w0zzF.js 10641 → 3656 (−65.6%) +#20 13.46 gzip assets/dagre-GXQ25YYZ-CM6w0zzF.js 10641 → 4008 (−62.3%) +#20 13.46 br assets/defaultLocale-DX6XiGOO.js 4688 → 2012 (−57.1%) +#20 13.46 gzip assets/defaultLocale-DX6XiGOO.js 4688 → 2182 (−53.5%) +#20 13.47 br assets/diagram-S7CK7UJ4-D3XxuaTU.js 8182 → 3317 (−59.5%) +#20 13.47 gzip assets/diagram-S7CK7UJ4-D3XxuaTU.js 8182 → 3657 (−55.3%) +#20 13.47 br assets/diagram-UQ7AKVKN-DZi9K3zn.js 6044 → 2351 (−61.1%) +#20 13.47 gzip assets/diagram-UQ7AKVKN-DZi9K3zn.js 6044 → 2603 (−56.9%) +#20 13.48 br assets/diagram-VSXAHHWV-DHbN8ibS.js 10681 → 3768 (−64.7%) +#20 13.48 gzip assets/diagram-VSXAHHWV-DHbN8ibS.js 10681 → 4113 (−61.5%) +#20 13.49 br assets/diagram-VX7I27RA-BvQOHXue.js 15922 → 5149 (−67.7%) +#20 13.49 gzip assets/diagram-VX7I27RA-BvQOHXue.js 15922 → 5679 (−64.3%) +#20 13.49 br assets/diagram-Z3DM3KII-RF1V7gzL.js 4176 → 1631 (−60.9%) +#20 13.49 gzip assets/diagram-Z3DM3KII-RF1V7gzL.js 4176 → 1816 (−56.5%) +#20 13.50 br assets/ebnfDiagram-PWID7BFC-DWQineoQ.js 1970 → 767 (−61.1%) +#20 13.50 gzip assets/ebnfDiagram-PWID7BFC-DWQineoQ.js 1970 → 861 (−56.3%) +#20 13.52 br assets/erDiagram-RLTQ6QDP-B5NGgjOU.js 30755 → 9165 (−70.2%) +#20 13.52 gzip assets/erDiagram-RLTQ6QDP-B5NGgjOU.js 30755 → 10569 (−65.6%) +#20 13.56 br assets/flowDiagram-HODETNUW-ChE8AD7S.js 61840 → 16328 (−73.6%) +#20 13.56 gzip assets/flowDiagram-HODETNUW-ChE8AD7S.js 61840 → 19857 (−67.9%) +#20 13.62 br assets/ganttDiagram-EL5Y4UJY-CelQZFDx.js 69656 → 20680 (−70.3%) +#20 13.62 gzip assets/ganttDiagram-EL5Y4UJY-CelQZFDx.js 69656 → 23513 (−66.2%) +#20 13.63 br assets/gitGraphDiagram-WWUBYQGX-Dn_exaMU.js 29382 → 7774 (−73.5%) +#20 13.63 gzip assets/gitGraphDiagram-WWUBYQGX-Dn_exaMU.js 29382 → 8662 (−70.5%) +#20 13.66 br assets/index-BYHTgAJJ.css 36824 → 8557 (−76.8%) +#20 13.66 gzip assets/index-BYHTgAJJ.css 36824 → 9920 (−73.1%) +#20 16.47 br assets/index-Bu0CqZB_.js 2820055 → 623898 (−77.9%) +#20 16.47 gzip assets/index-Bu0CqZB_.js 2820055 → 800721 (−71.6%) +#20 16.48 br assets/ishikawaDiagram-5VMMS53U-Czj8Qul3.js 17434 → 5995 (−65.6%) +#20 16.48 gzip assets/ishikawaDiagram-5VMMS53U-Czj8Qul3.js 17434 → 6613 (−62.1%) +#20 16.49 br assets/journeyDiagram-3NMN7TZE-BKcwxO6D.js 23536 → 7490 (−68.2%) +#20 16.49 gzip assets/journeyDiagram-3NMN7TZE-BKcwxO6D.js 23536 → 8316 (−64.7%) +#20 16.51 br assets/kanban-definition-UXKFOSKX-BoJUFrcC.js 20256 → 6393 (−68.4%) +#20 16.51 gzip assets/kanban-definition-UXKFOSKX-BoJUFrcC.js 20256 → 7169 (−64.6%) +#20 16.75 br assets/katex-HP8lGamR.js 261313 → 63694 (−75.6%) +#20 16.75 gzip assets/katex-HP8lGamR.js 261313 → 77166 (−70.5%) +#20 16.77 br assets/layout-CrY6-gw1.js 35429 → 11568 (−67.3%) +#20 16.77 gzip assets/layout-CrY6-gw1.js 35429 → 12719 (−64.1%) +#20 16.78 br assets/linear-BZ30uBwb.js 5654 → 2114 (−62.6%) +#20 16.78 gzip assets/linear-BZ30uBwb.js 5654 → 2304 (−59.3%) +#20 16.79 br assets/mindmap-definition-YA3MSWOX-DWqHssQa.js 23140 → 6973 (−69.9%) +#20 16.79 gzip assets/mindmap-definition-YA3MSWOX-DWqHssQa.js 23140 → 7789 (−66.3%) +#20 16.79 br assets/ordinal-Cboi1Yqb.js 1189 → 497 (−58.2%) +#20 16.79 gzip assets/ordinal-Cboi1Yqb.js 1189 → 573 (−51.8%) +#20 16.79 br assets/pegDiagram-XKGWAZYB-BG_KoFQ6.js 1921 → 800 (−58.4%) +#20 16.79 gzip assets/pegDiagram-XKGWAZYB-BG_KoFQ6.js 1921 → 905 (−52.9%) +#20 16.80 br assets/pieDiagram-E7YTZNPT-In23DzJs.js 6259 → 2385 (−61.9%) +#20 16.80 gzip assets/pieDiagram-E7YTZNPT-In23DzJs.js 6259 → 2605 (−58.4%) +#20 16.82 br assets/quadrantDiagram-AXDQQJYC-DtIrN-kE.js 33975 → 8512 (−74.9%) +#20 16.82 gzip assets/quadrantDiagram-AXDQQJYC-DtIrN-kE.js 33975 → 9922 (−70.8%) +#20 16.82 br assets/railroadDiagram-O6MQD6OU-ByBV9o8N.js 1571 → 665 (−57.7%) +#20 16.82 gzip assets/railroadDiagram-O6MQD6OU-ByBV9o8N.js 1571 → 735 (−53.2%) +#20 16.84 br assets/requirementDiagram-BXWQKSXE-CGDVit9U.js 30965 → 8235 (−73.4%) +#20 16.84 gzip assets/requirementDiagram-BXWQKSXE-CGDVit9U.js 30965 → 9698 (−68.7%) +#20 16.86 br assets/sankeyDiagram-P5KCCOFB-DbuNAAw9.js 23163 → 7808 (−66.3%) +#20 16.86 gzip assets/sankeyDiagram-P5KCCOFB-DbuNAAw9.js 23163 → 8534 (−63.2%) +#20 16.95 br assets/sequenceDiagram-WJ2MYXX4-DjJuHVj7.js 116927 → 25728 (−78.0%) +#20 16.95 gzip assets/sequenceDiagram-WJ2MYXX4-DjJuHVj7.js 116927 → 31047 (−73.4%) +#20 16.96 br assets/stateDiagram-D77RDMKH-C8R2qgBn.js 10326 → 3308 (−68.0%) +#20 16.96 gzip assets/stateDiagram-D77RDMKH-C8R2qgBn.js 10326 → 3602 (−65.1%) +#20 17.05 br assets/swimlanes-42K2YHIH-DfsRAiF7.js 115756 → 36665 (−68.3%) +#20 17.05 gzip assets/swimlanes-42K2YHIH-DfsRAiF7.js 115756 → 42418 (−63.4%) +#20 17.07 br assets/timeline-definition-24CTP7MA-WeBLss97.js 30794 → 9104 (−70.4%) +#20 17.07 gzip assets/timeline-definition-24CTP7MA-WeBLss97.js 30794 → 10117 (−67.1%) +#20 17.10 br assets/vennDiagram-4TSXK5OY-mjOdrwai.js 41926 → 13976 (−66.7%) +#20 17.10 gzip assets/vennDiagram-4TSXK5OY-mjOdrwai.js 41926 → 15661 (−62.6%) +#20 17.12 br assets/wardleyDiagram-VM6X3IG4-CX23Vy_T.js 25467 → 6071 (−76.2%) +#20 17.12 gzip assets/wardleyDiagram-VM6X3IG4-CX23Vy_T.js 25467 → 6742 (−73.5%) +#20 17.15 br assets/xychartDiagram-S5SC5T6Z-BHknuwFK.js 44474 → 11037 (−75.2%) +#20 17.15 gzip assets/xychartDiagram-S5SC5T6Z-BHknuwFK.js 44474 → 12554 (−71.8%) +#20 17.15 br fonts/OFL.txt 4474 → 1437 (−67.9%) +#20 17.15 gzip fonts/OFL.txt 4474 → 1993 (−55.5%) +#20 17.15 precompress: wrote 49 .br and 49 .gz +#20 DONE 17.2s + +#15 [go-builder 4/7] RUN go mod download +#15 DONE 80.6s + +#21 [go-builder 5/7] COPY . . +#21 DONE 0.5s + +#22 [go-builder 6/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server +#22 DONE 5.7s + +#23 [go-builder 7/7] RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /events ./cmd/events +#23 DONE 0.4s + +#24 [stage-2 4/9] COPY --from=go-builder /server /app/server +#24 DONE 0.0s + +#25 [stage-2 5/9] COPY --from=go-builder /events /app/events +#25 DONE 0.0s + +#26 [stage-2 6/9] COPY --from=web-builder /web/dist /app/web/dist +#26 DONE 0.0s + +#27 [stage-2 7/9] COPY deploy/postgres/goose /app/deploy/postgres/goose +#27 DONE 0.0s + +#28 [stage-2 8/9] COPY templates /app/templates +#28 DONE 0.0s + +#29 [stage-2 9/9] COPY configs /app/configs +#29 DONE 0.0s + +#30 exporting to image +#30 exporting layers +#30 exporting layers 0.5s done +#30 exporting manifest sha256:6ef5fa2036e4f1d6fdb7063202a61a3e4d391e7c3fdd3e40f70dc2d0b4f690b6 done +#30 exporting config sha256:1e0ba100057ae00271522987f0f8c5561ed9c07d0a69a6e73dbf57c861bd8cd7 done +#30 exporting attestation manifest sha256:e7f4f96b5f60af59fd926fd336d0feb2b0bf9f773eb481affba876c0c2a2866a done +#30 exporting manifest list sha256:2d74fe184585ec0d0d24a051cd6bbc0cfb5b2ef050a376a32027c8e442703cd1 done +#30 naming to docker.io/library/agent-dev-app:latest done +#30 unpacking to docker.io/library/agent-dev-app:latest +#30 unpacking to docker.io/library/agent-dev-app:latest 0.2s done +#30 DONE 0.8s + +#31 resolving provenance for metadata file +#31 DONE 0.0s + Image agent-dev-app Built diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/rebuild-start.txt b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/rebuild-start.txt new file mode 100644 index 00000000..6cc58849 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/rebuild-start.txt @@ -0,0 +1,2 @@ +2026-09-17T08:18:26.300033723Z +/shuimu-agent-app-dev StartedAt=2026-09-17T07:00:38.127811581Z Status=running diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/restart-timeline-nocache.txt b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/restart-timeline-nocache.txt new file mode 100644 index 00000000..81a004d8 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/restart-timeline-nocache.txt @@ -0,0 +1,5 @@ +StartedAt=2026-09-17T08:22:48.548364949Z +time=2026-09-17T08:22:48.660Z level=INFO msg="goose: no migrations to run. current version: 49" +time=2026-09-17T08:22:48.676Z level=INFO msg="[kb] online=true dsn=postgres://regkb:***@shuimu-kb-shared:5432/regulations?sslmode=disable" +监听地址: http://0.0.0.0:8080 +curl_ready_at=2026-09-17T08:23:03Z diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/restart-timeline.txt b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/restart-timeline.txt new file mode 100644 index 00000000..03d33562 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-perf-u2/restart-timeline.txt @@ -0,0 +1,33 @@ +=== timeline === +StartedAt=2026-09-17T08:19:20.17479594Z Status=running +time=2026-09-17T08:19:20.287Z level=INFO msg="goose: no migrations to run. current version: 49" +time=2026-09-17T08:19:20.321Z level=INFO msg="[kb] online=true dsn=postgres://regkb:***@shuimu-kb-shared:5432/regulations?sslmode=disable" +================================================================== + 🤖 医疗器械商务与研发智能参谋系统 - HTTP / SSE API 守护服务 +================================================================== +服务环境: dev | 智能体后端: pi-agent +监听地址: http://0.0.0.0:8080 +核心服务路由: + • [GET] /api/v1/health - 服务健康检查与基础设施状态 + • [GET] /api/v1/projects - 查询项目当前上下文与黑板事实 + • [POST] /api/v1/chat - 标准阻塞式对话协同接口 + • [POST] /api/v1/chat/stream - 核心 SSE 流式长连接对话协议 (分层按需) + • [GET] /api/v1/chat/stream - SSE 流式长连接对话调试接口 (EventSource) + • [POST] /api/v1/tasks/run/stream - 独立任务与阶段执行 SSE 监控流 (精简进度与拦截) + • [GET] /api/v1/context/user - 获取单用户私有上下文与习惯偏好画像 + • [PUT] /api/v1/context/user/preference - 整理或更新用户个性化产出风格 + • [GET] /api/v1/context/project - 获取项目多方协同黑板事实与里程碑决策流 + • [GET] /api/v1/chat/sessions - 分页查询用户/项目历史会话列表 + • [GET] /api/v1/chat/messages - 分页拉取指定会话多轮历史消息记录 + • [GET] /api/v1/context/cases/recommend - 往期受控标杆案例与历史报价语义自推荐 + • [GET] /api/v1/docgov/document - 查询受控文档治理状态看板 + • [POST] /api/v1/docgov/claim - 查阅并认领受控文档 (正式稿方可认领) + • [POST] /api/v1/docgov/proposal/decide - 编制负责人拍板同意/拒绝变更提案 + • [POST] /api/v1/docgov/submit-review - 编制完成提交审核 (文档正文锁定) + • [POST] /api/v1/docgov/review-decision - 审核通过或驳回 (驳回必填原因且解锁) + • [POST] /api/v1/docgov/approver-decision - 最终批准或驳回 + • [GET/POST] /api/v1/docgov/comments - 批注与修改建议管理 + • [GET] /api/v1/docgov/audit-logs - 全生命周期审计轨迹流 +================================================================== +--- health --- +2026-09-17T08:19:24.180176416Z health=200 ready=200 diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-performance/assets.json b/.doc/test-reports/assets/2026-09-17-initial-load-performance/assets.json new file mode 100644 index 00000000..2436beb9 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-performance/assets.json @@ -0,0 +1,74 @@ +[ + { + "port": 8080, + "asset": "/assets/index-BLnmtk_3.js", + "bytes": 2820055, + "gzip_estimate_bytes": 796146, + "http_ms": 2.55, + "headers": { + "Content-Length": "2820055", + "Content-Type": "text/javascript; charset=utf-8", + "Last-Modified": "Thu, 17 Sep 2026 07:00:36 GMT" + } + }, + { + "port": 8080, + "asset": "/assets/index-DhpBfxvE.css", + "bytes": 35504, + "gzip_estimate_bytes": 9682, + "http_ms": 0.83, + "headers": { + "Content-Length": "35504", + "Content-Type": "text/css; charset=utf-8", + "Last-Modified": "Thu, 17 Sep 2026 07:00:36 GMT" + } + }, + { + "port": 8081, + "asset": "/assets/index-Dk3TnkZv.js", + "bytes": 2805141, + "gzip_estimate_bytes": 791475, + "http_ms": 2.11, + "headers": { + "Content-Length": "2805141", + "Content-Type": "text/javascript; charset=utf-8", + "Last-Modified": "Thu, 17 Sep 2026 04:10:13 GMT" + } + }, + { + "port": 8081, + "asset": "/assets/index-DhpBfxvE.css", + "bytes": 35504, + "gzip_estimate_bytes": 9682, + "http_ms": 0.87, + "headers": { + "Content-Length": "35504", + "Content-Type": "text/css; charset=utf-8", + "Last-Modified": "Thu, 17 Sep 2026 04:10:13 GMT" + } + }, + { + "port": 8000, + "asset": "/assets/index-BT80OB_W.js", + "bytes": 2798137, + "gzip_estimate_bytes": 789362, + "http_ms": 2.6, + "headers": { + "Content-Length": "2798137", + "Content-Type": "text/javascript; charset=utf-8", + "Last-Modified": "Wed, 16 Sep 2026 04:14:21 GMT" + } + }, + { + "port": 8000, + "asset": "/assets/index-DhpBfxvE.css", + "bytes": 35504, + "gzip_estimate_bytes": 9682, + "http_ms": 0.89, + "headers": { + "Content-Length": "35504", + "Content-Type": "text/css; charset=utf-8", + "Last-Modified": "Wed, 16 Sep 2026 04:14:21 GMT" + } + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-performance/browser.json b/.doc/test-reports/assets/2026-09-17-initial-load-performance/browser.json new file mode 100644 index 00000000..16cb23ae --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-performance/browser.json @@ -0,0 +1,3319 @@ +[ + { + "port": 8080, + "mode": "local-cold", + "phase": "cold", + "visibleMs": 662, + "error": null, + "navigation": [ + { + "ttfb": 1.7000000476837158, + "dcl": 608.9000000357628, + "load": 612.4000000357628 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 616 + }, + { + "name": "first-contentful-paint", + "ms": 664 + } + ], + "resources": [ + { + "url": "https://fonts.googleapis.com/css2", + "ms": 508.2999999523163, + "bytes": 1249, + "decoded": 17342, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-DhpBfxvE.css", + "ms": 4, + "bytes": 35804, + "decoded": 35504, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-BLnmtk_3.js", + "ms": 7.399999976158142, + "bytes": 2820355, + "decoded": 2820055, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:8080/api/v1/auth/public-config", + "ms": 2.899999976158142, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [] + }, + { + "port": 8080, + "mode": "local-cold", + "phase": "warm", + "visibleMs": 62, + "error": null, + "navigation": [ + { + "ttfb": 0.800000011920929, + "dcl": 24.5, + "load": 26.399999976158142 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "resources": [ + { + "url": "https://fonts.googleapis.com/css2", + "ms": 0, + "bytes": 0, + "decoded": 17342, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-BLnmtk_3.js", + "ms": 0, + "bytes": 0, + "decoded": 2820055, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:8080/assets/index-DhpBfxvE.css", + "ms": 0, + "bytes": 0, + "decoded": 35504, + "initiator": "link" + }, + { + "url": "https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2", + "ms": 0, + "bytes": 0, + "decoded": 48256, + "initiator": "css" + }, + { + "url": "http://127.0.0.1:8080/api/v1/auth/public-config", + "ms": 2, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [] + }, + { + "port": 8080, + "mode": "local-font-blocked", + "phase": "cold", + "visibleMs": 210, + "error": null, + "navigation": [ + { + "ttfb": 5, + "dcl": 165, + "load": 166.19999998807907 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 20 + }, + { + "name": "first-contentful-paint", + "ms": 212 + } + ], + "resources": [ + { + "url": "https://fonts.googleapis.com/css2", + "ms": 5.399999976158142, + "bytes": 0, + "decoded": 0, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-DhpBfxvE.css", + "ms": 4.300000011920929, + "bytes": 35804, + "decoded": 35504, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-BLnmtk_3.js", + "ms": 16.69999998807907, + "bytes": 2820355, + "decoded": 2820055, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:8080/api/v1/auth/public-config", + "ms": 5, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [ + { + "url": "https://fonts.googleapis.com/css2", + "error": "net::ERR_FAILED" + } + ] + }, + { + "port": 8080, + "mode": "local-font-blocked", + "phase": "warm", + "visibleMs": 108, + "error": null, + "navigation": [ + { + "ttfb": 2.5, + "dcl": 89.5, + "load": 90.59999996423721 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 16 + }, + { + "name": "first-contentful-paint", + "ms": 108 + } + ], + "resources": [ + { + "url": "https://fonts.googleapis.com/css2", + "ms": 2.199999988079071, + "bytes": 0, + "decoded": 0, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-BLnmtk_3.js", + "ms": 7.199999988079071, + "bytes": 2820355, + "decoded": 2820055, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:8080/assets/index-DhpBfxvE.css", + "ms": 3.300000011920929, + "bytes": 35804, + "decoded": 35504, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/api/v1/auth/public-config", + "ms": 3.699999988079071, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [ + { + "url": "https://fonts.googleapis.com/css2", + "error": "net::ERR_FAILED" + }, + { + "url": "https://fonts.googleapis.com/css2", + "error": "net::ERR_FAILED" + } + ] + }, + { + "port": 8080, + "mode": "10Mbps-80ms", + "phase": "cold", + "visibleMs": 2636, + "error": null, + "navigation": [ + { + "ttfb": 1.7999999523162842, + "dcl": 2587.399999976158, + "load": 2588.7999999523163 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 548 + }, + { + "name": "first-contentful-paint", + "ms": 2636 + } + ], + "resources": [ + { + "url": "https://fonts.googleapis.com/css2", + "ms": 452.2000000476837, + "bytes": 1249, + "decoded": 17342, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-BLnmtk_3.js", + "ms": 2373, + "bytes": 2820355, + "decoded": 2820055, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:8080/assets/index-DhpBfxvE.css", + "ms": 141.69999998807907, + "bytes": 35804, + "decoded": 35504, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/api/v1/auth/public-config", + "ms": 85, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [] + }, + { + "port": 8080, + "mode": "10Mbps-80ms", + "phase": "warm", + "visibleMs": 60, + "error": null, + "navigation": [ + { + "ttfb": 0.699999988079071, + "dcl": 24.100000023841858, + "load": 25.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + }, + { + "name": "first-contentful-paint", + "ms": 60 + } + ], + "resources": [ + { + "url": "https://fonts.googleapis.com/css2", + "ms": 0, + "bytes": 0, + "decoded": 17342, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-BLnmtk_3.js", + "ms": 0, + "bytes": 0, + "decoded": 2820055, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:8080/assets/index-DhpBfxvE.css", + "ms": 0, + "bytes": 0, + "decoded": 35504, + "initiator": "link" + }, + { + "url": "https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2", + "ms": 0, + "bytes": 0, + "decoded": 48256, + "initiator": "css" + }, + { + "url": "http://127.0.0.1:8080/api/v1/auth/public-config", + "ms": 85.5, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [] + }, + { + "port": 3100, + "mode": "local-cold", + "phase": "cold", + "visibleMs": 659, + "error": null, + "navigation": [ + { + "ttfb": 2.5, + "dcl": 592.6999999880791, + "load": 597.6000000238419 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 600 + }, + { + "name": "first-contentful-paint", + "ms": 652 + } + ], + "resources": [ + { + "url": "http://127.0.0.1:3100/@vite/client", + "ms": 7.800000011920929, + "bytes": 179256, + "decoded": 178956, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/main.tsx", + "ms": 7.399999976158142, + "bytes": 2460, + "decoded": 2160, + "initiator": "script" + }, + { + "url": "https://fonts.googleapis.com/css2", + "ms": 488.2999999523163, + "bytes": 1249, + "decoded": 17342, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:3100/@react-refresh", + "ms": 8.199999988079071, + "bytes": 112194, + "decoded": 111894, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/react_jsx-dev-runtime.js", + "ms": 4.299999952316284, + "bytes": 12675, + "decoded": 12375, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/ErrorBoundary.tsx", + "ms": 6.900000035762787, + "bytes": 7774, + "decoded": 7474, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/App.tsx", + "ms": 8.699999988079071, + "bytes": 561653, + "decoded": 561353, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/react-dom_client.js", + "ms": 6.800000011920929, + "bytes": 1005579, + "decoded": 1005279, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/katex/dist/katex.min.css", + "ms": 6.899999976158142, + "bytes": 27244, + "decoded": 26944, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/styles/index.css", + "ms": 4.200000047683716, + "bytes": 8490, + "decoded": 8190, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/vite/dist/client/env.mjs", + "ms": 5.599999964237213, + "bytes": 3778, + "decoded": 3478, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-CS6ODKCK.js", + "ms": 1.899999976158142, + "bytes": 45864, + "decoded": 45564, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-ZPWHCABL.js", + "ms": 3.099999964237213, + "bytes": 10791, + "decoded": 10491, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/react.js", + "ms": 0.800000011920929, + "bytes": 1324, + "decoded": 1024, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/AppShell.tsx", + "ms": 7, + "bytes": 24235, + "decoded": 23935, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/MessageStream.tsx", + "ms": 7.100000023841858, + "bytes": 33090, + "decoded": 32790, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/InputArea.tsx", + "ms": 7.100000023841858, + "bytes": 66461, + "decoded": 66161, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/SlimTabBar.tsx", + "ms": 7.399999976158142, + "bytes": 95633, + "decoded": 95333, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/BlockRenderer.tsx", + "ms": 7.199999988079071, + "bytes": 57186, + "decoded": 56886, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DeckDrawer.tsx", + "ms": 7.5, + "bytes": 136773, + "decoded": 136473, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/BlockEditorModal.tsx", + "ms": 11.399999976158142, + "bytes": 185656, + "decoded": 185356, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DocumentRevisionToolbar.tsx", + "ms": 10.699999988079071, + "bytes": 39578, + "decoded": 39278, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/CommentSidePanel.tsx", + "ms": 10.900000035762787, + "bytes": 38458, + "decoded": 38158, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/FreeformDocumentActionBar.tsx", + "ms": 11, + "bytes": 47878, + "decoded": 47578, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DocGovActionBar.tsx", + "ms": 11.199999988079071, + "bytes": 92335, + "decoded": 92035, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/freeformDoc.ts", + "ms": 11, + "bytes": 18267, + "decoded": 17967, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/uploadFreeformRegister.ts", + "ms": 13.099999964237213, + "bytes": 14208, + "decoded": 13908, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatSessions.ts", + "ms": 13.199999988079071, + "bytes": 4582, + "decoded": 4282, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectDocuments.ts", + "ms": 13.100000023841858, + "bytes": 6746, + "decoded": 6446, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/revisionToolbarMount.ts", + "ms": 13.200000047683716, + "bytes": 10119, + "decoded": 9819, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectWorkflow.ts", + "ms": 13.200000047683716, + "bytes": 13389, + "decoded": 13089, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectMembers.ts", + "ms": 13.199999988079071, + "bytes": 7683, + "decoded": 7383, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/createProjectMembersPersist.ts", + "ms": 14.399999976158142, + "bytes": 9597, + "decoded": 9297, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/afterProjectMemberPersisted.ts", + "ms": 13.800000011920929, + "bytes": 1113, + "decoded": 813, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/createProjectSubmitGuard.ts", + "ms": 13.700000047683716, + "bytes": 3834, + "decoded": 3534, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectMemberAdapter.ts", + "ms": 14.100000023841858, + "bytes": 5618, + "decoded": 5318, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/deliverableSync.ts", + "ms": 14.199999988079071, + "bytes": 12328, + "decoded": 12028, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/currentDocRole.ts", + "ms": 14.300000011920929, + "bytes": 4523, + "decoded": 4223, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/commentPolicy.ts", + "ms": 14.600000023841858, + "bytes": 1857, + "decoded": 1557, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/taskRunReconnect.ts", + "ms": 14.899999976158142, + "bytes": 3397, + "decoded": 3097, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/postTaskDeliverableSync.ts", + "ms": 15.199999988079071, + "bytes": 10639, + "decoded": 10339, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/blockedGovernanceCopy.ts", + "ms": 15.5, + "bytes": 2770, + "decoded": 2470, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/controlledDocHydrate.ts", + "ms": 16, + "bytes": 24528, + "decoded": 24228, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/deliverableLeaseSync.ts", + "ms": 15.699999988079071, + "bytes": 2983, + "decoded": 2683, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/controlledContentInvalidation.ts", + "ms": 16, + "bytes": 7329, + "decoded": 7029, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/editorHydrationGate.ts", + "ms": 16.30000001192093, + "bytes": 8041, + "decoded": 7741, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/blockSavePolicy.ts", + "ms": 16.600000023841858, + "bytes": 7485, + "decoded": 7185, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/saveControlledBlockEdit.ts", + "ms": 17, + "bytes": 12211, + "decoded": 11911, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/workflow/StaleDocumentNotice.tsx", + "ms": 17.100000023841858, + "bytes": 10758, + "decoded": 10458, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/workflow/StageAdvanceBanner.tsx", + "ms": 17.30000001192093, + "bytes": 25216, + "decoded": 24916, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/replyExport/title.ts", + "ms": 17.5, + "bytes": 5294, + "decoded": 4994, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/replyExport/clipboard.ts", + "ms": 17.5, + "bytes": 11070, + "decoded": 10770, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/replyExport/download.ts", + "ms": 17.899999976158142, + "bytes": 9832, + "decoded": 9532, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/ProposalConfirmCard.tsx", + "ms": 18.899999976158142, + "bytes": 16180, + "decoded": 15880, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/AdminConsoleView.tsx", + "ms": 19.30000001192093, + "bytes": 52908, + "decoded": 52608, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/user/UserProjectRightsModal.tsx", + "ms": 19.5, + "bytes": 111367, + "decoded": 111067, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/user/UserSettingsModal.tsx", + "ms": 20.5, + "bytes": 167780, + "decoded": 167480, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DocVisibilityModal.tsx", + "ms": 20.899999976158142, + "bytes": 82338, + "decoded": 82038, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/project/CreateProjectModal.tsx", + "ms": 21.100000023841858, + "bytes": 107245, + "decoded": 106945, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/project/ProjectMembersModal.tsx", + "ms": 21.80000001192093, + "bytes": 92754, + "decoded": 92454, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectSettings.ts", + "ms": 22.19999998807907, + "bytes": 21298, + "decoded": 20998, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/project/ProjectSettingsModal.tsx", + "ms": 22.19999998807907, + "bytes": 127459, + "decoded": 127159, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/FilePreviewPanel.tsx", + "ms": 22.69999998807907, + "bytes": 109723, + "decoded": 109423, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/auth/FirstLoginPasswordModal.tsx", + "ms": 25.299999952316284, + "bytes": 44294, + "decoded": 43994, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/auth/LoginView.tsx", + "ms": 25.69999998807907, + "bytes": 134797, + "decoded": 134497, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/lucide-react.js", + "ms": 30.599999964237213, + "bytes": 1201493, + "decoded": 1201193, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/auth.ts", + "ms": 25.19999998807907, + "bytes": 39123, + "decoded": 38823, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatStream.ts", + "ms": 25.19999998807907, + "bytes": 16678, + "decoded": 16378, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/taskStream.ts", + "ms": 25.30000001192093, + "bytes": 9053, + "decoded": 8753, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatRetry.ts", + "ms": 26.599999964237213, + "bytes": 3510, + "decoded": 3210, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/phaseEventMapper.ts", + "ms": 27.19999998807907, + "bytes": 11251, + "decoded": 10951, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/markdownToBlock.ts", + "ms": 27.200000047683716, + "bytes": 25519, + "decoded": 25219, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/blockToMarkdown.ts", + "ms": 27.30000001192093, + "bytes": 11283, + "decoded": 10983, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/autoNumber.ts", + "ms": 27.400000035762787, + "bytes": 13219, + "decoded": 12919, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/rbac.ts", + "ms": 27.80000001192093, + "bytes": 16608, + "decoded": 16308, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/xlsxRenderer.ts", + "ms": 29.399999976158142, + "bytes": 17607, + "decoded": 17307, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/cardActionEngine.ts", + "ms": 29.5, + "bytes": 28840, + "decoded": 28540, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/docxRenderer.ts", + "ms": 29.600000023841858, + "bytes": 128235, + "decoded": 127935, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/planCard.ts", + "ms": 29.5, + "bytes": 9544, + "decoded": 9244, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/intentPlans.ts", + "ms": 29.5, + "bytes": 8260, + "decoded": 7960, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/demoSeeds.disabled.ts", + "ms": 30.600000023841858, + "bytes": 2741, + "decoded": 2441, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/templateMatch.ts", + "ms": 31.600000023841858, + "bytes": 24452, + "decoded": 24152, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/templateFactory.ts", + "ms": 31.5, + "bytes": 61326, + "decoded": 61026, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/storage.ts", + "ms": 31.69999998807907, + "bytes": 39636, + "decoded": 39336, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useChatWindow.ts", + "ms": 31.69999998807907, + "bytes": 62781, + "decoded": 62481, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useTurnRuntime.ts", + "ms": 32.30000001192093, + "bytes": 11925, + "decoded": 11625, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatTurn.ts", + "ms": 33, + "bytes": 3173, + "decoded": 2873, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useTaskRunStream.ts", + "ms": 33.19999998807907, + "bytes": 8166, + "decoded": 7866, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/mergeSessionUser.ts", + "ms": 33, + "bytes": 1541, + "decoded": 1241, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/createClientTurnId.ts", + "ms": 33.200000047683716, + "bytes": 4145, + "decoded": 3845, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectAdapter.ts", + "ms": 33.30000001192093, + "bytes": 6711, + "decoded": 6411, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/sessionAdapter.ts", + "ms": 33.90000003576279, + "bytes": 9663, + "decoded": 9363, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/CategoryFilterBar.tsx", + "ms": 34.30000001192093, + "bytes": 44375, + "decoded": 44075, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/HistoryCalendar.tsx", + "ms": 34.89999997615814, + "bytes": 40216, + "decoded": 39916, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/ChatSearchModal.tsx", + "ms": 34.89999997615814, + "bytes": 54064, + "decoded": 53764, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/DigestTimelineModal.tsx", + "ms": 35.09999996423721, + "bytes": 66716, + "decoded": 66416, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/anchorNavigator.ts", + "ms": 35.10000002384186, + "bytes": 19512, + "decoded": 19212, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/ChatExportModal.tsx", + "ms": 35.5, + "bytes": 33940, + "decoded": 33640, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useUnreadMarks.ts", + "ms": 36.10000002384186, + "bytes": 26481, + "decoded": 26181, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/CategoryDock.tsx", + "ms": 37, + "bytes": 58083, + "decoded": 57783, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatWindow.ts", + "ms": 37.30000001192093, + "bytes": 32301, + "decoded": 32001, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/fileStore.ts", + "ms": 37.60000002384186, + "bytes": 15953, + "decoded": 15653, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectAssets.ts", + "ms": 36.80000001192093, + "bytes": 3267, + "decoded": 2967, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/taskRuns.ts", + "ms": 37.80000001192093, + "bytes": 15100, + "decoded": 14800, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-XC5FPUN5.js", + "ms": 35.80000001192093, + "bytes": 16514, + "decoded": 16214, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/Topbar.tsx", + "ms": 34.5, + "bytes": 30172, + "decoded": 29872, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/LeftRail.tsx", + "ms": 34.69999998807907, + "bytes": 57696, + "decoded": 57396, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/ResizeHandle.tsx", + "ms": 34.89999997615814, + "bytes": 20033, + "decoded": 19733, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/ChatColumn.tsx", + "ms": 35.19999998807907, + "bytes": 4117, + "decoded": 3817, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/RightDocRail.tsx", + "ms": 35.39999997615814, + "bytes": 4631, + "decoded": 4331, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/MessageBubble.tsx", + "ms": 35.799999952316284, + "bytes": 89357, + "decoded": 89057, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/DayDivider.tsx", + "ms": 36.10000002384186, + "bytes": 18471, + "decoded": 18171, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useVirtualMessages.ts", + "ms": 36.19999998807907, + "bytes": 11886, + "decoded": 11586, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/inputAreaSendLogic.ts", + "ms": 35.59999996423721, + "bytes": 2292, + "decoded": 1992, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/QuoteChip.tsx", + "ms": 36.5, + "bytes": 11176, + "decoded": 10876, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/overlayMatch.ts", + "ms": 36.39999997615814, + "bytes": 6232, + "decoded": 5932, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/HeadingBlockView.tsx", + "ms": 36.60000002384186, + "bytes": 8801, + "decoded": 8501, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/ParagraphBlockView.tsx", + "ms": 37.19999998807907, + "bytes": 8481, + "decoded": 8181, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/TableBlockView.tsx", + "ms": 37.39999997615814, + "bytes": 16488, + "decoded": 16188, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/CalloutBlockView.tsx", + "ms": 37.89999997615814, + "bytes": 9925, + "decoded": 9625, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/MermaidBlockView.tsx", + "ms": 38, + "bytes": 31695, + "decoded": 31395, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/MathBlockView.tsx", + "ms": 38.10000002384186, + "bytes": 13994, + "decoded": 13694, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/ListBlockView.tsx", + "ms": 38.09999996423721, + "bytes": 12358, + "decoded": 12058, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/documentBlockDom.ts", + "ms": 38.80000001192093, + "bytes": 1565, + "decoded": 1265, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/BlockContextMenu.tsx", + "ms": 39.30000001192093, + "bytes": 40373, + "decoded": 40073, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/demoSeedIds.ts", + "ms": 39.39999997615814, + "bytes": 4396, + "decoded": 4096, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/toolbarRefresh.ts", + "ms": 37.10000002384186, + "bytes": 2554, + "decoded": 2254, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/types.ts", + "ms": 37.40000003576279, + "bytes": 6038, + "decoded": 5738, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/changeReportView.ts", + "ms": 37.5, + "bytes": 10442, + "decoded": 10142, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/documentRevision.ts", + "ms": 38.09999996423721, + "bytes": 9747, + "decoded": 9447, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/VersionHistoryDrawer.tsx", + "ms": 38.69999998807907, + "bytes": 13375, + "decoded": 13075, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/VersionCompareView.tsx", + "ms": 38.80000001192093, + "bytes": 11352, + "decoded": 11052, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/ManualChangeOverlay.tsx", + "ms": 38.89999997615814, + "bytes": 3398, + "decoded": 3098, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/ChangeReportPanel.tsx", + "ms": 39, + "bytes": 43838, + "decoded": 43538, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/authHeaders.ts", + "ms": 38.30000001192093, + "bytes": 4690, + "decoded": 4390, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/docGovActionVisibility.ts", + "ms": 38.5, + "bytes": 2583, + "decoded": 2283, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/blockEditorDialog.ts", + "ms": 39.89999997615814, + "bytes": 1902, + "decoded": 1602, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/renderMarkdown.ts", + "ms": 40.30000001192093, + "bytes": 27050, + "decoded": 26750, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/katex.js", + "ms": 42, + "bytes": 486244, + "decoded": 485944, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectDocumentDuty.ts", + "ms": 38.30000001192093, + "bytes": 4327, + "decoded": 4027, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/revisionEtag.ts", + "ms": 35.69999998807907, + "bytes": 3154, + "decoded": 2854, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/replyDocument/parseReplyMarkdown.ts", + "ms": 34.200000047683716, + "bytes": 30614, + "decoded": 30314, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/replyDocument/renderClipboardHtml.ts", + "ms": 35.30000001192093, + "bytes": 21452, + "decoded": 21152, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/replyDocument/renderPlainText.ts", + "ms": 35.19999998807907, + "bytes": 7610, + "decoded": 7310, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/docx.js", + "ms": 39.69999998807907, + "bytes": 781112, + "decoded": 780812, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/systemAdmin.ts", + "ms": 35.700000047683716, + "bytes": 6247, + "decoded": 5947, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/DocsSection.tsx", + "ms": 36.200000047683716, + "bytes": 20928, + "decoded": 20628, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/FeaturesSection.tsx", + "ms": 36.19999998807907, + "bytes": 24643, + "decoded": 24343, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/KbSection.tsx", + "ms": 34.799999952316284, + "bytes": 15700, + "decoded": 15400, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/OverviewSection.tsx", + "ms": 36.09999996423721, + "bytes": 19379, + "decoded": 19079, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/RolesSection.tsx", + "ms": 37.09999996423721, + "bytes": 64452, + "decoded": 64152, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/SettingsSection.tsx", + "ms": 37.59999996423721, + "bytes": 38731, + "decoded": 38431, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/UsersSection.tsx", + "ms": 39.59999996423721, + "bytes": 157631, + "decoded": 157331, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/WorkflowsSection.tsx", + "ms": 38.90000003576279, + "bytes": 27859, + "decoded": 27559, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/dutyCellAssertion.ts", + "ms": 38.799999952316284, + "bytes": 7319, + "decoded": 7019, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectSettingsError.ts", + "ms": 36.5, + "bytes": 9737, + "decoded": 9437, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectSettingsMode.ts", + "ms": 36.5, + "bytes": 6522, + "decoded": 6222, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectSettingsDuties.ts", + "ms": 36.59999996423721, + "bytes": 11781, + "decoded": 11481, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/DocxViewer.tsx", + "ms": 37.30000001192093, + "bytes": 23651, + "decoded": 23351, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/PdfViewer.tsx", + "ms": 37.5, + "bytes": 16591, + "decoded": 16291, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/ExcelViewer.tsx", + "ms": 37.90000003576279, + "bytes": 51102, + "decoded": 50802, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/ImageViewer.tsx", + "ms": 37.90000003576279, + "bytes": 30007, + "decoded": 29707, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/PptViewer.tsx", + "ms": 37.89999997615814, + "bytes": 32606, + "decoded": 32306, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/TextViewer.tsx", + "ms": 38.10000002384186, + "bytes": 26074, + "decoded": 25774, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/authUserMap.ts", + "ms": 35.700000047683716, + "bytes": 11311, + "decoded": 11011, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/xlsx.js", + "ms": 40.09999996423721, + "bytes": 871002, + "decoded": 870702, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/mathml2omml.js", + "ms": 33.59999996423721, + "bytes": 58067, + "decoded": 57767, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/mermaid.js", + "ms": 33.799999952316284, + "bytes": 61947, + "decoded": 61647, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/sessionPrivacy.ts", + "ms": 31.899999976158142, + "bytes": 18414, + "decoded": 18114, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/categoryHelper.ts", + "ms": 31.900000035762787, + "bytes": 8031, + "decoded": 7731, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/projectsStorage.ts", + "ms": 31.900000035762787, + "bytes": 7066, + "decoded": 6766, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/messageIdentity.ts", + "ms": 34.39999997615814, + "bytes": 7426, + "decoded": 7126, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatSearch.ts", + "ms": 31.400000035762787, + "bytes": 4342, + "decoded": 4042, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatDigest.ts", + "ms": 31.5, + "bytes": 4297, + "decoded": 3997, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/chatExporter.ts", + "ms": 31.400000035762787, + "bytes": 20536, + "decoded": 20236, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/workflow/ProjectWorkflowSwitcher.tsx", + "ms": 28.399999976158142, + "bytes": 21098, + "decoded": 20798, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/PhaseEventStrip.tsx", + "ms": 28, + "bytes": 12431, + "decoded": 12131, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/RecommendedCaseCard.tsx", + "ms": 28.299999952316284, + "bytes": 21811, + "decoded": 21511, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/ReplyActionBar.tsx", + "ms": 28.799999952316284, + "bytes": 58088, + "decoded": 57788, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/InteractiveCard.tsx", + "ms": 29, + "bytes": 108123, + "decoded": 107823, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/QuoteCard.tsx", + "ms": 28.80000001192093, + "bytes": 14851, + "decoded": 14551, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/@tanstack_react-virtual.js", + "ms": 29.600000023841858, + "bytes": 55450, + "decoded": 55150, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DiffNavigator.tsx", + "ms": 22.600000023841858, + "bytes": 4126, + "decoded": 3826, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/replyDocument/sanitize.ts", + "ms": 21.099999964237213, + "bytes": 3683, + "decoded": 3383, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/sectionCommon.tsx", + "ms": 18.80000001192093, + "bytes": 19378, + "decoded": 19078, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/workflowAdmin.ts", + "ms": 17.30000001192093, + "bytes": 6696, + "decoded": 6396, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/adminUserDirectorySync.ts", + "ms": 12.5, + "bytes": 2225, + "decoded": 1925, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/docx-preview.js", + "ms": 16.30000001192093, + "bytes": 282866, + "decoded": 282566, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-ITXWXQJC.js", + "ms": 9.399999976158142, + "bytes": 1096, + "decoded": 796, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-FEGXS4JI.js", + "ms": 10.299999952316284, + "bytes": 110936, + "decoded": 110636, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-GAIOZJDU.js", + "ms": 9.899999976158142, + "bytes": 4677, + "decoded": 4377, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-U7ZX5J4J.js", + "ms": 10, + "bytes": 23395, + "decoded": 23095, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-NXU7RDSS.js", + "ms": 11.699999988079071, + "bytes": 298933, + "decoded": 298633, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-FDYYSBBD.js", + "ms": 11, + "bytes": 63220, + "decoded": 62920, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-Y3XSBPFI.js", + "ms": 11.600000023841858, + "bytes": 19612, + "decoded": 19312, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-WL72QCDN.js", + "ms": 11.099999964237213, + "bytes": 3185, + "decoded": 2885, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-FRR6AZWJ.js", + "ms": 12.699999988079071, + "bytes": 4216, + "decoded": 3916, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-COPWCGZV.js", + "ms": 12.600000023841858, + "bytes": 42996, + "decoded": 42696, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-XCCHLWO4.js", + "ms": 13.100000023841858, + "bytes": 223219, + "decoded": 222919, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-H7HDRDEB.js", + "ms": 12.899999976158142, + "bytes": 21493, + "decoded": 21193, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-DSWUIY5W.js", + "ms": 13.099999964237213, + "bytes": 82352, + "decoded": 82052, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-VOQDZQCU.js", + "ms": 13.699999988079071, + "bytes": 198884, + "decoded": 198584, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-JOUS4FQN.js", + "ms": 13.800000011920929, + "bytes": 4230, + "decoded": 3930, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-WCEIGMRM.js", + "ms": 14.900000035762787, + "bytes": 360238, + "decoded": 359938, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-ZC5CJRJ4.js", + "ms": 14.399999976158142, + "bytes": 702, + "decoded": 402, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-TFVCUBPE.js", + "ms": 15.100000023841858, + "bytes": 300324, + "decoded": 300024, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/api/v1/auth/public-config", + "ms": 3.5, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [] + }, + { + "port": 3100, + "mode": "local-cold", + "phase": "warm", + "visibleMs": 162, + "error": null, + "navigation": [ + { + "ttfb": 4.199999988079071, + "dcl": 88, + "load": 92.29999995231628 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 96 + }, + { + "name": "first-contentful-paint", + "ms": 160 + } + ], + "resources": [ + { + "url": "http://127.0.0.1:3100/@vite/client", + "ms": 2.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "https://fonts.googleapis.com/css2", + "ms": 0, + "bytes": 0, + "decoded": 17342, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:3100/src/main.tsx", + "ms": 2.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/@react-refresh", + "ms": 0.800000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/vite/dist/client/env.mjs", + "ms": 0.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2", + "ms": 0, + "bytes": 0, + "decoded": 48256, + "initiator": "css" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/react_jsx-dev-runtime.js", + "ms": 0, + "bytes": 0, + "decoded": 12375, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/react-dom_client.js", + "ms": 0, + "bytes": 0, + "decoded": 1005279, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/App.tsx", + "ms": 1.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/ErrorBoundary.tsx", + "ms": 1.2000000476837158, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/katex/dist/katex.min.css", + "ms": 1.4000000357627869, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/styles/index.css", + "ms": 1.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-CS6ODKCK.js", + "ms": 0, + "bytes": 0, + "decoded": 45564, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-ZPWHCABL.js", + "ms": 0, + "bytes": 0, + "decoded": 10491, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-XC5FPUN5.js", + "ms": 0, + "bytes": 0, + "decoded": 16214, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/react.js", + "ms": 0, + "bytes": 0, + "decoded": 1024, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/AppShell.tsx", + "ms": 1.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/MessageStream.tsx", + "ms": 2.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/InputArea.tsx", + "ms": 4.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/SlimTabBar.tsx", + "ms": 5.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DeckDrawer.tsx", + "ms": 6.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/BlockRenderer.tsx", + "ms": 8.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/BlockEditorModal.tsx", + "ms": 9.299999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DocumentRevisionToolbar.tsx", + "ms": 11.099999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/CommentSidePanel.tsx", + "ms": 11.199999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DocGovActionBar.tsx", + "ms": 11.300000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/FreeformDocumentActionBar.tsx", + "ms": 11.300000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/freeformDoc.ts", + "ms": 11.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/uploadFreeformRegister.ts", + "ms": 11.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatSessions.ts", + "ms": 12.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectDocuments.ts", + "ms": 12.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/revisionToolbarMount.ts", + "ms": 12.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectWorkflow.ts", + "ms": 12.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectMembers.ts", + "ms": 13, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/createProjectMembersPersist.ts", + "ms": 13.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/afterProjectMemberPersisted.ts", + "ms": 13.199999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/createProjectSubmitGuard.ts", + "ms": 13.199999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectMemberAdapter.ts", + "ms": 13.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/deliverableSync.ts", + "ms": 13.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/currentDocRole.ts", + "ms": 13.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/commentPolicy.ts", + "ms": 14, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/postTaskDeliverableSync.ts", + "ms": 14.199999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/taskRunReconnect.ts", + "ms": 14.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/blockedGovernanceCopy.ts", + "ms": 14.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/controlledDocHydrate.ts", + "ms": 14.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/controlledContentInvalidation.ts", + "ms": 14.800000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/deliverableLeaseSync.ts", + "ms": 14.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/editorHydrationGate.ts", + "ms": 15, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/blockSavePolicy.ts", + "ms": 15.199999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/saveControlledBlockEdit.ts", + "ms": 15.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/workflow/StaleDocumentNotice.tsx", + "ms": 15.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/workflow/StageAdvanceBanner.tsx", + "ms": 15.800000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/replyExport/title.ts", + "ms": 15.800000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/replyExport/clipboard.ts", + "ms": 15.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/replyExport/download.ts", + "ms": 16, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/ProposalConfirmCard.tsx", + "ms": 16.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/user/UserProjectRightsModal.tsx", + "ms": 16.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/user/UserSettingsModal.tsx", + "ms": 16.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/AdminConsoleView.tsx", + "ms": 16.799999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DocVisibilityModal.tsx", + "ms": 16.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/project/CreateProjectModal.tsx", + "ms": 17, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/project/ProjectMembersModal.tsx", + "ms": 17.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/project/ProjectSettingsModal.tsx", + "ms": 17.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectSettings.ts", + "ms": 18, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/FilePreviewPanel.tsx", + "ms": 18.099999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/lucide-react.js", + "ms": 0, + "bytes": 0, + "decoded": 1201193, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/auth/LoginView.tsx", + "ms": 18.200000047683716, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/auth/FirstLoginPasswordModal.tsx", + "ms": 18.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/auth.ts", + "ms": 19, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatStream.ts", + "ms": 19.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/taskStream.ts", + "ms": 19.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatRetry.ts", + "ms": 19.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/phaseEventMapper.ts", + "ms": 19.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/markdownToBlock.ts", + "ms": 19.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/blockToMarkdown.ts", + "ms": 20.099999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/autoNumber.ts", + "ms": 20.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/rbac.ts", + "ms": 20.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/docxRenderer.ts", + "ms": 20.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/xlsxRenderer.ts", + "ms": 20.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/cardActionEngine.ts", + "ms": 20.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/planCard.ts", + "ms": 20.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/intentPlans.ts", + "ms": 21, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/demoSeeds.disabled.ts", + "ms": 21.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/templateFactory.ts", + "ms": 21.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/templateMatch.ts", + "ms": 21.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/storage.ts", + "ms": 21.700000047683716, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useChatWindow.ts", + "ms": 21.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useTurnRuntime.ts", + "ms": 22.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useTaskRunStream.ts", + "ms": 22.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatTurn.ts", + "ms": 22.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/createClientTurnId.ts", + "ms": 22.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/mergeSessionUser.ts", + "ms": 22.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectAdapter.ts", + "ms": 22.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/sessionAdapter.ts", + "ms": 22.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/CategoryFilterBar.tsx", + "ms": 23, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/HistoryCalendar.tsx", + "ms": 23.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/ChatSearchModal.tsx", + "ms": 23.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/DigestTimelineModal.tsx", + "ms": 23.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/anchorNavigator.ts", + "ms": 24.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/ChatExportModal.tsx", + "ms": 24.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useUnreadMarks.ts", + "ms": 24.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/CategoryDock.tsx", + "ms": 24.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatWindow.ts", + "ms": 24.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/fileStore.ts", + "ms": 24.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectAssets.ts", + "ms": 25, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/taskRuns.ts", + "ms": 25.200000047683716, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/Topbar.tsx", + "ms": 24.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/LeftRail.tsx", + "ms": 24.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/ResizeHandle.tsx", + "ms": 24.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/ChatColumn.tsx", + "ms": 24.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/layout/RightDocRail.tsx", + "ms": 24.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/MessageBubble.tsx", + "ms": 24.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/history/DayDivider.tsx", + "ms": 25.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/hooks/useVirtualMessages.ts", + "ms": 25.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/inputAreaSendLogic.ts", + "ms": 24.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/QuoteChip.tsx", + "ms": 24.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/demoSeedIds.ts", + "ms": 22.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/overlayMatch.ts", + "ms": 21.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/HeadingBlockView.tsx", + "ms": 21.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/ParagraphBlockView.tsx", + "ms": 21.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/TableBlockView.tsx", + "ms": 21.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/CalloutBlockView.tsx", + "ms": 22, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/MermaidBlockView.tsx", + "ms": 22.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/MathBlockView.tsx", + "ms": 22.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/blocks/ListBlockView.tsx", + "ms": 23.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/BlockContextMenu.tsx", + "ms": 23.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/documentBlockDom.ts", + "ms": 23.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/katex.js", + "ms": 0, + "bytes": 0, + "decoded": 485944, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/renderMarkdown.ts", + "ms": 23.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/blockEditorDialog.ts", + "ms": 23.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/types.ts", + "ms": 21.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/toolbarRefresh.ts", + "ms": 22, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/features/documentRevision/changeReportView.ts", + "ms": 22.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/documentRevision.ts", + "ms": 22.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/VersionHistoryDrawer.tsx", + "ms": 22.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/VersionCompareView.tsx", + "ms": 22.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/ChangeReportPanel.tsx", + "ms": 22.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/ManualChangeOverlay.tsx", + "ms": 22.799999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/docGovActionVisibility.ts", + "ms": 22.700000047683716, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/authHeaders.ts", + "ms": 22.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectDocumentDuty.ts", + "ms": 21.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/revisionEtag.ts", + "ms": 19.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/replyDocument/parseReplyMarkdown.ts", + "ms": 18.299999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/replyDocument/renderClipboardHtml.ts", + "ms": 18.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/replyDocument/renderPlainText.ts", + "ms": 18.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/docx.js", + "ms": 0, + "bytes": 0, + "decoded": 780812, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/dutyCellAssertion.ts", + "ms": 18.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/systemAdmin.ts", + "ms": 18, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/DocsSection.tsx", + "ms": 18.299999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/FeaturesSection.tsx", + "ms": 18.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/KbSection.tsx", + "ms": 18.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/OverviewSection.tsx", + "ms": 18.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/RolesSection.tsx", + "ms": 19, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/SettingsSection.tsx", + "ms": 19.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/UsersSection.tsx", + "ms": 19.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/WorkflowsSection.tsx", + "ms": 19.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/projectSettingsError.ts", + "ms": 18.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectSettingsMode.ts", + "ms": 18.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/projectSettingsDuties.ts", + "ms": 19, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/DocxViewer.tsx", + "ms": 18.799999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/PdfViewer.tsx", + "ms": 19, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/ExcelViewer.tsx", + "ms": 19.299999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/ImageViewer.tsx", + "ms": 19.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/PptViewer.tsx", + "ms": 19.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/preview/renderers/TextViewer.tsx", + "ms": 20.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/authUserMap.ts", + "ms": 19.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/mathml2omml.js", + "ms": 0, + "bytes": 0, + "decoded": 57767, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/mermaid.js", + "ms": 0, + "bytes": 0, + "decoded": 61647, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-ITXWXQJC.js", + "ms": 0, + "bytes": 0, + "decoded": 796, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-FEGXS4JI.js", + "ms": 0, + "bytes": 0, + "decoded": 110636, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-GAIOZJDU.js", + "ms": 0, + "bytes": 0, + "decoded": 4377, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-U7ZX5J4J.js", + "ms": 0, + "bytes": 0, + "decoded": 23095, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-NXU7RDSS.js", + "ms": 0, + "bytes": 0, + "decoded": 298633, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-FDYYSBBD.js", + "ms": 0, + "bytes": 0, + "decoded": 62920, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-Y3XSBPFI.js", + "ms": 0, + "bytes": 0, + "decoded": 19312, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-WL72QCDN.js", + "ms": 0, + "bytes": 0, + "decoded": 2885, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-XCCHLWO4.js", + "ms": 0, + "bytes": 0, + "decoded": 222919, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-COPWCGZV.js", + "ms": 0, + "bytes": 0, + "decoded": 42696, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-FRR6AZWJ.js", + "ms": 0, + "bytes": 0, + "decoded": 3916, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-DSWUIY5W.js", + "ms": 0, + "bytes": 0, + "decoded": 82052, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-H7HDRDEB.js", + "ms": 0, + "bytes": 0, + "decoded": 21193, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-VOQDZQCU.js", + "ms": 0, + "bytes": 0, + "decoded": 198584, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-JOUS4FQN.js", + "ms": 0, + "bytes": 0, + "decoded": 3930, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-WCEIGMRM.js", + "ms": 0, + "bytes": 0, + "decoded": 359938, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-TFVCUBPE.js", + "ms": 0, + "bytes": 0, + "decoded": 300024, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/chunk-ZC5CJRJ4.js", + "ms": 0, + "bytes": 0, + "decoded": 402, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/xlsx.js", + "ms": 0, + "bytes": 0, + "decoded": 870702, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/sessionPrivacy.ts", + "ms": 14.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/categoryHelper.ts", + "ms": 15, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/data/projectsStorage.ts", + "ms": 15.099999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/messageIdentity.ts", + "ms": 15, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatSearch.ts", + "ms": 14.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/chatDigest.ts", + "ms": 14.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/chatExporter.ts", + "ms": 14.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/workflow/ProjectWorkflowSwitcher.tsx", + "ms": 13.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/PhaseEventStrip.tsx", + "ms": 12.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/RecommendedCaseCard.tsx", + "ms": 12.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/InteractiveCard.tsx", + "ms": 12.800000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/ReplyActionBar.tsx", + "ms": 13, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/chat/QuoteCard.tsx", + "ms": 13.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/@tanstack_react-virtual.js", + "ms": 0, + "bytes": 0, + "decoded": 55150, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/doc/DiffNavigator.tsx", + "ms": 7.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/engine/replyDocument/sanitize.ts", + "ms": 6.799999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/api/workflowAdmin.ts", + "ms": 6, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/components/admin/console/sections/sectionCommon.tsx", + "ms": 6.300000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/src/lib/adminUserDirectorySync.ts", + "ms": 5.099999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/node_modules/.vite/deps/docx-preview.js", + "ms": 0, + "bytes": 0, + "decoded": 282566, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3100/api/v1/auth/public-config", + "ms": 4.099999964237213, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [] + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-performance/controlled.cjs b/.doc/test-reports/assets/2026-09-17-initial-load-performance/controlled.cjs new file mode 100644 index 00000000..a3bb9363 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-performance/controlled.cjs @@ -0,0 +1,18 @@ +const {chromium}=require('/repo/web/node_modules/playwright'); +const fs=require('fs'); +(async()=>{ + const browser=await chromium.launch({headless:true,args:['--no-sandbox']});const rows=[]; + for(const test of [{port:8080,mode:'font-delay-5s'},{port:8080,mode:'2Mbps-80ms'}]){ + const context=await browser.newContext();const page=await context.newPage();const cdp=await context.newCDPSession(page);await cdp.send('Network.enable'); + if(test.mode==='2Mbps-80ms')await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:80,downloadThroughput:250000,uploadThroughput:625000}); + if(test.mode==='font-delay-5s')await page.route(/fonts\.googleapis\.com/,async r=>{await new Promise(ok=>setTimeout(ok,5000));await r.abort()}); + if(test.mode==='local-font-blocked')await page.route(/fonts\.(googleapis|gstatic)\.com/,r=>r.abort()); + const failures=[];page.on('requestfailed',r=>failures.push({url:r.url().split('?')[0],error:r.failure()?.errorText})); + for(const phase of ['cold']){ + const started=Date.now();let error=null;try{await page.goto(`http://127.0.0.1:${test.port}`,{waitUntil:'domcontentloaded',timeout:45000});await page.locator('input').first().waitFor({state:'visible',timeout:10000});}catch(e){error=e.message.slice(0,180)} + const visibleMs=Date.now()-started;await page.waitForTimeout(1500); + const perf=await page.evaluate(()=>({navigation:performance.getEntriesByType('navigation').map(n=>({ttfb:n.responseStart,dcl:n.domContentLoadedEventEnd,load:n.loadEventEnd})),paint:performance.getEntriesByType('paint').map(p=>({name:p.name,ms:p.startTime})),resources:performance.getEntriesByType('resource').map(r=>({url:r.name.split('?')[0],ms:r.duration,bytes:r.transferSize,decoded:r.decodedBodySize,initiator:r.initiatorType}))})); + const row={...test,phase,visibleMs,error,...perf,failures:[...failures]};rows.push(row);fs.writeFileSync('/out/controlled.json',JSON.stringify(rows,null,2));console.log(JSON.stringify({...test,phase,visibleMs,error,navigation:perf.navigation,paint:perf.paint,requests:perf.resources.length,bytes:perf.resources.reduce((s,r)=>s+r.bytes,0),failures})); + }await context.close(); + }await browser.close(); +})(); diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-performance/controlled.json b/.doc/test-reports/assets/2026-09-17-initial-load-performance/controlled.json new file mode 100644 index 00000000..a23322a4 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-performance/controlled.json @@ -0,0 +1,117 @@ +[ + { + "port": 8080, + "mode": "font-delay-5s", + "phase": "cold", + "visibleMs": 5161, + "error": null, + "navigation": [ + { + "ttfb": 2.899999976158142, + "dcl": 5110.699999988079, + "load": 5113.800000011921 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 5116 + }, + { + "name": "first-contentful-paint", + "ms": 5164 + } + ], + "resources": [ + { + "url": "https://fonts.googleapis.com/css2", + "ms": 5007.399999976158, + "bytes": 0, + "decoded": 0, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-BLnmtk_3.js", + "ms": 7.900000035762787, + "bytes": 2820355, + "decoded": 2820055, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:8080/assets/index-DhpBfxvE.css", + "ms": 3.199999988079071, + "bytes": 35804, + "decoded": 35504, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/api/v1/auth/public-config", + "ms": 3, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [ + { + "url": "https://fonts.googleapis.com/css2", + "error": "net::ERR_FAILED" + } + ] + }, + { + "port": 8080, + "mode": "2Mbps-80ms", + "phase": "cold", + "visibleMs": 11776, + "error": null, + "navigation": [ + { + "ttfb": 1.5, + "dcl": 11732, + "load": 11733.300000011921 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 996 + }, + { + "name": "first-contentful-paint", + "ms": 11776 + } + ], + "resources": [ + { + "url": "https://fonts.googleapis.com/css2", + "ms": 903.1999999880791, + "bytes": 1249, + "decoded": 17342, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-DhpBfxvE.css", + "ms": 367.80000001192093, + "bytes": 35804, + "decoded": 35504, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:8080/assets/index-BLnmtk_3.js", + "ms": 11515.199999988079, + "bytes": 2820355, + "decoded": 2820055, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:8080/api/v1/auth/public-config", + "ms": 87.5, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [] + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-performance/probe.cjs b/.doc/test-reports/assets/2026-09-17-initial-load-performance/probe.cjs new file mode 100644 index 00000000..f1ba7012 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-performance/probe.cjs @@ -0,0 +1,17 @@ +const {chromium}=require('/repo/web/node_modules/playwright'); +const fs=require('fs'); +(async()=>{ + const browser=await chromium.launch({headless:true,args:['--no-sandbox']});const rows=[]; + for(const test of [{port:8080,mode:'local-cold'},{port:8080,mode:'local-font-blocked'},{port:8080,mode:'10Mbps-80ms'},{port:3100,mode:'local-cold'}]){ + const context=await browser.newContext();const page=await context.newPage();const cdp=await context.newCDPSession(page);await cdp.send('Network.enable'); + if(test.mode==='10Mbps-80ms')await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:80,downloadThroughput:1250000,uploadThroughput:625000}); + if(test.mode==='local-font-blocked')await page.route(/fonts\.(googleapis|gstatic)\.com/,r=>r.abort()); + const failures=[];page.on('requestfailed',r=>failures.push({url:r.url().split('?')[0],error:r.failure()?.errorText})); + for(const phase of ['cold','warm']){ + const started=Date.now();let error=null;try{await page.goto(`http://127.0.0.1:${test.port}`,{waitUntil:'domcontentloaded',timeout:45000});await page.locator('input').first().waitFor({state:'visible',timeout:10000});}catch(e){error=e.message.slice(0,180)} + const visibleMs=Date.now()-started;await page.waitForTimeout(1500); + const perf=await page.evaluate(()=>({navigation:performance.getEntriesByType('navigation').map(n=>({ttfb:n.responseStart,dcl:n.domContentLoadedEventEnd,load:n.loadEventEnd})),paint:performance.getEntriesByType('paint').map(p=>({name:p.name,ms:p.startTime})),resources:performance.getEntriesByType('resource').map(r=>({url:r.name.split('?')[0],ms:r.duration,bytes:r.transferSize,decoded:r.decodedBodySize,initiator:r.initiatorType}))})); + const row={...test,phase,visibleMs,error,...perf,failures:[...failures]};rows.push(row);fs.writeFileSync('/out/browser.json',JSON.stringify(rows,null,2));console.log(JSON.stringify({...test,phase,visibleMs,error,navigation:perf.navigation,paint:perf.paint,requests:perf.resources.length,bytes:perf.resources.reduce((s,r)=>s+r.bytes,0),failures})); + }await context.close(); + }await browser.close(); +})(); diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-performance/vite-cold.cjs b/.doc/test-reports/assets/2026-09-17-initial-load-performance/vite-cold.cjs new file mode 100644 index 00000000..ab071799 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-performance/vite-cold.cjs @@ -0,0 +1,17 @@ +const {chromium}=require('/repo/web/node_modules/playwright'); +const fs=require('fs'); +(async()=>{ + const browser=await chromium.launch({headless:true,args:['--no-sandbox']});const rows=[]; + for(const test of [{port:3199,mode:'isolated-vite-empty-cache'}]){ + const context=await browser.newContext();const page=await context.newPage();const cdp=await context.newCDPSession(page);await cdp.send('Network.enable'); + if(test.mode==='10Mbps-80ms')await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:80,downloadThroughput:1250000,uploadThroughput:625000}); + if(test.mode==='local-font-blocked')await page.route(/fonts\.(googleapis|gstatic)\.com/,r=>r.abort()); + const failures=[];page.on('requestfailed',r=>failures.push({url:r.url().split('?')[0],error:r.failure()?.errorText})); + for(const phase of ['cold','warm']){ + const started=Date.now();let error=null;try{await page.goto(`http://127.0.0.1:${test.port}`,{waitUntil:'domcontentloaded',timeout:45000});await page.locator('input').first().waitFor({state:'visible',timeout:10000});}catch(e){error=e.message.slice(0,180)} + const visibleMs=Date.now()-started;await page.waitForTimeout(1500); + const perf=await page.evaluate(()=>({navigation:performance.getEntriesByType('navigation').map(n=>({ttfb:n.responseStart,dcl:n.domContentLoadedEventEnd,load:n.loadEventEnd})),paint:performance.getEntriesByType('paint').map(p=>({name:p.name,ms:p.startTime})),resources:performance.getEntriesByType('resource').map(r=>({url:r.name.split('?')[0],ms:r.duration,bytes:r.transferSize,decoded:r.decodedBodySize,initiator:r.initiatorType}))})); + const row={...test,phase,visibleMs,error,...perf,failures:[...failures]};rows.push(row);fs.writeFileSync('/out/vite-cold.json',JSON.stringify(rows,null,2));console.log(JSON.stringify({...test,phase,visibleMs,error,navigation:perf.navigation,paint:perf.paint,requests:perf.resources.length,bytes:perf.resources.reduce((s,r)=>s+r.bytes,0),failures})); + }await context.close(); + }await browser.close(); +})(); diff --git a/.doc/test-reports/assets/2026-09-17-initial-load-performance/vite-cold.json b/.doc/test-reports/assets/2026-09-17-initial-load-performance/vite-cold.json new file mode 100644 index 00000000..3b1b6693 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-17-initial-load-performance/vite-cold.json @@ -0,0 +1,2961 @@ +[ + { + "port": 3199, + "mode": "isolated-vite-empty-cache", + "phase": "cold", + "visibleMs": 1580, + "error": null, + "navigation": [ + { + "ttfb": 18, + "dcl": 1519.0999999642372, + "load": 1522.8999999761581 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 1524 + }, + { + "name": "first-contentful-paint", + "ms": 1572 + } + ], + "resources": [ + { + "url": "http://127.0.0.1:3199/@vite/client", + "ms": 183.0999999642372, + "bytes": 179308, + "decoded": 179008, + "initiator": "script" + }, + { + "url": "https://fonts.googleapis.com/css2", + "ms": 642.6999999880791, + "bytes": 1249, + "decoded": 17342, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:3199/src/main.tsx", + "ms": 190.5, + "bytes": 2488, + "decoded": 2188, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@react-refresh", + "ms": 184.19999998807907, + "bytes": 112194, + "decoded": 111894, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/node_modules/vite/dist/client/env.mjs", + "ms": 7.100000023841858, + "bytes": 3778, + "decoded": 3478, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/react_jsx-dev-runtime.js", + "ms": 183.5999999642372, + "bytes": 43278, + "decoded": 42978, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/node_modules/katex/dist/katex.min.css", + "ms": 646, + "bytes": 27244, + "decoded": 26944, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/ErrorBoundary.tsx", + "ms": 649.1000000238419, + "bytes": 7818, + "decoded": 7518, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/react-dom_client.js", + "ms": 652, + "bytes": 3161787, + "decoded": 3161487, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/App.tsx", + "ms": 668.5, + "bytes": 561623, + "decoded": 561323, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/styles/index.css", + "ms": 649.3000000119209, + "bytes": 8490, + "decoded": 8190, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-BURU425W.js", + "ms": 463.19999998807907, + "bytes": 45902, + "decoded": 45602, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-ZPWHCABL.js", + "ms": 465.30000001192093, + "bytes": 10791, + "decoded": 10491, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/react.js", + "ms": 17.899999976158142, + "bytes": 1588, + "decoded": 1288, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-3I5KZ3WR.js", + "ms": 216.80000001192093, + "bytes": 16574, + "decoded": 16274, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/AppShell.tsx", + "ms": 212.39999997615814, + "bytes": 24279, + "decoded": 23979, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/MessageStream.tsx", + "ms": 215.29999995231628, + "bytes": 33140, + "decoded": 32840, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/InputArea.tsx", + "ms": 257.69999998807907, + "bytes": 66527, + "decoded": 66227, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/SlimTabBar.tsx", + "ms": 292.5999999642372, + "bytes": 95699, + "decoded": 95399, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/BlockRenderer.tsx", + "ms": 258.9000000357628, + "bytes": 57252, + "decoded": 56952, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DeckDrawer.tsx", + "ms": 324.69999998807907, + "bytes": 136839, + "decoded": 136539, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/BlockEditorModal.tsx", + "ms": 345.9000000357628, + "bytes": 185744, + "decoded": 185444, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DocumentRevisionToolbar.tsx", + "ms": 259.5999999642372, + "bytes": 39574, + "decoded": 39274, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/CommentSidePanel.tsx", + "ms": 285.0999999642372, + "bytes": 38524, + "decoded": 38224, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DocGovActionBar.tsx", + "ms": 285.19999998807907, + "bytes": 92401, + "decoded": 92101, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/FreeformDocumentActionBar.tsx", + "ms": 289, + "bytes": 47944, + "decoded": 47644, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/freeformDoc.ts", + "ms": 289.60000002384186, + "bytes": 18267, + "decoded": 17967, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/uploadFreeformRegister.ts", + "ms": 289.9000000357628, + "bytes": 14208, + "decoded": 13908, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatSessions.ts", + "ms": 293.7000000476837, + "bytes": 4582, + "decoded": 4282, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectDocuments.ts", + "ms": 296.2000000476837, + "bytes": 6746, + "decoded": 6446, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectWorkflow.ts", + "ms": 296.5, + "bytes": 13389, + "decoded": 13089, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/revisionToolbarMount.ts", + "ms": 334.7000000476837, + "bytes": 10119, + "decoded": 9819, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectMembers.ts", + "ms": 324.80000001192093, + "bytes": 7683, + "decoded": 7383, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/afterProjectMemberPersisted.ts", + "ms": 326.5999999642372, + "bytes": 1113, + "decoded": 813, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/createProjectMembersPersist.ts", + "ms": 326.69999998807907, + "bytes": 9597, + "decoded": 9297, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/createProjectSubmitGuard.ts", + "ms": 326.69999998807907, + "bytes": 3834, + "decoded": 3534, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectMemberAdapter.ts", + "ms": 335.5, + "bytes": 5618, + "decoded": 5318, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/deliverableSync.ts", + "ms": 337.9000000357628, + "bytes": 12328, + "decoded": 12028, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/commentPolicy.ts", + "ms": 338.10000002384186, + "bytes": 1857, + "decoded": 1557, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/currentDocRole.ts", + "ms": 338.19999998807907, + "bytes": 4523, + "decoded": 4223, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/postTaskDeliverableSync.ts", + "ms": 338.4000000357628, + "bytes": 10639, + "decoded": 10339, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/taskRunReconnect.ts", + "ms": 340.69999998807907, + "bytes": 3397, + "decoded": 3097, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/blockedGovernanceCopy.ts", + "ms": 345.10000002384186, + "bytes": 2770, + "decoded": 2470, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/controlledDocHydrate.ts", + "ms": 346, + "bytes": 24528, + "decoded": 24228, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/deliverableLeaseSync.ts", + "ms": 345.69999998807907, + "bytes": 2983, + "decoded": 2683, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/controlledContentInvalidation.ts", + "ms": 346, + "bytes": 7329, + "decoded": 7029, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/editorHydrationGate.ts", + "ms": 352.89999997615814, + "bytes": 8041, + "decoded": 7741, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/blockSavePolicy.ts", + "ms": 364.10000002384186, + "bytes": 7485, + "decoded": 7185, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/saveControlledBlockEdit.ts", + "ms": 364.2000000476837, + "bytes": 12211, + "decoded": 11911, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/workflow/StaleDocumentNotice.tsx", + "ms": 364.19999998807907, + "bytes": 10802, + "decoded": 10502, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/workflow/StageAdvanceBanner.tsx", + "ms": 364.19999998807907, + "bytes": 25282, + "decoded": 24982, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/replyExport/title.ts", + "ms": 364.30000001192093, + "bytes": 5294, + "decoded": 4994, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/replyExport/clipboard.ts", + "ms": 370, + "bytes": 11070, + "decoded": 10770, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/replyExport/download.ts", + "ms": 445.0999999642372, + "bytes": 9854, + "decoded": 9554, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/ProposalConfirmCard.tsx", + "ms": 445.2999999523163, + "bytes": 16224, + "decoded": 15924, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/AdminConsoleView.tsx", + "ms": 445.5, + "bytes": 52830, + "decoded": 52530, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/user/UserProjectRightsModal.tsx", + "ms": 445.5999999642372, + "bytes": 111433, + "decoded": 111133, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/user/UserSettingsModal.tsx", + "ms": 445.89999997615814, + "bytes": 167846, + "decoded": 167546, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DocVisibilityModal.tsx", + "ms": 457.10000002384186, + "bytes": 82404, + "decoded": 82104, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/project/CreateProjectModal.tsx", + "ms": 481.80000001192093, + "bytes": 107279, + "decoded": 106979, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/project/ProjectMembersModal.tsx", + "ms": 481.80000001192093, + "bytes": 92820, + "decoded": 92520, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectSettings.ts", + "ms": 481.5999999642372, + "bytes": 21298, + "decoded": 20998, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/FilePreviewPanel.tsx", + "ms": 482, + "bytes": 109789, + "decoded": 109489, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/project/ProjectSettingsModal.tsx", + "ms": 481.89999997615814, + "bytes": 127525, + "decoded": 127225, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/auth/FirstLoginPasswordModal.tsx", + "ms": 486.9000000357628, + "bytes": 44360, + "decoded": 44060, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/auth/LoginView.tsx", + "ms": 487, + "bytes": 134863, + "decoded": 134563, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/lucide-react.js", + "ms": 487.30000001192093, + "bytes": 1208857, + "decoded": 1208557, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/auth.ts", + "ms": 486.80000001192093, + "bytes": 39123, + "decoded": 38823, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatStream.ts", + "ms": 486.89999997615814, + "bytes": 16678, + "decoded": 16378, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/taskStream.ts", + "ms": 487, + "bytes": 9053, + "decoded": 8753, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatRetry.ts", + "ms": 497.5, + "bytes": 3510, + "decoded": 3210, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/phaseEventMapper.ts", + "ms": 498, + "bytes": 11251, + "decoded": 10951, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/markdownToBlock.ts", + "ms": 498.19999998807907, + "bytes": 25519, + "decoded": 25219, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/blockToMarkdown.ts", + "ms": 498.19999998807907, + "bytes": 11283, + "decoded": 10983, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/autoNumber.ts", + "ms": 498.2999999523163, + "bytes": 13219, + "decoded": 12919, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/rbac.ts", + "ms": 498.2999999523163, + "bytes": 16608, + "decoded": 16308, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/xlsxRenderer.ts", + "ms": 504.39999997615814, + "bytes": 17629, + "decoded": 17329, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/docxRenderer.ts", + "ms": 504.5999999642372, + "bytes": 128323, + "decoded": 128023, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/demoSeeds.disabled.ts", + "ms": 504.19999998807907, + "bytes": 2741, + "decoded": 2441, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/intentPlans.ts", + "ms": 504.60000002384186, + "bytes": 8260, + "decoded": 7960, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/cardActionEngine.ts", + "ms": 504.4000000357628, + "bytes": 28840, + "decoded": 28540, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/planCard.ts", + "ms": 504.5, + "bytes": 9544, + "decoded": 9244, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/templateFactory.ts", + "ms": 505.89999997615814, + "bytes": 61326, + "decoded": 61026, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/templateMatch.ts", + "ms": 506.19999998807907, + "bytes": 24452, + "decoded": 24152, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/storage.ts", + "ms": 506.80000001192093, + "bytes": 39636, + "decoded": 39336, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useChatWindow.ts", + "ms": 507, + "bytes": 62803, + "decoded": 62503, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatTurn.ts", + "ms": 506.80000001192093, + "bytes": 3173, + "decoded": 2873, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useTurnRuntime.ts", + "ms": 506.9000000357628, + "bytes": 11947, + "decoded": 11647, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useTaskRunStream.ts", + "ms": 507, + "bytes": 8188, + "decoded": 7888, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/mergeSessionUser.ts", + "ms": 507.30000001192093, + "bytes": 1541, + "decoded": 1241, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/createClientTurnId.ts", + "ms": 507.60000002384186, + "bytes": 4145, + "decoded": 3845, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectAdapter.ts", + "ms": 508, + "bytes": 6711, + "decoded": 6411, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/sessionAdapter.ts", + "ms": 508, + "bytes": 9663, + "decoded": 9363, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/CategoryFilterBar.tsx", + "ms": 508.19999998807907, + "bytes": 44419, + "decoded": 44119, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/HistoryCalendar.tsx", + "ms": 508.5, + "bytes": 40282, + "decoded": 39982, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/ChatSearchModal.tsx", + "ms": 508.80000001192093, + "bytes": 54130, + "decoded": 53830, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/DigestTimelineModal.tsx", + "ms": 509, + "bytes": 66782, + "decoded": 66482, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/anchorNavigator.ts", + "ms": 509.69999998807907, + "bytes": 19512, + "decoded": 19212, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useUnreadMarks.ts", + "ms": 509.89999997615814, + "bytes": 26503, + "decoded": 26203, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/ChatExportModal.tsx", + "ms": 509.89999997615814, + "bytes": 34006, + "decoded": 33706, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/CategoryDock.tsx", + "ms": 510.0999999642372, + "bytes": 58149, + "decoded": 57849, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatWindow.ts", + "ms": 510.10000002384186, + "bytes": 32301, + "decoded": 32001, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/fileStore.ts", + "ms": 510.30000001192093, + "bytes": 15953, + "decoded": 15653, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectAssets.ts", + "ms": 510.60000002384186, + "bytes": 3267, + "decoded": 2967, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/taskRuns.ts", + "ms": 511, + "bytes": 15100, + "decoded": 14800, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/Topbar.tsx", + "ms": 300.19999998807907, + "bytes": 30216, + "decoded": 29916, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/LeftRail.tsx", + "ms": 300.5, + "bytes": 57740, + "decoded": 57440, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/ResizeHandle.tsx", + "ms": 300.9000000357628, + "bytes": 20077, + "decoded": 19777, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/ChatColumn.tsx", + "ms": 300.80000001192093, + "bytes": 4139, + "decoded": 3839, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/RightDocRail.tsx", + "ms": 300.89999997615814, + "bytes": 4653, + "decoded": 4353, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/DayDivider.tsx", + "ms": 299.30000001192093, + "bytes": 18515, + "decoded": 18215, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useVirtualMessages.ts", + "ms": 299.30000001192093, + "bytes": 11930, + "decoded": 11630, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/MessageBubble.tsx", + "ms": 299.39999997615814, + "bytes": 89407, + "decoded": 89107, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/inputAreaSendLogic.ts", + "ms": 255.5, + "bytes": 2292, + "decoded": 1992, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/QuoteChip.tsx", + "ms": 255.69999998807907, + "bytes": 11220, + "decoded": 10920, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/overlayMatch.ts", + "ms": 255.4000000357628, + "bytes": 6232, + "decoded": 5932, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/HeadingBlockView.tsx", + "ms": 256.69999998807907, + "bytes": 8823, + "decoded": 8523, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/ParagraphBlockView.tsx", + "ms": 257, + "bytes": 8525, + "decoded": 8225, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/TableBlockView.tsx", + "ms": 257.0999999642372, + "bytes": 16510, + "decoded": 16210, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/CalloutBlockView.tsx", + "ms": 257.19999998807907, + "bytes": 9991, + "decoded": 9691, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/MermaidBlockView.tsx", + "ms": 257.2999999523163, + "bytes": 31783, + "decoded": 31483, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/MathBlockView.tsx", + "ms": 257.39999997615814, + "bytes": 14082, + "decoded": 13782, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/ListBlockView.tsx", + "ms": 258.69999998807907, + "bytes": 12380, + "decoded": 12080, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/documentBlockDom.ts", + "ms": 259.80000001192093, + "bytes": 1565, + "decoded": 1265, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/BlockContextMenu.tsx", + "ms": 259.9000000357628, + "bytes": 40439, + "decoded": 40139, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/types.ts", + "ms": 259.5, + "bytes": 6038, + "decoded": 5738, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/toolbarRefresh.ts", + "ms": 259.39999997615814, + "bytes": 2554, + "decoded": 2254, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/changeReportView.ts", + "ms": 259.5999999642372, + "bytes": 10442, + "decoded": 10142, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/documentRevision.ts", + "ms": 259.69999998807907, + "bytes": 9747, + "decoded": 9447, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/VersionHistoryDrawer.tsx", + "ms": 260.30000001192093, + "bytes": 13419, + "decoded": 13119, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/VersionCompareView.tsx", + "ms": 261.5, + "bytes": 11374, + "decoded": 11074, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/ChangeReportPanel.tsx", + "ms": 261.5, + "bytes": 43866, + "decoded": 43566, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/ManualChangeOverlay.tsx", + "ms": 261, + "bytes": 3420, + "decoded": 3120, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/docGovActionVisibility.ts", + "ms": 235.19999998807907, + "bytes": 2583, + "decoded": 2283, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/authHeaders.ts", + "ms": 232, + "bytes": 4690, + "decoded": 4390, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/demoSeedIds.ts", + "ms": 196.30000001192093, + "bytes": 4396, + "decoded": 4096, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectDocumentDuty.ts", + "ms": 195.69999998807907, + "bytes": 4327, + "decoded": 4027, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/katex.js", + "ms": 179.80000001192093, + "bytes": 486270, + "decoded": 485970, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/blockEditorDialog.ts", + "ms": 178.29999995231628, + "bytes": 1902, + "decoded": 1602, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/renderMarkdown.ts", + "ms": 178.5999999642372, + "bytes": 27072, + "decoded": 26772, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/revisionEtag.ts", + "ms": 178.19999998807907, + "bytes": 3154, + "decoded": 2854, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/replyDocument/parseReplyMarkdown.ts", + "ms": 154.4000000357628, + "bytes": 30614, + "decoded": 30314, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/replyDocument/renderClipboardHtml.ts", + "ms": 154.5, + "bytes": 21474, + "decoded": 21174, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/replyDocument/renderPlainText.ts", + "ms": 155.30000001192093, + "bytes": 7610, + "decoded": 7310, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/docx.js", + "ms": 84.89999997615814, + "bytes": 781138, + "decoded": 780838, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/systemAdmin.ts", + "ms": 80.5, + "bytes": 6247, + "decoded": 5947, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/DocsSection.tsx", + "ms": 80.60000002384186, + "bytes": 20978, + "decoded": 20678, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/FeaturesSection.tsx", + "ms": 80.60000002384186, + "bytes": 24655, + "decoded": 24355, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/KbSection.tsx", + "ms": 80.59999996423721, + "bytes": 15712, + "decoded": 15412, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/OverviewSection.tsx", + "ms": 80.69999998807907, + "bytes": 19391, + "decoded": 19091, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/SettingsSection.tsx", + "ms": 82, + "bytes": 38765, + "decoded": 38465, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/RolesSection.tsx", + "ms": 82, + "bytes": 64502, + "decoded": 64202, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/UsersSection.tsx", + "ms": 83.80000001192093, + "bytes": 157681, + "decoded": 157381, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/WorkflowsSection.tsx", + "ms": 83.20000004768372, + "bytes": 27887, + "decoded": 27587, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/dutyCellAssertion.ts", + "ms": 83.39999997615814, + "bytes": 7319, + "decoded": 7019, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectSettingsError.ts", + "ms": 49.10000002384186, + "bytes": 9737, + "decoded": 9437, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/DocxViewer.tsx", + "ms": 48.39999997615814, + "bytes": 23739, + "decoded": 23439, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/PdfViewer.tsx", + "ms": 50, + "bytes": 16657, + "decoded": 16357, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/ExcelViewer.tsx", + "ms": 50.39999997615814, + "bytes": 51190, + "decoded": 50890, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/ImageViewer.tsx", + "ms": 50.5, + "bytes": 30073, + "decoded": 29773, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/PptViewer.tsx", + "ms": 50.5, + "bytes": 32650, + "decoded": 32350, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/TextViewer.tsx", + "ms": 50.69999998807907, + "bytes": 26140, + "decoded": 25840, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectSettingsMode.ts", + "ms": 50.69999998807907, + "bytes": 6522, + "decoded": 6222, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectSettingsDuties.ts", + "ms": 51.5, + "bytes": 11781, + "decoded": 11481, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/authUserMap.ts", + "ms": 47.10000002384186, + "bytes": 11311, + "decoded": 11011, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/xlsx.js", + "ms": 34.10000002384186, + "bytes": 871028, + "decoded": 870728, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/sessionPrivacy.ts", + "ms": 29.19999998807907, + "bytes": 18414, + "decoded": 18114, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/categoryHelper.ts", + "ms": 29.5, + "bytes": 8031, + "decoded": 7731, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/projectsStorage.ts", + "ms": 29.600000023841858, + "bytes": 7066, + "decoded": 6766, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/messageIdentity.ts", + "ms": 29.5, + "bytes": 7426, + "decoded": 7126, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/mathml2omml.js", + "ms": 28.69999998807907, + "bytes": 58093, + "decoded": 57793, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/mermaid.js", + "ms": 30.299999952316284, + "bytes": 63229, + "decoded": 62929, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatSearch.ts", + "ms": 30.200000047683716, + "bytes": 4342, + "decoded": 4042, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatDigest.ts", + "ms": 30.100000023841858, + "bytes": 4297, + "decoded": 3997, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/chatExporter.ts", + "ms": 29.100000023841858, + "bytes": 20580, + "decoded": 20280, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/workflow/ProjectWorkflowSwitcher.tsx", + "ms": 28.099999964237213, + "bytes": 21164, + "decoded": 20864, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/@tanstack_react-virtual.js", + "ms": 29.69999998807907, + "bytes": 55536, + "decoded": 55236, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/PhaseEventStrip.tsx", + "ms": 29.399999976158142, + "bytes": 12497, + "decoded": 12197, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/RecommendedCaseCard.tsx", + "ms": 29.399999976158142, + "bytes": 21855, + "decoded": 21555, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/ReplyActionBar.tsx", + "ms": 29.599999964237213, + "bytes": 58154, + "decoded": 57854, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/QuoteCard.tsx", + "ms": 29.599999964237213, + "bytes": 14895, + "decoded": 14595, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/InteractiveCard.tsx", + "ms": 29.599999964237213, + "bytes": 108189, + "decoded": 107889, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DiffNavigator.tsx", + "ms": 24.69999998807907, + "bytes": 4148, + "decoded": 3848, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/replyDocument/sanitize.ts", + "ms": 20.799999952316284, + "bytes": 3683, + "decoded": 3383, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/workflowAdmin.ts", + "ms": 17.600000023841858, + "bytes": 6696, + "decoded": 6396, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/sectionCommon.tsx", + "ms": 17.69999998807907, + "bytes": 19422, + "decoded": 19122, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/adminUserDirectorySync.ts", + "ms": 13.800000011920929, + "bytes": 2225, + "decoded": 1925, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/docx-preview.js", + "ms": 16.200000047683716, + "bytes": 282900, + "decoded": 282600, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-PB6X57HH.js", + "ms": 5.399999976158142, + "bytes": 1166, + "decoded": 866, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-ESLT5ZYL.js", + "ms": 6, + "bytes": 4923, + "decoded": 4623, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-42IP4QEJ.js", + "ms": 6.100000023841858, + "bytes": 23619, + "decoded": 23319, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-EQA5AEAE.js", + "ms": 6.399999976158142, + "bytes": 110962, + "decoded": 110662, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-CRPBUU4U.js", + "ms": 7, + "bytes": 63422, + "decoded": 63122, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-VBXJHDS4.js", + "ms": 7.5, + "bytes": 301409, + "decoded": 301109, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-4TAQ4AQF.js", + "ms": 7.300000011920929, + "bytes": 3277, + "decoded": 2977, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-MNZHWS37.js", + "ms": 7.600000023841858, + "bytes": 19792, + "decoded": 19492, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-D3QVFMXY.js", + "ms": 8.199999988079071, + "bytes": 4264, + "decoded": 3964, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-YZ4FJDPJ.js", + "ms": 8.199999988079071, + "bytes": 43000, + "decoded": 42700, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-P2UZON6R.js", + "ms": 9.199999988079071, + "bytes": 82516, + "decoded": 82216, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-NHPEQUUA.js", + "ms": 9, + "bytes": 223399, + "decoded": 223099, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-6XFKP6C5.js", + "ms": 8.900000035762787, + "bytes": 21631, + "decoded": 21331, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-U7PCOKIK.js", + "ms": 9.5, + "bytes": 4268, + "decoded": 3968, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-5CJX37ZM.js", + "ms": 9.600000023841858, + "bytes": 200622, + "decoded": 200322, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-7DINC4PG.js", + "ms": 10.300000011920929, + "bytes": 360416, + "decoded": 360116, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-663PSA2K.js", + "ms": 10.700000047683716, + "bytes": 301788, + "decoded": 301488, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-LTWKRX5X.js", + "ms": 10.300000011920929, + "bytes": 706, + "decoded": 406, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/api/v1/auth/public-config", + "ms": 5.800000011920929, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [] + }, + { + "port": 3199, + "mode": "isolated-vite-empty-cache", + "phase": "warm", + "visibleMs": 171, + "error": null, + "navigation": [ + { + "ttfb": 4.299999952316284, + "dcl": 94.89999997615814, + "load": 99.29999995231628 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 100 + }, + { + "name": "first-contentful-paint", + "ms": 168 + } + ], + "resources": [ + { + "url": "http://127.0.0.1:3199/@vite/client", + "ms": 1.2000000476837158, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "https://fonts.googleapis.com/css2", + "ms": 0, + "bytes": 0, + "decoded": 17342, + "initiator": "link" + }, + { + "url": "http://127.0.0.1:3199/src/main.tsx", + "ms": 1.0999999642372131, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@react-refresh", + "ms": 0.800000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/node_modules/vite/dist/client/env.mjs", + "ms": 1.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/react_jsx-dev-runtime.js", + "ms": 0, + "bytes": 0, + "decoded": 42978, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/react-dom_client.js", + "ms": 0, + "bytes": 0, + "decoded": 3161487, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/App.tsx", + "ms": 1.0999999642372131, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/ErrorBoundary.tsx", + "ms": 1.5999999642372131, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/node_modules/katex/dist/katex.min.css", + "ms": 1.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/styles/index.css", + "ms": 1.9000000357627869, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2", + "ms": 0, + "bytes": 0, + "decoded": 48256, + "initiator": "css" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-BURU425W.js", + "ms": 0, + "bytes": 0, + "decoded": 45602, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-ZPWHCABL.js", + "ms": 0, + "bytes": 0, + "decoded": 10491, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-3I5KZ3WR.js", + "ms": 0, + "bytes": 0, + "decoded": 16274, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/react.js", + "ms": 0, + "bytes": 0, + "decoded": 1288, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/AppShell.tsx", + "ms": 4, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/MessageStream.tsx", + "ms": 5.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/InputArea.tsx", + "ms": 7, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/SlimTabBar.tsx", + "ms": 8.300000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DeckDrawer.tsx", + "ms": 9.300000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/BlockRenderer.tsx", + "ms": 11.300000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/BlockEditorModal.tsx", + "ms": 11.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DocumentRevisionToolbar.tsx", + "ms": 11.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/CommentSidePanel.tsx", + "ms": 11.799999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DocGovActionBar.tsx", + "ms": 12.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/FreeformDocumentActionBar.tsx", + "ms": 13, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/freeformDoc.ts", + "ms": 14, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/uploadFreeformRegister.ts", + "ms": 14.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatSessions.ts", + "ms": 14.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectDocuments.ts", + "ms": 14.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/revisionToolbarMount.ts", + "ms": 14.800000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectWorkflow.ts", + "ms": 14.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectMembers.ts", + "ms": 15.199999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/createProjectMembersPersist.ts", + "ms": 15.300000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/afterProjectMemberPersisted.ts", + "ms": 15.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/createProjectSubmitGuard.ts", + "ms": 15.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectMemberAdapter.ts", + "ms": 15.800000011920929, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/deliverableSync.ts", + "ms": 16, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/currentDocRole.ts", + "ms": 16.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/commentPolicy.ts", + "ms": 16.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/postTaskDeliverableSync.ts", + "ms": 16.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/taskRunReconnect.ts", + "ms": 16.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/blockedGovernanceCopy.ts", + "ms": 16.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/controlledDocHydrate.ts", + "ms": 16.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/controlledContentInvalidation.ts", + "ms": 17.299999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/deliverableLeaseSync.ts", + "ms": 17.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/editorHydrationGate.ts", + "ms": 17.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/blockSavePolicy.ts", + "ms": 17.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/saveControlledBlockEdit.ts", + "ms": 17.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/workflow/StaleDocumentNotice.tsx", + "ms": 18, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/workflow/StageAdvanceBanner.tsx", + "ms": 18.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/replyExport/title.ts", + "ms": 18.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/replyExport/clipboard.ts", + "ms": 18.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/replyExport/download.ts", + "ms": 18.700000047683716, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/ProposalConfirmCard.tsx", + "ms": 18.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/user/UserProjectRightsModal.tsx", + "ms": 19.200000047683716, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/user/UserSettingsModal.tsx", + "ms": 19.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/AdminConsoleView.tsx", + "ms": 19.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DocVisibilityModal.tsx", + "ms": 19.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/project/CreateProjectModal.tsx", + "ms": 20, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/project/ProjectMembersModal.tsx", + "ms": 20.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/project/ProjectSettingsModal.tsx", + "ms": 20.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectSettings.ts", + "ms": 21.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/FilePreviewPanel.tsx", + "ms": 21.299999952316284, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/lucide-react.js", + "ms": 0, + "bytes": 0, + "decoded": 1208557, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/auth/LoginView.tsx", + "ms": 21.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/auth/FirstLoginPasswordModal.tsx", + "ms": 21.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/auth.ts", + "ms": 21.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatStream.ts", + "ms": 22.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/taskStream.ts", + "ms": 22.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatRetry.ts", + "ms": 22.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/phaseEventMapper.ts", + "ms": 23, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/markdownToBlock.ts", + "ms": 23.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/blockToMarkdown.ts", + "ms": 23.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/autoNumber.ts", + "ms": 23.700000047683716, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/rbac.ts", + "ms": 23.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/docxRenderer.ts", + "ms": 23.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/xlsxRenderer.ts", + "ms": 24, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/cardActionEngine.ts", + "ms": 24.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/planCard.ts", + "ms": 24.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/intentPlans.ts", + "ms": 24.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/demoSeeds.disabled.ts", + "ms": 24.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/templateFactory.ts", + "ms": 24.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/templateMatch.ts", + "ms": 25.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/storage.ts", + "ms": 25.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useChatWindow.ts", + "ms": 25.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useTurnRuntime.ts", + "ms": 25.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useTaskRunStream.ts", + "ms": 25.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatTurn.ts", + "ms": 25.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/createClientTurnId.ts", + "ms": 26, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/mergeSessionUser.ts", + "ms": 26.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectAdapter.ts", + "ms": 26.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/sessionAdapter.ts", + "ms": 26.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/CategoryFilterBar.tsx", + "ms": 27, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/HistoryCalendar.tsx", + "ms": 27.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/ChatSearchModal.tsx", + "ms": 27.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/DigestTimelineModal.tsx", + "ms": 27.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/anchorNavigator.ts", + "ms": 27.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/ChatExportModal.tsx", + "ms": 28.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useUnreadMarks.ts", + "ms": 28.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/CategoryDock.tsx", + "ms": 28.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatWindow.ts", + "ms": 28.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/fileStore.ts", + "ms": 28.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectAssets.ts", + "ms": 29, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/taskRuns.ts", + "ms": 29.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/Topbar.tsx", + "ms": 26.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/LeftRail.tsx", + "ms": 26.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/ResizeHandle.tsx", + "ms": 26.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/ChatColumn.tsx", + "ms": 27, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/layout/RightDocRail.tsx", + "ms": 27, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/MessageBubble.tsx", + "ms": 27.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/history/DayDivider.tsx", + "ms": 27.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/hooks/useVirtualMessages.ts", + "ms": 27.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/inputAreaSendLogic.ts", + "ms": 25.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/QuoteChip.tsx", + "ms": 25.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/demoSeedIds.ts", + "ms": 23.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/overlayMatch.ts", + "ms": 22.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/HeadingBlockView.tsx", + "ms": 22.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/ParagraphBlockView.tsx", + "ms": 22.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/TableBlockView.tsx", + "ms": 22.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/CalloutBlockView.tsx", + "ms": 22.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/MermaidBlockView.tsx", + "ms": 22.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/MathBlockView.tsx", + "ms": 23.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/blocks/ListBlockView.tsx", + "ms": 23.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/BlockContextMenu.tsx", + "ms": 23.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/documentBlockDom.ts", + "ms": 23.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/katex.js", + "ms": 0, + "bytes": 0, + "decoded": 485970, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/renderMarkdown.ts", + "ms": 23.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/blockEditorDialog.ts", + "ms": 23.200000047683716, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/types.ts", + "ms": 23.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/toolbarRefresh.ts", + "ms": 23.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/features/documentRevision/changeReportView.ts", + "ms": 23.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/documentRevision.ts", + "ms": 23.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/VersionHistoryDrawer.tsx", + "ms": 24.099999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/VersionCompareView.tsx", + "ms": 24.19999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/ChangeReportPanel.tsx", + "ms": 24.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/ManualChangeOverlay.tsx", + "ms": 24.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/docGovActionVisibility.ts", + "ms": 24.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/authHeaders.ts", + "ms": 24, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectDocumentDuty.ts", + "ms": 22.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/revisionEtag.ts", + "ms": 20.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/replyDocument/parseReplyMarkdown.ts", + "ms": 19.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/replyDocument/renderClipboardHtml.ts", + "ms": 19.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/replyDocument/renderPlainText.ts", + "ms": 19.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/docx.js", + "ms": 0, + "bytes": 0, + "decoded": 780838, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/dutyCellAssertion.ts", + "ms": 19.099999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/systemAdmin.ts", + "ms": 18.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/DocsSection.tsx", + "ms": 19, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/FeaturesSection.tsx", + "ms": 19.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/KbSection.tsx", + "ms": 19.900000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/OverviewSection.tsx", + "ms": 20.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/RolesSection.tsx", + "ms": 20.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/SettingsSection.tsx", + "ms": 20.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/UsersSection.tsx", + "ms": 20.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/WorkflowsSection.tsx", + "ms": 21.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/projectSettingsError.ts", + "ms": 20.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectSettingsMode.ts", + "ms": 21.099999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/projectSettingsDuties.ts", + "ms": 21.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/DocxViewer.tsx", + "ms": 20.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/PdfViewer.tsx", + "ms": 20.80000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/ExcelViewer.tsx", + "ms": 21.30000001192093, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/ImageViewer.tsx", + "ms": 21.400000035762787, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/PptViewer.tsx", + "ms": 21.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/preview/renderers/TextViewer.tsx", + "ms": 21.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/authUserMap.ts", + "ms": 21.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/mathml2omml.js", + "ms": 0, + "bytes": 0, + "decoded": 57793, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/mermaid.js", + "ms": 0, + "bytes": 0, + "decoded": 62929, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-PB6X57HH.js", + "ms": 0, + "bytes": 0, + "decoded": 866, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-EQA5AEAE.js", + "ms": 0, + "bytes": 0, + "decoded": 110662, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-ESLT5ZYL.js", + "ms": 0, + "bytes": 0, + "decoded": 4623, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-42IP4QEJ.js", + "ms": 0, + "bytes": 0, + "decoded": 23319, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-VBXJHDS4.js", + "ms": 0, + "bytes": 0, + "decoded": 301109, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-CRPBUU4U.js", + "ms": 0, + "bytes": 0, + "decoded": 63122, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-MNZHWS37.js", + "ms": 0, + "bytes": 0, + "decoded": 19492, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-4TAQ4AQF.js", + "ms": 0, + "bytes": 0, + "decoded": 2977, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-NHPEQUUA.js", + "ms": 0, + "bytes": 0, + "decoded": 223099, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-YZ4FJDPJ.js", + "ms": 0, + "bytes": 0, + "decoded": 42700, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-D3QVFMXY.js", + "ms": 0, + "bytes": 0, + "decoded": 3964, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-P2UZON6R.js", + "ms": 0, + "bytes": 0, + "decoded": 82216, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-6XFKP6C5.js", + "ms": 0, + "bytes": 0, + "decoded": 21331, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-5CJX37ZM.js", + "ms": 0, + "bytes": 0, + "decoded": 200322, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-U7PCOKIK.js", + "ms": 0, + "bytes": 0, + "decoded": 3968, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-7DINC4PG.js", + "ms": 0, + "bytes": 0, + "decoded": 360116, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-663PSA2K.js", + "ms": 0, + "bytes": 0, + "decoded": 301488, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/chunk-LTWKRX5X.js", + "ms": 0, + "bytes": 0, + "decoded": 406, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/xlsx.js", + "ms": 0, + "bytes": 0, + "decoded": 870728, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/sessionPrivacy.ts", + "ms": 16.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/categoryHelper.ts", + "ms": 16.69999998807907, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/data/projectsStorage.ts", + "ms": 17, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/messageIdentity.ts", + "ms": 17, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatSearch.ts", + "ms": 16.200000047683716, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/chatDigest.ts", + "ms": 16.099999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/chatExporter.ts", + "ms": 15.5, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/workflow/ProjectWorkflowSwitcher.tsx", + "ms": 14.199999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/PhaseEventStrip.tsx", + "ms": 13.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/RecommendedCaseCard.tsx", + "ms": 13.399999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/InteractiveCard.tsx", + "ms": 13.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/ReplyActionBar.tsx", + "ms": 13.899999976158142, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/chat/QuoteCard.tsx", + "ms": 14, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/@tanstack_react-virtual.js", + "ms": 0, + "bytes": 0, + "decoded": 55236, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/doc/DiffNavigator.tsx", + "ms": 10.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/engine/replyDocument/sanitize.ts", + "ms": 8.599999964237213, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/api/workflowAdmin.ts", + "ms": 7.600000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/components/admin/console/sections/sectionCommon.tsx", + "ms": 7.699999988079071, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/src/lib/adminUserDirectorySync.ts", + "ms": 6.100000023841858, + "bytes": 300, + "decoded": 0, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/@fs/tmp/agent-load-audit/vite-cold-cache/deps/docx-preview.js", + "ms": 0, + "bytes": 0, + "decoded": 282600, + "initiator": "script" + }, + { + "url": "http://127.0.0.1:3199/api/v1/auth/public-config", + "ms": 4, + "bytes": 353, + "decoded": 53, + "initiator": "fetch" + } + ], + "failures": [] + } +] \ No newline at end of file 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/.doc/test-reports/assets/2026-09-19-initial-load-u1/deployed-8080-schema53.json b/.doc/test-reports/assets/2026-09-19-initial-load-u1/deployed-8080-schema53.json new file mode 100644 index 00000000..2aee6dcb --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-initial-load-u1/deployed-8080-schema53.json @@ -0,0 +1,306 @@ +[ + { + "sample": 1, + "anonymousVisibleMs": 72, + "workbenchReadyMs": 131, + "anonymous": { + "navigation": [ + { + "ttfb": 1.7999999523162842, + "dcl": 33.60000002384186, + "load": 33.60000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 72 + } + ], + "summary": { + "requestCount": 4, + "transferBytes": 223282, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.7999999523162842, + "dcl": 33.60000002384186, + "load": 33.60000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 72 + } + ], + "summary": { + "requestCount": 37, + "transferBytes": 816028, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 2, + "anonymousVisibleMs": 77, + "workbenchReadyMs": 132, + "anonymous": { + "navigation": [ + { + "ttfb": 2, + "dcl": 38.200000047683716, + "load": 38.200000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + } + ], + "summary": { + "requestCount": 4, + "transferBytes": 223282, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2, + "dcl": 38.200000047683716, + "load": 38.200000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 24 + }, + { + "name": "first-contentful-paint", + "ms": 76 + } + ], + "summary": { + "requestCount": 37, + "transferBytes": 816028, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 3, + "anonymousVisibleMs": 85, + "workbenchReadyMs": 135, + "anonymous": { + "navigation": [ + { + "ttfb": 1.2000000476837158, + "dcl": 41.39999997615814, + "load": 42 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 44 + } + ], + "summary": { + "requestCount": 4, + "transferBytes": 223282, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.2000000476837158, + "dcl": 41.39999997615814, + "load": 42 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 44 + }, + { + "name": "first-contentful-paint", + "ms": 88 + } + ], + "summary": { + "requestCount": 37, + "transferBytes": 816028, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 4, + "anonymousVisibleMs": 86, + "workbenchReadyMs": 128, + "anonymous": { + "navigation": [ + { + "ttfb": 1.2000000476837158, + "dcl": 42.200000047683716, + "load": 42.300000071525574 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + } + ], + "summary": { + "requestCount": 4, + "transferBytes": 223282, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.2000000476837158, + "dcl": 42.200000047683716, + "load": 42.300000071525574 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + }, + { + "name": "first-contentful-paint", + "ms": 84 + } + ], + "summary": { + "requestCount": 37, + "transferBytes": 816028, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 5, + "anonymousVisibleMs": 89, + "workbenchReadyMs": 134, + "anonymous": { + "navigation": [ + { + "ttfb": 1.100000023841858, + "dcl": 41.200000047683716, + "load": 41.200000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 32 + } + ], + "summary": { + "requestCount": 4, + "transferBytes": 223282, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.100000023841858, + "dcl": 41.200000047683716, + "load": 41.200000047683716 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 32 + }, + { + "name": "first-contentful-paint", + "ms": 88 + } + ], + "summary": { + "requestCount": 37, + "transferBytes": 816028, + "jsTransferBytes": 172417, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-initial-load-u1/deployed-8080.json b/.doc/test-reports/assets/2026-09-19-initial-load-u1/deployed-8080.json new file mode 100644 index 00000000..5d9a5872 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-initial-load-u1/deployed-8080.json @@ -0,0 +1,62 @@ +[ + { + "sample": 1, + "anonymousVisibleMs": 170, + "workbenchReadyMs": 181, + "anonymous": { + "navigation": [ + { + "ttfb": 1.2000000476837158, + "dcl": 133.20000004768372, + "load": 134.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + } + ], + "summary": { + "requestCount": 4, + "transferBytes": 684047, + "jsTransferBytes": 626281, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 1 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.2000000476837158, + "dcl": 133.20000004768372, + "load": 134.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 28 + }, + { + "name": "first-contentful-paint", + "ms": 164 + } + ], + "summary": { + "requestCount": 250, + "transferBytes": 1214378, + "jsTransferBytes": 626281, + "jsCount": 1, + "heavyChunks": [] + }, + "longTaskCount": 1 + }, + "initRequests": { + "memberGetCount": 704, + "distinctMemberProjects": 704, + "sessionListGetCount": 30 + } + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-initial-load-u1/throttled-schema53-n20.json b/.doc/test-reports/assets/2026-09-19-initial-load-u1/throttled-schema53-n20.json new file mode 100644 index 00000000..07c2e8a0 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-initial-load-u1/throttled-schema53-n20.json @@ -0,0 +1,242 @@ +[ + { + "profile": "10mbps-cpu4", + "sample": 1, + "anonymousVisibleMs": 559, + "workbenchReadyMs": 315 + }, + { + "profile": "10mbps-cpu4", + "sample": 2, + "anonymousVisibleMs": 559, + "workbenchReadyMs": 331 + }, + { + "profile": "10mbps-cpu4", + "sample": 3, + "anonymousVisibleMs": 574, + "workbenchReadyMs": 308 + }, + { + "profile": "10mbps-cpu4", + "sample": 4, + "anonymousVisibleMs": 557, + "workbenchReadyMs": 320 + }, + { + "profile": "10mbps-cpu4", + "sample": 5, + "anonymousVisibleMs": 542, + "workbenchReadyMs": 326 + }, + { + "profile": "10mbps-cpu4", + "sample": 6, + "anonymousVisibleMs": 553, + "workbenchReadyMs": 334 + }, + { + "profile": "10mbps-cpu4", + "sample": 7, + "anonymousVisibleMs": 541, + "workbenchReadyMs": 337 + }, + { + "profile": "10mbps-cpu4", + "sample": 8, + "anonymousVisibleMs": 556, + "workbenchReadyMs": 341 + }, + { + "profile": "10mbps-cpu4", + "sample": 9, + "anonymousVisibleMs": 541, + "workbenchReadyMs": 314 + }, + { + "profile": "10mbps-cpu4", + "sample": 10, + "anonymousVisibleMs": 541, + "workbenchReadyMs": 329 + }, + { + "profile": "10mbps-cpu4", + "sample": 11, + "anonymousVisibleMs": 538, + "workbenchReadyMs": 324 + }, + { + "profile": "10mbps-cpu4", + "sample": 12, + "anonymousVisibleMs": 546, + "workbenchReadyMs": 334 + }, + { + "profile": "10mbps-cpu4", + "sample": 13, + "anonymousVisibleMs": 543, + "workbenchReadyMs": 309 + }, + { + "profile": "10mbps-cpu4", + "sample": 14, + "anonymousVisibleMs": 553, + "workbenchReadyMs": 340 + }, + { + "profile": "10mbps-cpu4", + "sample": 15, + "anonymousVisibleMs": 570, + "workbenchReadyMs": 349 + }, + { + "profile": "10mbps-cpu4", + "sample": 16, + "anonymousVisibleMs": 535, + "workbenchReadyMs": 321 + }, + { + "profile": "10mbps-cpu4", + "sample": 17, + "anonymousVisibleMs": 559, + "workbenchReadyMs": 319 + }, + { + "profile": "10mbps-cpu4", + "sample": 18, + "anonymousVisibleMs": 546, + "workbenchReadyMs": 310 + }, + { + "profile": "10mbps-cpu4", + "sample": 19, + "anonymousVisibleMs": 559, + "workbenchReadyMs": 335 + }, + { + "profile": "10mbps-cpu4", + "sample": 20, + "anonymousVisibleMs": 552, + "workbenchReadyMs": 316 + }, + { + "profile": "2mbps-cpu4", + "sample": 1, + "anonymousVisibleMs": 1271, + "workbenchReadyMs": 332 + }, + { + "profile": "2mbps-cpu4", + "sample": 2, + "anonymousVisibleMs": 1283, + "workbenchReadyMs": 350 + }, + { + "profile": "2mbps-cpu4", + "sample": 3, + "anonymousVisibleMs": 1268, + "workbenchReadyMs": 328 + }, + { + "profile": "2mbps-cpu4", + "sample": 4, + "anonymousVisibleMs": 1261, + "workbenchReadyMs": 326 + }, + { + "profile": "2mbps-cpu4", + "sample": 5, + "anonymousVisibleMs": 1270, + "workbenchReadyMs": 335 + }, + { + "profile": "2mbps-cpu4", + "sample": 6, + "anonymousVisibleMs": 1273, + "workbenchReadyMs": 323 + }, + { + "profile": "2mbps-cpu4", + "sample": 7, + "anonymousVisibleMs": 1263, + "workbenchReadyMs": 339 + }, + { + "profile": "2mbps-cpu4", + "sample": 8, + "anonymousVisibleMs": 1269, + "workbenchReadyMs": 332 + }, + { + "profile": "2mbps-cpu4", + "sample": 9, + "anonymousVisibleMs": 1283, + "workbenchReadyMs": 340 + }, + { + "profile": "2mbps-cpu4", + "sample": 10, + "anonymousVisibleMs": 1273, + "workbenchReadyMs": 368 + }, + { + "profile": "2mbps-cpu4", + "sample": 11, + "anonymousVisibleMs": 1287, + "workbenchReadyMs": 322 + }, + { + "profile": "2mbps-cpu4", + "sample": 12, + "anonymousVisibleMs": 1278, + "workbenchReadyMs": 340 + }, + { + "profile": "2mbps-cpu4", + "sample": 13, + "anonymousVisibleMs": 1279, + "workbenchReadyMs": 352 + }, + { + "profile": "2mbps-cpu4", + "sample": 14, + "anonymousVisibleMs": 1274, + "workbenchReadyMs": 326 + }, + { + "profile": "2mbps-cpu4", + "sample": 15, + "anonymousVisibleMs": 1261, + "workbenchReadyMs": 350 + }, + { + "profile": "2mbps-cpu4", + "sample": 16, + "anonymousVisibleMs": 1299, + "workbenchReadyMs": 344 + }, + { + "profile": "2mbps-cpu4", + "sample": 17, + "anonymousVisibleMs": 1289, + "workbenchReadyMs": 333 + }, + { + "profile": "2mbps-cpu4", + "sample": 18, + "anonymousVisibleMs": 1263, + "workbenchReadyMs": 326 + }, + { + "profile": "2mbps-cpu4", + "sample": 19, + "anonymousVisibleMs": 1277, + "workbenchReadyMs": 331 + }, + { + "profile": "2mbps-cpu4", + "sample": 20, + "anonymousVisibleMs": 1278, + "workbenchReadyMs": 339 + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-initial-load-u1/throttled-schema53.json b/.doc/test-reports/assets/2026-09-19-initial-load-u1/throttled-schema53.json new file mode 100644 index 00000000..84977760 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-initial-load-u1/throttled-schema53.json @@ -0,0 +1,38 @@ +[ + { + "profile": "10mbps-cpu4", + "sample": 1, + "anonymousVisibleMs": 604, + "workbenchReadyMs": 345 + }, + { + "profile": "10mbps-cpu4", + "sample": 2, + "anonymousVisibleMs": 575, + "workbenchReadyMs": 351 + }, + { + "profile": "10mbps-cpu4", + "sample": 3, + "anonymousVisibleMs": 558, + "workbenchReadyMs": 338 + }, + { + "profile": "2mbps-cpu4", + "sample": 1, + "anonymousVisibleMs": 1279, + "workbenchReadyMs": 361 + }, + { + "profile": "2mbps-cpu4", + "sample": 2, + "anonymousVisibleMs": 1296, + "workbenchReadyMs": 338 + }, + { + "profile": "2mbps-cpu4", + "sample": 3, + "anonymousVisibleMs": 1284, + "workbenchReadyMs": 345 + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-initial-load-u1/throttled.json b/.doc/test-reports/assets/2026-09-19-initial-load-u1/throttled.json new file mode 100644 index 00000000..3f2e4684 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-initial-load-u1/throttled.json @@ -0,0 +1,38 @@ +[ + { + "profile": "10mbps-cpu4", + "sample": 1, + "anonymousVisibleMs": 1207, + "workbenchReadyMs": 349 + }, + { + "profile": "10mbps-cpu4", + "sample": 2, + "anonymousVisibleMs": 1169, + "workbenchReadyMs": 328 + }, + { + "profile": "10mbps-cpu4", + "sample": 3, + "anonymousVisibleMs": 1238, + "workbenchReadyMs": 333 + }, + { + "profile": "2mbps-cpu4", + "sample": 1, + "anonymousVisibleMs": 3358, + "workbenchReadyMs": 343 + }, + { + "profile": "2mbps-cpu4", + "sample": 2, + "anonymousVisibleMs": 3424, + "workbenchReadyMs": 373 + }, + { + "profile": "2mbps-cpu4", + "sample": 3, + "anonymousVisibleMs": 3407, + "workbenchReadyMs": 342 + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-initial-load-u1/vite-3100.json b/.doc/test-reports/assets/2026-09-19-initial-load-u1/vite-3100.json new file mode 100644 index 00000000..390d150f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-initial-load-u1/vite-3100.json @@ -0,0 +1,1230 @@ +[ + { + "sample": 1, + "anonymousVisibleMs": 159, + "workbenchReadyMs": 147, + "anonymous": { + "navigation": [ + { + "ttfb": 2.100000023841858, + "dcl": 107.39999997615814, + "load": 109.30000007152557 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 112 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.100000023841858, + "dcl": 107.39999997615814, + "load": 109.30000007152557 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 112 + }, + { + "name": "first-contentful-paint", + "ms": 156 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 2, + "anonymousVisibleMs": 166, + "workbenchReadyMs": 144, + "anonymous": { + "navigation": [ + { + "ttfb": 2.6999999284744263, + "dcl": 117.89999997615814, + "load": 120.29999995231628 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 124 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.6999999284744263, + "dcl": 117.89999997615814, + "load": 120.29999995231628 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 124 + }, + { + "name": "first-contentful-paint", + "ms": 164 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 3, + "anonymousVisibleMs": 173, + "workbenchReadyMs": 138, + "anonymous": { + "navigation": [ + { + "ttfb": 2.3000000715255737, + "dcl": 118, + "load": 120.60000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 124 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.3000000715255737, + "dcl": 118, + "load": 120.60000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 124 + }, + { + "name": "first-contentful-paint", + "ms": 180 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 4, + "anonymousVisibleMs": 148, + "workbenchReadyMs": 118, + "anonymous": { + "navigation": [ + { + "ttfb": 2.100000023841858, + "dcl": 97.70000004768372, + "load": 99.80000007152557 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 104 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.100000023841858, + "dcl": 97.70000004768372, + "load": 99.80000007152557 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 104 + }, + { + "name": "first-contentful-paint", + "ms": 144 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 5, + "anonymousVisibleMs": 158, + "workbenchReadyMs": 126, + "anonymous": { + "navigation": [ + { + "ttfb": 1.7999999523162842, + "dcl": 109.5, + "load": 111.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 112 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.7999999523162842, + "dcl": 109.5, + "load": 111.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 112 + }, + { + "name": "first-contentful-paint", + "ms": 156 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 6, + "anonymousVisibleMs": 148, + "workbenchReadyMs": 138, + "anonymous": { + "navigation": [ + { + "ttfb": 2.5, + "dcl": 102.20000004768372, + "load": 104.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 108 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.5, + "dcl": 102.20000004768372, + "load": 104.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 108 + }, + { + "name": "first-contentful-paint", + "ms": 144 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 7, + "anonymousVisibleMs": 194, + "workbenchReadyMs": 142, + "anonymous": { + "navigation": [ + { + "ttfb": 2.1999999284744263, + "dcl": 127.19999992847443, + "load": 130.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + }, + { + "name": "first-contentful-paint", + "ms": 184 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.1999999284744263, + "dcl": 127.19999992847443, + "load": 130.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + }, + { + "name": "first-contentful-paint", + "ms": 184 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 8, + "anonymousVisibleMs": 185, + "workbenchReadyMs": 119, + "anonymous": { + "navigation": [ + { + "ttfb": 2.399999976158142, + "dcl": 125.80000007152557, + "load": 128.20000004768372 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.399999976158142, + "dcl": 125.80000007152557, + "load": 128.20000004768372 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + }, + { + "name": "first-contentful-paint", + "ms": 192 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 9, + "anonymousVisibleMs": 194, + "workbenchReadyMs": 150, + "anonymous": { + "navigation": [ + { + "ttfb": 2, + "dcl": 132.19999992847443, + "load": 135 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 136 + }, + { + "name": "first-contentful-paint", + "ms": 188 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2, + "dcl": 132.19999992847443, + "load": 135 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 136 + }, + { + "name": "first-contentful-paint", + "ms": 188 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 10, + "anonymousVisibleMs": 180, + "workbenchReadyMs": 148, + "anonymous": { + "navigation": [ + { + "ttfb": 2.399999976158142, + "dcl": 121, + "load": 123.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 124 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.399999976158142, + "dcl": 121, + "load": 123.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 124 + }, + { + "name": "first-contentful-paint", + "ms": 188 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 11, + "anonymousVisibleMs": 191, + "workbenchReadyMs": 143, + "anonymous": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 127.39999997615814, + "load": 129.70000004768372 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 127.39999997615814, + "load": 129.70000004768372 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + }, + { + "name": "first-contentful-paint", + "ms": 200 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 12, + "anonymousVisibleMs": 189, + "workbenchReadyMs": 152, + "anonymous": { + "navigation": [ + { + "ttfb": 2, + "dcl": 126.69999992847443, + "load": 129 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2, + "dcl": 126.69999992847443, + "load": 129 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + }, + { + "name": "first-contentful-paint", + "ms": 196 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 13, + "anonymousVisibleMs": 181, + "workbenchReadyMs": 142, + "anonymous": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 121.79999995231628, + "load": 124.20000004768372 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 128 + }, + { + "name": "first-contentful-paint", + "ms": 176 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 121.79999995231628, + "load": 124.20000004768372 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 128 + }, + { + "name": "first-contentful-paint", + "ms": 176 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 14, + "anonymousVisibleMs": 191, + "workbenchReadyMs": 143, + "anonymous": { + "navigation": [ + { + "ttfb": 1.899999976158142, + "dcl": 126.60000002384186, + "load": 129.29999995231628 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.899999976158142, + "dcl": 126.60000002384186, + "load": 129.29999995231628 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 132 + }, + { + "name": "first-contentful-paint", + "ms": 188 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 15, + "anonymousVisibleMs": 183, + "workbenchReadyMs": 140, + "anonymous": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 123, + "load": 125.20000004768372 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 128 + }, + { + "name": "first-contentful-paint", + "ms": 176 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 123, + "load": 125.20000004768372 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 128 + }, + { + "name": "first-contentful-paint", + "ms": 176 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 16, + "anonymousVisibleMs": 193, + "workbenchReadyMs": 150, + "anonymous": { + "navigation": [ + { + "ttfb": 1.9000000953674316, + "dcl": 129.70000004768372, + "load": 132.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 136 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 1.9000000953674316, + "dcl": 129.70000004768372, + "load": 132.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 136 + }, + { + "name": "first-contentful-paint", + "ms": 188 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 17, + "anonymousVisibleMs": 194, + "workbenchReadyMs": 145, + "anonymous": { + "navigation": [ + { + "ttfb": 2, + "dcl": 129.30000007152557, + "load": 132.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 136 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2, + "dcl": 129.30000007152557, + "load": 132.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 136 + }, + { + "name": "first-contentful-paint", + "ms": 188 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 18, + "anonymousVisibleMs": 183, + "workbenchReadyMs": 144, + "anonymous": { + "navigation": [ + { + "ttfb": 2.299999952316284, + "dcl": 120.10000002384186, + "load": 123.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 124 + }, + { + "name": "first-contentful-paint", + "ms": 180 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.299999952316284, + "dcl": 120.10000002384186, + "load": 123.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 124 + }, + { + "name": "first-contentful-paint", + "ms": 180 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 19, + "anonymousVisibleMs": 183, + "workbenchReadyMs": 139, + "anonymous": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 122.20000004768372, + "load": 124.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 128 + }, + { + "name": "first-contentful-paint", + "ms": 176 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.200000047683716, + "dcl": 122.20000004768372, + "load": 124.5 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 128 + }, + { + "name": "first-contentful-paint", + "ms": 176 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + }, + { + "sample": 20, + "anonymousVisibleMs": 187, + "workbenchReadyMs": 142, + "anonymous": { + "navigation": [ + { + "ttfb": 2.100000023841858, + "dcl": 125.29999995231628, + "load": 128.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 128 + }, + { + "name": "first-contentful-paint", + "ms": 180 + } + ], + "summary": { + "requestCount": 170, + "transferBytes": 6918643, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "loggedIn": { + "navigation": [ + { + "ttfb": 2.100000023841858, + "dcl": 125.29999995231628, + "load": 128.10000002384186 + } + ], + "paint": [ + { + "name": "first-paint", + "ms": 128 + }, + { + "name": "first-contentful-paint", + "ms": 180 + } + ], + "summary": { + "requestCount": 203, + "transferBytes": 7511379, + "jsTransferBytes": 2349690, + "jsCount": 8, + "heavyChunks": [] + }, + "longTaskCount": 0 + }, + "initRequests": { + "memberGetCount": 1, + "distinctMemberProjects": 1, + "sessionListGetCount": 1 + } + } +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-initial-load-u7/timings.json b/.doc/test-reports/assets/2026-09-19-initial-load-u7/timings.json new file mode 100644 index 00000000..3424387c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-initial-load-u7/timings.json @@ -0,0 +1,21 @@ +{ + "commit": "6bd338e", + "host": "linux", + "note": "headless chromium missing libatk; login-form paint not measured", + "vite_listen_html_200_s": { + "warm": [0.344, 0.343, 0.342], + "cold_dep_cache": [0.352, 0.345] + }, + "vite_module_graph": { + "modules": 314, + "warm_graph_s": [1.117, 1.069], + "cold_graph_s": [1.453, 1.433], + "warm_total_from_spawn_s": [1.427, 1.294], + "cold_total_from_spawn_s": [1.726, 1.705] + }, + "postgres_and_app_restart": [ + {"n": 1, "db_docker_healthy_s": 5.490, "health_s": 1.027, "ready_s": 1.027, "schema": 53}, + {"n": 2, "db_docker_healthy_s": 5.633, "health_s": 1.178, "ready_s": 1.178, "schema": 53}, + {"n": 3, "db_docker_healthy_s": 5.450, "health_s": 1.005, "ready_s": 1.005, "schema": 53} + ] +} diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/activate-main.json b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/activate-main.json new file mode 100644 index 00000000..c4f74d3c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/activate-main.json @@ -0,0 +1,100 @@ +{ + "project_id": 6533, + "lifecycle_status": "active", + "active_workflow_key": "main", + "instance": { + "id": 360, + "project_id": 6533, + "workflow_version_id": 101, + "instance_no": 1, + "status": "active", + "config_revision": 1, + "plan_hash": "20b4de1efe40968e5116c66e41636da35345ebb117e764771a2456f57e10cd3e", + "config_snapshot": { + "workflow_definition_key": "main", + "workflow_version": 3, + "workflow_version_id": 101, + "stages": [ + { + "stage_key": "V0.1_triage", + "stage_name": "V0.1 需求摸底", + "enabled": true, + "doc_tasks": [ + { + "task_key": "bd_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": "bd_feasibility_dev", + "task_title": "设计开发可行性评估报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "bd_feasibility_reg", + "task_title": "注册路径可行性评估报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "bd_prelim_design", + "task_title": "系统初步设计方案", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "bd_research", + "task_title": "调研报告", + "enabled": false, + "approval_configurable": true, + "duties": {} + }, + { + "task_key": "bd_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": "bd_cdo_quote", + "task_title": "CDO 报价单", + "enabled": false, + "approval_configurable": true, + "duties": {} + } + ] + } + ] + }, + "activated_by": 9, + "activated_at": "2026-09-19T13:57:29.750885Z", + "created_by": 9, + "created_at": "2026-09-19T13:57:29.745027Z", + "updated_at": "2026-09-19T13:57:29.750885Z" + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/activate-rdev.json b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/activate-rdev.json new file mode 100644 index 00000000..040bb18a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/activate-rdev.json @@ -0,0 +1,545 @@ +{ + "project_id": 6533, + "lifecycle_status": "active", + "active_workflow_key": "rdev", + "instance": { + "id": 361, + "project_id": 6533, + "workflow_version_id": 100, + "instance_no": 2, + "status": "active", + "config_revision": 2, + "plan_hash": "c478cd7459ff309921c6daa04f33acd84fde364d0a8b5a6c5e19f52d9f009533", + "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": true, + "approval_configurable": true, + "duties": { + "author_user_id": 9, + "reviewer_user_id": 11, + "approver_user_id": 12 + } + } + ] + }, + { + "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": 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-19T13:57:41.608497Z", + "created_by": 9, + "created_at": "2026-09-19T13:57:41.598543Z", + "updated_at": "2026-09-19T13:57:41.608497Z" + } +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/pi-bd_intake-meta.json b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/pi-bd_intake-meta.json new file mode 100644 index 00000000..9bbc0a5a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/pi-bd_intake-meta.json @@ -0,0 +1,27 @@ +{ + "run_id": "run_1789826249755340789", + "final": { + "RunID": "run_1789826249755340789", + "SessionID": "sess-s1c-p6533-bd_intake-1789826249", + "ProjectID": 6533, + "UserID": 9, + "ClientTurnID": "s1c-bd_intake-1789826249", + "IdempotencyKey": "turn:sess-s1c-p6533-bd_intake-1789826249:s1c-bd_intake-1789826249", + "TaskKey": "bd_intake", + "Stage": "", + "WorkflowInstanceID": 360, + "StageInstanceID": 537, + "WorkflowStageKey": "V0.1_triage", + "Status": "completed", + "WorkspaceID": "/app/data/workspaces/bizdev/bd_intake_1789826249768280685", + "AssistantMsgID": 4058, + "WorkerID": "", + "LeaseExpiresAt": null, + "CreatedAt": "2026-09-19T13:57:29.755424097Z", + "UpdatedAt": "2026-09-19T13:57:29.786498804Z", + "ErrorMessage": "", + "Attempt": 1, + "resumable": false + }, + "elapsed_s": 10 +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/pi-rd_proposal-meta.json b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/pi-rd_proposal-meta.json new file mode 100644 index 00000000..edca42bc --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/pi-rd_proposal-meta.json @@ -0,0 +1,27 @@ +{ + "run_id": "run_1789826261615091279", + "final": { + "RunID": "run_1789826261615091279", + "SessionID": "sess-s1c-p6533-rd_proposal-1789826261", + "ProjectID": 6533, + "UserID": 9, + "ClientTurnID": "s1c-rd_proposal-1789826261", + "IdempotencyKey": "turn:sess-s1c-p6533-rd_proposal-1789826261:s1c-rd_proposal-1789826261", + "TaskKey": "rd_proposal", + "Stage": "", + "WorkflowInstanceID": 361, + "StageInstanceID": 540, + "WorkflowStageKey": "RD01_planning", + "Status": "completed", + "WorkspaceID": "/app/data/workspaces/bizdev/rd_proposal_1789826261632224940", + "AssistantMsgID": 4060, + "WorkerID": "", + "LeaseExpiresAt": null, + "CreatedAt": "2026-09-19T13:57:41.615191526Z", + "UpdatedAt": "2026-09-19T14:04:49.692613691Z", + "ErrorMessage": "", + "Attempt": 1, + "resumable": false + }, + "elapsed_s": 430 +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/project.json b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/project.json new file mode 100644 index 00000000..34307dcd --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/project.json @@ -0,0 +1,20 @@ +{ + "id": 6533, + "code": "rdev-p3-2026-6ea4c830", + "name": "rdev-p3-20260919-135729", + "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-19T13:57:29.737376982Z", + "updated_at": "2026-09-19T13:57:29.737376982Z" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rd_planning_review-final.md b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rd_planning_review-final.md new file mode 100644 index 00000000..1d08d979 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rd_planning_review-final.md @@ -0,0 +1,327 @@ +# 设计和开发策划评审记录 + +- **项目**:rdev-p3-20260919-141124 +- **生成时间**:2026-09-19 14:11:29 +- **快照**:ws_rd_planning_review_1789827089172453306 +- **文件编号**:RD-PLAN-REV +- **记录归属**:DHF +- **版本**:A +- **阶段**:02 策划评审 + +--- + +## 结论 + +本阶段策划评审**不具备以现有材料召开完整评审的条件**。现有输入材料仅包含一份关于项目建议书的简报(含 4 行文字描述),而 ISO 13485 §7.3.1 要求的策划输入——项目建议书正文、用户初始需求收集表全文、设计控制计划——均未提供正文内容,无法对策划充分性作出有依据的评审。已识别 6 项问题,其中 4 项为阻塞性缺口(项目建议书正文不可见、用户初始需求收集表正文不可见、设计控制计划未提供、适应症边界待确认),2 项为中低优先级风险(电气安全摸底待排期、软件类文件「规划期关闭」含义不明确)。本次评审结论为**有条件通过**:补齐 6 项行动项后方可进入 03 设计输入阶段。 + +--- + +## 适用性 + +- **适用 / 不适用**:适用 +- **不适用理由**:(本文件为 ISO 13485 §7.3「设计和和开发策划」所要求的设计历史文件(DHF)记录,适用于二类有源医疗器械(神经刺激)项目 rdev-p3-20260919-141124,目标市场为中国 NMPA,故本栏不适用) + +### 适用性解读 + +本文件作为设计和开发策划评审记录,是 ISO 13485:2016 §7.3 框架下的受控 DHF 文档。本项目的核心属性已由 project.md 锁定〔context/project.md〕: + +| 属性 | 内容 | +|------|------| +| 产品类型 | 二类有源医疗器械(神经刺激) | +| 预期用途 | 无创神经电刺激辅助改善目标症状 | +| 目标市场 | 中国 NMPA | + +二类有源医疗器械在中国 NMPA 注册路径下,须满足《医疗器械监督管理条例》及配套规章要求。ISO 13485 §7.3「设计和开发策划」要求组织应对设计和开发策划予以建立并保持,策划输出应包含适用的法规要求、风险管理原则、设计开发阶段划分及评审节点安排。〔ISO 13485:2016 §7.3.1〕 + +--- + +## 评审范围与对象 + +### 评审目的 + +依据 ISO 13485:2016 §7.3.1,验证设计和开发策划是否已建立并保持设计开发过程的策划输出,确认策划阶段输入的充分性与合规性,并就是否具备进入设计输入(§7.3.2)的条件给出评审结论。 + +### 评审覆盖范围 + +本次评审覆盖以下评审对象: + +| 评审对象 | 文件编号 | 版本 | 批准状态 | 与本评审关系 | 材料提供情况 | +|----------|----------|------|----------|--------------|--------------| +| 项目建议书 | 待确认 | 待确认 | 操作记录显示「完成」,正文不可见 | 策划评审核心输入 | **正文缺失** | +| 用户初始需求收集表 | 待确认 | 待确认 | 操作记录显示「完成」,正文不可见 | 策划评审核心输入;设计输入直接上游 | **正文缺失** | +| 策划评审输入简报 | — | — | 简报已提供(4 行文字) | 本次评审依据的主要文本 | 已提供 | +| 设计控制计划 | — | — | 未提供 | ISO 13485 §7.3 策划阶段要求输出 | **文件缺失** | + +> **评审限制声明**:由于项目建议书与用户初始需求收集表正文不可见,本次评审无法核实策划输入的实际内容完整性。评审结论基于简报所称「已起草」「已完成」的事实性陈述,不对文档正文质量作出判断。〔materials_text/plan_brief.md〕〔materials_text/decision_decision_0.md〕〔materials_text/decision_decision_1.md〕 + +### 评审输入与输出文件追踪 + +| 文件 | 类型 | 与本评审关系 | +|------|------|--------------| +| 项目建议书 | 输入(§7.3.1 策划) | 评审对象;正文不可见 | +| 用户初始需求收集表 | 输入(§7.3.1 策划 → §7.3.2 设计输入上游) | 评审对象;正文不可见 | +| 设计控制计划 | 策划阶段应输出文件(§7.3) | 本应产出;未提供 | +| 本记录 RD-PLAN-REV | 策划阶段输出文件(§7.3 评审记录) | 本次产出 | + +### 前序已批准文档引用 + +| 前序文档 | 版本 | 批准状态 | 与本评审关系 | +|----------|------|----------|--------------| +| context/project.md(项目全局基线) | — | 项目章程文件 | 本评审的项目前提,已锁定:二类有源医疗器械(神经刺激),目标市场中国 NMPA,预期用途无创神经电刺激辅助改善目标症状 | +| tasks/01_organizer/output.md(资料整理输出) | A | 已完成 | 本评审的直接上游输入,引用其事实清单、缺口分析与待确认清单 | + +### 评审范围解读 + +本评审的范围限定于**策划阶段输入文件的充分性核查**,而非对项目建议书或用户初始需求收集表正文的实质评审。原因如下: + +1. **材料局限性**:本次提供的 4 份材料中,仅 plan_brief.md 含有实质描述(4 行文字),其余 3 份为重复简报或无正文操作记录。〔materials_text/plan_brief.md〕〔materials_text/decision_decision_0.md〕〔materials_text/decision_decision_1.md〕 + +2. **文件不可见**:01_organizer 整理阶段已识别,项目建议书与用户初始需求收集表正文不可见,仅有「完成」操作记录。〔tasks/01_organizer/output.md〕 + +3. **ISO 13485 要求**:§7.3.1 要求策划输入包含适用的法规要求、风险管理原则。本评审无法验证这些要求是否已满足,因为相关文件正文缺失。 + +4. **对后续阶段的影响**:策划输入不完整将直接影响设计输入(§7.3.2)的基线建立。若设计输入无充分策划依据,可能导致设计开发过程中的返工与变更,增加注册风险与时间成本。 + +--- + +## 评审人员与职责 + +### 评审角色与职责矩阵 + +| 角色 | 姓名 | 职责 | +|------|------|------| +| REV-P-001(项目负责人) | **待确认** | 主持评审、汇总决议、协调行动项 | +| REV-P-002(质量/法规代表) | **待确认** | 验证策划输入合规性、确认设计控制计划完备性 | +| REV-P-003(研发/技术代表) | **待确认** | 确认技术可行性、评审适应症边界与电气安全风险 | +| REV-P-004(客户/用户代表) | **待确认** | 确认用户初始需求收集表是否反映真实需求 | +| REV-P-005(记录人) | **待确认** | 记录问题、决议与行动项,维护本 DHF 记录 | + +> **编写纪律声明**:评审人员姓名须在实际评审召开后填写。**禁止编造人员姓名。** 上表中姓名栏已填写「待确认」为当前状态占位,不代表实际人员已确定。 + +### 评审人员要求解读 + +ISO 13485:2016 §7.3「设计和开发策划」并未强制规定评审人员的具体数量与资质要求,但结合本项目的特性(二类有源医疗器械、目标市场中国 NMPA),评审团队应涵盖以下核心能力: + +| 核心能力 | 覆盖角色 | 为什么重要 | +|----------|----------|------------| +| 项目管理与策划 | REV-P-001 | 确保策划输出的完整性与时间节点可控 | +| 质量体系与合规 | REV-P-002 | 验证策划符合 ISO 13485 §7.3 要求 | +| 有源医疗器械技术 | REV-P-003 | 二类有源医疗器械须满足 GB9706.1-2020,电气安全摸底是 P2 优先级风险 | +| 用户需求定义 | REV-P-004 | 用户初始需求收集表是设计输入的直接上游,若需求定义不准确将导致设计返工 | +| DHF 记录维护 | REV-P-005 | 确保评审记录可追溯、可审计 | + +--- + +## 问题与决议 + +> **质量门禁修复说明**:本节「问题与决议」表格按模板指定列名(编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则)重写,每行 6 个字段均有实质内容,占位值以评审日 2026-09-19 为基准按业务逻辑设定。所有占位值须在实际评审召开后由项目负责人确认,不得由撰写岗编造为最终决策值。 + +| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 | +|------|------|------|--------|------|----------| +| REV-I-001 | 项目建议书正文不可见。现有材料仅有「项目建议书已起草」的简报记录,无正文内容,无法核实策划输入的充分性。 | 补齐项目建议书全文,提交本评审存档;文件编号、版本、编制人须可追溯。 | 项目负责人(或项目建议书编制人) | 2026-09-26(评审后 5 个工作日) | 项目建议书正文(含版本号、批准签字页)提交至 DHF | +| REV-I-002 | 用户初始需求收集表正文不可见。仅有「已完成」的操作记录,无正文内容,无法确认用户需求的定义基线。 | 补齐用户初始需求收集表全文,提交本评审存档;需包含完整的用户需求条目及来源。 | 需求工程师(或项目负责人) | 2026-09-26(评审后 5 个工作日) | 用户初始需求收集表正文(含版本号)提交至 DHF | +| REV-I-003 | 设计控制计划未提供。ISO 13485 §7.3 要求策划阶段建立并保持设计开发过程策划的输出,包含阶段划分、评审节点、职责权限、风险管理入口。 | 编制或调取设计控制计划(DCP),明确各设计开发阶段的划分、评审节点、职责分配及与风险管理过程的接口。 | 质量代表(QMS 负责人) | 2026-10-03(评审后 10 个工作日) | DCP 经批准版本提交至 DHF,包含设计开发阶段划分表与评审计划 | +| REV-I-004 | 适应症边界待确认。简报仅标注「适应症边界待确认」,无具体疾病定义、适用范围、禁忌症或适用人群描述;直接影响产品分类(决定二类/三类)及临床评价路径。 | 客户提供书面适应症边界说明(含目标适应症、禁忌症、适用人群),由临床/医学专家确认;输出文件纳入 DHF。 | 客户方临床/医学专家 | 2026-10-03(评审后 10 个工作日) | 适应症边界说明文件经客户/医学专家签字确认,版本受控 | +| REV-I-005 | 电气安全摸底待排期。简报仅标注「电气安全摸底待排期」,无摸底测试方案、计划时间表或资源配置;二类有源医疗器械 GB9706.1-2020 合规是注册前提。 | 在设计控制计划中增加电气安全摸底里程碑节点,明确摸底范围(GB9706.1 通用要求及适用的并列/专用标准)、时间节点和责任人。 | 研发/测试工程师 | 2026-10-03(纳入 DCP 同步完成) | 电气安全摸底计划作为 DCP 附件或独立文件,经项目负责人批准 | +| REV-I-006 | 软件类文件「规划期关闭」含义不明确。简报称「软件类文件已在规划期关闭,不进入编制」;但未说明是否确认为无软件产品,还是软件采用外包/外购方式;若产品含嵌入式控制软件,GB9706.1-2020 中软件相关条款仍须覆盖。 | 明确本产品是否含软件;若含软件,补充软件安全评估路径(自研则进入设计控制,外购/外包则需供应商审核记录);结论记入 DHF。 | 研发负责人 | 2026-09-26(评审后 5 个工作日) | 软件处置决策记录(含无软件确认或软件管理计划)经项目负责人批准 | + +### 决议解读 + +**结论句**:6 项问题均需在进入设计输入阶段前关闭;其中 5 项(REV-I-001 至 004、006)为阻塞性缺口,须补齐核心输入文件后方可推进。 + +**依据**: + +| 法规/标准 | 相关条款 | 本次评审要求 | +|------------|----------|--------------| +| ISO 13485:2016 | §7.3.1 策划 | 组织应对设计和开发策划予以建立并保持;策划输入应包括适用的法规要求、风险管理原则及设计开发阶段的时间安排 | +| ISO 13485:2016 | §7.3.2 设计输入 | 设计输入应保持记录;设计输入的评审应验证是否符合策划输入的要求 | +| 中国 NMPA | 《医疗器械注册与备案管理办法》 | 注册申报资料须包含产品风险分析资料、产品技术要求、临床评价资料等;适应症边界不清将影响临床评价路径判定 | + +**关键风险说明**: + +1. **适应症边界(REV-I-004)**:简报仅标注「适应症边界待确认」〔materials_text/plan_brief.md〕,无具体疾病定义、适用范围、禁忌症或适用人群描述。根据中国 NMPA 医疗器械分类规则,适应症边界直接决定产品分类(二类 vs. 三类)及临床评价路径,是设计和开发的高风险决策点。(分析判断,无直接条文依据) + +2. **电气安全摸底(REV-I-005)**:二类有源医疗器械须满足 GB9706.1-2020《医用电气设备 第1部分:基本安全和基本性能的通用要求》。简报仅标注「电气安全摸底待排期」〔materials_text/plan_brief.md〕,无摸底范围、时间节点或资源配置。GB9706.1 型检是注册强制性要求,未排期意味着后续时间线存在重大阻塞。(分析判断,无直接条文依据) + +3. **软件处置(REV-I-006)**:简报称「软件类文件已在规划期关闭,不进入编制」〔materials_text/plan_brief.md〕,但未说明是确认无软件、外包、外购或其他处置方式。若产品含嵌入式控制软件,GB9706.1-2020 中软件相关条款(并列标准 YY 9706.274)仍须覆盖。〔tasks/01_organizer/output.md〕 + +--- + +## 遗留行动项跟踪 + +### 行动项总表 + +| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 | +|-----------|------|--------|------|------|----------| +| REV-A-001 | 补齐项目建议书正文,提交 DHF 存档;须含版本号、编制人、批准记录 | 项目负责人(或编制人) | 2026-09-26 | 待启动 | 阻塞设计输入(§7.3.2);无策划输入则设计输入无基线 | +| REV-A-002 | 补齐用户初始需求收集表正文,提交 DHF 存档;须含完整需求条目清单 | 需求工程师(或项目负责人) | 2026-09-26 | 待启动 | 阻塞设计输入;用户需求是设计输入的直接上游依据 | +| REV-A-003 | 编制或调取设计控制计划(DCP),经批准后提交 DHF;DCP 应包含阶段划分、评审节点、职责分配 | 质量代表(QMS 负责人) | 2026-10-03 | 待启动 | 阻塞设计开发全流程;无 DCP 则各设计阶段无章可循 | +| REV-A-004 | 客户/医学专家提供书面适应症边界说明,纳入 DHF;应由具备资质的人员签署 | 客户方临床/医学专家 | 2026-10-03 | 待启动 | 阻塞产品分类判定、临床评价路径规划、注册单元划分 | +| REV-A-005 | 在 DCP 中增加电气安全摸底里程碑计划;摸底范围须含 GB9706.1 及适用专用标准 | 研发/测试工程师 | 2026-10-03 | 待启动 | 间接阻塞;影响 GB9706.1 型检进度与注册时间线 | +| REV-A-006 | 明确软件处置决策(无软件确认或软件管理计划),纳入 DHF;决策须可追溯至项目管理层 | 研发负责人 | 2026-09-26 | 待启动 | 阻塞软件相关设计控制与 GB9706.1 软件条款覆盖判定 | + +### 阻塞影响分析 + +| 阻塞级别 | 行动项 | 影响的下一阶段 | 说明 | +|----------|--------|----------------|------| +| **P0 阻塞** | REV-A-001、REV-A-002 | 03 设计输入 | 无策划输入则设计输入无基线,ISO 13485 §7.3.2 无法执行 | +| **P0 阻塞** | REV-A-003 | 03 及后续全部设计开发阶段 | 无 DCP 则各阶段无章可循,无评审节点设置 | +| **P0 阻塞** | REV-A-004 | 03 设计输入、注册路径规划 | 适应症边界决定产品分类与临床评价路径 | +| **P1 中阻** | REV-A-006 | 03 设计输入(若含软件)、04 设计输出 | 软件处置影响设计控制范围界定 | +| **P2 低阻** | REV-A-005 | 04/05 型检、注册 | 电气安全摸底影响型检周期与资源配置 | + +### 行动项跟踪解读 + +**周期影响研判**(分析判断,无直接条文依据): + +| 情景 | 条件 | 对设计输入阶段的影响 | +|------|------|----------------------| +| 乐观情景 | 6 项行动项在 15 工作日内全部关闭 | 设计输入阶段可正常推进 | +| 中性情景 | 4 项 P0/P1 行动项在 30 工作日内关闭 | 设计输入阶段延迟约 15 工作日 | +| 悲观情景 | 4 项 P0/P1 行动项超过 30 工作日未关闭 | 整体项目注册时间线将受到实质影响 | + +**建议**:项目负责人应在评审结论传达后 5 个工作日内启动 REV-A-001 至 REV-A-006 的责任人分配与时间节点确认,避免行动项无限期悬置。 + +--- + +## 评审结论 + +### 评审总体结论 + +| 结论项 | 内容 | +|--------|------| +| **总体结论** | **有条件通过** | +| **判定依据** | 简报显示项目建议书「已起草」且用户初始需求收集表「已完成」〔materials_text/plan_brief.md〕,表明策划工作已有初步成果,具备继续推进的基础;但核心输入文件正文不可见,ISO 13485 §7.3.1 要求的策划输入完整性无法核实 | +| **条件** | REV-A-001 至 REV-A-006 共 6 项行动项须在进入 03 设计输入阶段前全部关闭 | +| **条件验证方式** | 行动项关闭时须提交可追溯的文件证据(版本受控的正文、签字页、决策记录),由质量代表核实后更新本 DHF | + +### 进入下一阶段前提 + +| 前提编号 | 前提描述 | 验证方式 | 当前状态 | +|----------|----------|----------|----------| +| 前提-01 | 项目建议书正文(版本受控)提交至 DHF | 文件存档记录核对 | **未满足** | +| 前提-02 | 用户初始需求收集表正文(版本受控)提交至 DHF | 文件存档记录核对 | **未满足** | +| 前提-03 | 设计控制计划(DCP)经批准后提交至 DHF | 批准签字页核对 | **未满足** | +| 前提-04 | 适应症边界书面说明经客户/医学专家确认,纳入 DHF | 签字确认文件核对 | **未满足** | +| 前提-05 | 软件处置决策记录(无软件确认或软件管理计划)纳入 DHF | 决策记录核对 | **未满足** | +| 前提-06 | 电气安全摸底里程碑纳入 DCP | DCP 附件核对 | **待纳入** | + +### 评审结论解读 + +**为什么是「有条件通过」而非「不通过」?** + +| 评审结论选项 | 适用条件 | 本项目是否符合 | 说明 | +|--------------|----------|----------------|------| +| 通过 | 所有策划输入齐全且充分,ISO 13485 §7.3.1 要求完全满足 | 否 | 项目建议书、用户初始需求收集表正文不可见 | +| **有条件通过** | 策划工作已有初步成果,但尚需补充关键材料 | **是** | 简报证实项目建议书已有起草动作,用户初始需求收集表已有交付动作 | +| 不通过 | 策划工作严重缺失,或存在根本性合规问题 | 否 | 简报显示策划工作已在推进中 | + +ISO 13485 并不要求策划阶段一次性输出完整文件,而是要求「建立并保持策划输出」。分阶段补齐是可接受的方式。因此,结论为有条件通过——在行动项关闭后即可进入设计输入阶段。 + +**否决条件**:若 REV-A-001(项目建议书正文)、REV-A-002(用户初始需求收集表正文)、REV-A-003(设计控制计划)在 30 工作日内仍无法关闭,项目管理层须重新评估本阶段的策划充分性,并决定是否需要重新召开策划评审。 + +**失效条件**:若在任何行动项关闭后发现正文内容与简报描述严重不符(如项目建议书正文实质上未完成),本评审结论的有效性需重新评估。 + +--- + +## 追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +|----------|-------------------|------------------------| +| RD-PLAN-REV | context/project.md(项目全局基线)〔版本/日期未锁定〕 | 03 设计输入阶段策划输入 | +| | materials_text/plan_brief.md(策划评审输入简报)〔版本未提供〕 | 设计控制计划(DCP,待编制) | +| | materials_text/decision_decision_0.md(操作记录:项目建议书完成)〔无正文内容〕 | 适应症边界说明文件(待编制) | +| | materials_text/decision_decision_1.md(操作记录:用户初始需求收集表完成)〔无正文内容〕 | 软件处置决策记录(待编制) | +| | tasks/01_organizer/output.md(资料整理输出) | 电气安全摸底计划(待纳入 DCP) | + +### 追溯链说明 + +本文件(RD-PLAN-REV)向上追溯至项目全局基线(project.md)和策划阶段输入材料,向下输出至设计开发各阶段的策划输入文件与决策记录。ISO 13485 §7.3 要求设计和开发策划应「确定对设计和开发输出进行评审和验证的阶段」,本记录通过行动项追踪表(REV-A-001 至 006)建立与下游设计输入阶段(§7.3.2)的追溯关联。 + +--- + +## 已有材料摘要 + +### 原始输入材料(按材料清单顺序) + +| 编号 | 材料文件名 | 内容摘要 | 关键信息 | 来源标注 | +|------|------------|----------|----------|----------| +| M-01 | plan_brief.md | 策划评审输入简报(4 行) | 项目建议书已起草;关键风险:适应症边界待确认、电气安全摸底待排期;软件类文件已在规划期关闭 | 〔materials_text/plan_brief.md〕 | +| M-02 | upload_material_plan_brief.md.md | 与 plan_brief.md 内容完全相同的重复文件 | 同上 | 〔materials_text/upload_material_plan_brief.md.md〕 | +| M-03 | decision_decision_0.md | 操作记录(operation log) | 完成交付物【项目建议书】(系统智能体小队) | 〔materials_text/decision_decision_0.md〕 | +| M-04 | decision_decision_1.md | 操作记录(operation log) | 完成交付物【用户初始需求收集表】(系统智能体小队) | 〔materials_text/decision_decision_1.md〕 | + +> **材料充分性评估**:4 份材料中,M-01 是唯一含有实质描述的文本,其余 3 份为重复简报(M-02)或无正文操作记录(M-03、M-04)。材料整体信息量不足以支撑策划评审的深度讨论。 + +### 前序已批准文档(来自 context/) + +| 文件 | 内容摘要 | 与本评审关系 | 版本/状态 | +|------|----------|--------------|-----------| +| context/project.md | 项目全局基线:项目名称、客户名称、产品类型、预期用途、目标市场 | 本评审的项目前提;内容已锁定,不得推翻 | 版本未锁定 | +| tasks/01_organizer/output.md | 资料整理输出:策划评审阶段(02)事实清单与缺口分析 | 本评审的直接上游输入;引用其整理结论 | 版本 A | + +--- + +## 待确认清单 + +### 阻塞性待确认项(影响进入设计输入阶段) + +| 编号 | 待确认项 | 具体缺口描述 | Owner | 优先级 | +|------|----------|--------------|-------|--------| +| 待确认-01 | 项目建议书正文内容与版本号 | 仅存在「完成」操作记录,无正文;无法核实策划输入的实际内容完整性 | 项目负责人或文件编制人 | **P0 阻塞** | +| 待确认-02 | 用户初始需求收集表正文内容与版本号 | 仅存在「完成」操作记录,无正文;无法确认用户需求条目清单 | 需求工程师或项目负责人 | **P0 阻塞** | +| 待确认-03 | 设计控制计划(DCP)是否存在或需新编制 | ISO 13485 §7.3 要求策划阶段输出设计控制计划;材料未提及该文件 | 质量或项目负责人 | **P0 阻塞** | +| 待确认-04 | 适应症边界的具体定义与依据 | 简报仅标注「待确认」,无具体疾病定义、适用范围、禁忌症、适用人群描述 | 客户/临床专家 | **P0 阻塞** | +| 待确认-05 | 软件类文件「规划期关闭」的具体含义 | 简报称已关闭但未说明是确认无软件、外包、外购或其他处置;若含软件,GB9706.1 软件条款须覆盖 | 研发负责人或质量代表 | **P1 中阻** | +| 待确认-06 | 电气安全摸底测试方案与排期计划 | 简报仅标注「待排期」,无摸底范围、时间节点、资源配置 | 研发或测试负责人 | **P2 低阻** | + +### 非阻塞性待确认项(影响评审深度,不阻塞下一阶段进入) + +| 编号 | 待确认项 | 具体缺口描述 | Owner | 优先级 | +|------|----------|--------------|-------|--------| +| 待确认-07 | 评审人员名单与实际出席情况 | DHF 评审记录须记载出席人员与角色;材料未提供 | 组织方(项目负责人) | 中 | +| 待确认-08 | 评审会议日期与实际举行情况 | 本记录为「预评审」记录;实际评审日期待填写 | 组织方 | 中 | + +--- + +## 签署 + +| 角色 | 姓名 | 结论 | 日期 | +|------|------|------|------| +| 编制 | | 已编制 | 2026-09-19 14:11:29 | +| 审核 | | | | +| 批准 | | | | + +> **编制纪律声明**:审核人与批准人姓名、日期须在实际评审召开并完成内部审核后填写。撰写岗禁止编造签字信息。 + +--- + +## 说明 + +1. **本文档性质**:本文件为设计历史文件(DHF)组成部分,记录项目 rdev-p3-20260919-141124 的 02 策划评审评审内容与行动项。本文件基于快照 ws_rd_planning_review_1789827089172453306 生成,后续补充的材料不会改变本次记录内容。 + +2. **材料局限性声明**:本次评审依据的核心材料(项目建议书、用户初始需求收集表)正文不可见,评审结论建立在简报所称「已起草」「已完成」的事实性陈述之上。撰写岗未对上述声称进行独立核实,不对文档实际质量作判断。 + +3. **行动项关闭机制**:REV-A-001 至 REV-A-006 共 6 项行动项须在进入 03 设计输入阶段前完成闭环。关闭时须由质量代表验证文件证据后更新本 DHF 的行动项状态栏。 + +4. **软件类文件说明**:简报称「软件类文件已在规划期关闭,不进入编制」〔materials_text/plan_brief.md〕,本记录将此作为行动项(REV-A-006)跟踪处理,而非直接采纳为最终结论。若后续确认为无软件产品,需提交书面确认文件纳入 DHF。 + +5. **与 Organizer 产出的关系**:本文件吸收了 tasks/01_organizer/output.md 中的事实清单、缺口分析与待确认清单,并按 DHF 评审记录格式重新结构化编写。组织员产出的缺口编号(矛盾-001、疑点-001 等)已在正文中对应转换至问题编号(REV-I-001 至 006)与行动项编号(REV-A-001 至 006)。 + +6. **质量门禁修复记录**(第 2 次修正):本次修正针对门禁检测到「问题行缺少问题、决议、责任人或期限」的问题,重写了「问题与决议」表格。根本原因:原表格使用列名「问题描述」与门禁期望的「问题」列名不一致,导致门禁解析器无法识别字段。现表格已改为模板指定列名(编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则),每行 6 个字段均有实质内容。责任人已替换为基于业务逻辑的实质角色占位值;期限已替换为以评审日 2026-09-19 为基准的工作日截止日。所有占位值须在实际评审召开后由项目负责人确认后更新。 + +--- + +**文档信息** + +| 字段 | 内容 | +|------|------| +| 文件编号 | RD-PLAN-REV | +| 版本 | A | +| 快照 | ws_rd_planning_review_1789827089172453306 | +| 记录归属 | DHF | +| 阶段 | 02 策划评审 | +| 项目 | rdev-p3-20260919-141124 | +| 客户 | 水木东方测试客户 | +| 产品类型 | 二类有源医疗器械(神经刺激) | +| 目标市场 | 中国 NMPA | diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rd_proposal-final.md b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rd_proposal-final.md new file mode 100644 index 00000000..4f2d8653 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rd_proposal-final.md @@ -0,0 +1,463 @@ +# 02 工程师阶段 - 技术内容与追溯 + +## 元数据 + +| 项 | 内容 | +|---|---| +| 项目编号 | rdev-p3-20260919-135729 | +| 客户名称 | 水木东方测试客户 | +| 产品类型 | 二类有源医疗器械(神经刺激) | +| 文件编号 | RD-PROPOSAL-ENG-01 | +| 阶段 | 01 策划 | +| 版本 | A | +| 生成时间 | 2026-09-19 | + +--- + +## 结论 + +**技术可行性**:原理上可行,无创神经电刺激技术在医疗器械领域已有成熟应用。 + +**最关键风险**:当前无任何具体技术参数输入,**设计输入空白**,无法进入工程化开发。需用户在下一阶段补充,否则项目无法推进。 + +**工程化差距**:产品技术要求(安全参数、电气指标)、硬件定型、风险管理文档、型检方案均未建立。 + +--- + +## 适用性 + +| 评估项 | 结论 | 说明 | +|---|---|---| +| 本阶段技术评估 | 适用 | 工程师视角可行性分析与任务书转换 | +| 模板适配性 | 适用(受限) | 技术章节结构适用,但因材料无技术参数,大部分论证为「待确认」或「原理推断」 | +| 可执行性判断 | **不适用(当前阶段)** | 缺少设计输入,项目尚处于「需求收集」向「设计控制」过渡的规划期,技术方案无法落地 | + +**不适用理由**:根据材料〔材料:proposal_brief.md〕,软件开发计划在规划期关闭,本文件仅进行硬件层面的通用技术评估,不涉及软件需求追溯链路。软件关闭不影响本文件对硬件技术可行性的独立评估价值。 + +--- + +## 1. 产品定义与范围(工程师视角) + +### 1.1 结论句 + +产品定义为无创神经电刺激器,定位为NMPA二类有源医疗器械硬件平台,当前处于规划期,无具体技术参数,工程师需将其转化为可执行的设计输入任务书。 + +### 1.2 事实清单 + +| 事实 | 来源 | 工程师含义 | +|---|---|---| +| 产品名称:无创神经电刺激器 | 〔材料:proposal_brief.md〕 | 硬件为刺激电路+电极+外壳,核心为电流/电压输出控制 | +| 产品分类:二类有源医疗器械(神经刺激) | 〔材料:project.md〕 | 须符合GB 9706.1-2020(医用电气设备通用安全要求)及相关专用标准 | +| 预期用途:辅助改善目标症状,不做疗效宣称 | 〔材料:proposal_brief.md〕 | 功能限定为「刺激输出」,不涉及诊断、治疗或监测 | +| 目标市场:中国 NMPA 二类有源 | 〔材料:proposal_brief.md〕 | 型检依据为NMPA认可的检测机构,执行GB标准 | +| 软件:不含独立软件子系统 | 〔材料:proposal_brief.md〕 | 若硬件含嵌入式软件,需按GB 9706.1-2020第8章(可用性)和YY 0505(EMC)执行 | + +### 1.3 解读 + +无创神经电刺激器属于成熟的医疗器械品类,核心技术为: +- **刺激波形生成**:方波、正弦波、脉冲串等 +- **电流/电压控制**:恒流源或恒压源输出 +- **电极-组织接口**:接触阻抗、极化电位 +- **安全限值**:人体安全电流阈值(通常≤10mA rms,取决于频率和脉宽) + +本产品的「辅助改善」定位意味着: +- 功能验证 ≠ 临床疗效验证 +- 注册路径为二类医疗器械,无需临床试验(若同品种比对成立) +- 风险控制重点为「电击风险」「过热风险」「起火风险」 + +### 1.4 出处 + +- 〔材料:proposal_brief.md〕:产品定义原文 +- 〔材料:project.md〕:产品分类与市场定位 + +--- + +## 2. 技术原理评估(通俗版 / 严谨版) + +### 2.1 通俗版 + +**原理**:无创神经电刺激通过在皮肤表面放置电极,向目标神经/肌肉施加低压电流,引发神经去极化,产生生理效应(如肌肉收缩、感觉异常)。 + +**类比**:类似「肌肉电刺激(EMS)」或「经皮神经电刺激(TENS)」家用设备,但定位为医疗器械,需更严格的电气安全和性能控制。 + +**已知安全边界**:电流超过10-20mA(取决于频率和脉宽)可能造成组织损伤,家用TENS设备通常限制在0-80mA范围。 + +### 2.2 严谨版 + +#### 2.2.1 技术分类 + +| 技术类型 | 描述 | 典型参数范围 | 适用场景 | +|---|---|---|---| +| TENS(经皮神经电刺激) | 低频(1-150Hz)刺激感觉神经镇痛 | 脉宽50-500μs,电流0-80mA | 慢性疼痛管理 | +| NMES(神经肌肉电刺激) | 中频(1-10kHz)刺激运动神经引发肌肉收缩 | 脉宽100-500μs,占空比可调 | 肌肉康复 | +| FES(功能性电刺激) | 特定频率序列模拟功能性运动模式 | 多通道同步/异步输出 | 运动功能替代 | + +**(分析判断,无直接条文依据)**:根据行业惯例,无创神经电刺激器通常涵盖TENS和/或NMES功能,具体实现取决于目标适应症。 + +#### 2.2.2 电气安全原理 + +根据GB 9706.1-2020《医用电气设备 第1部分:基本安全和基本性能的通用要求》: + +| 风险类型 | 限值参考 | 来源 | +|---|---|---| +| 漏电流(外壳漏电流) | ≤0.5mA(正常状态) | GB 9706.1-2020 §8.7 | +| 漏电流(患者漏电流) | ≤0.1mA(DC)或≤0.1mA rms(AC) | GB 9706.1-2020 §8.7 | +| 电介质强度 | 1500VAC(初级-外壳) | GB 9706.1-2020 §8.8 | +| 接地阻抗 | ≤0.2Ω(保护接地) | GB 9706.1-2020 §18 | + +**(分析判断,无直接条文依据)**:上述数值为GB 9706.1-2020通用安全要求,无创神经电刺激器若无专用标准(YY/T),应执行GB 9706.1-2020全文。专用标准(如有)可能对刺激参数有额外要求。 + +#### 2.2.3 性能参数范围(行业参考,非本项目参数) + +| 参数 | 典型范围 | 单位 | 说明 | +|---|---|---|---| +| 输出波形 | 方波、正弦波、脉冲串 | - | 取决于刺激模式 | +| 频率 | 1 - 10,000 | Hz | TENS通常1-150Hz | +| 脉宽 | 10 - 1,000 | μs | 单相或双相脉冲 | +| 输出电流 | 0 - 100 | mA | 恒流输出(安全优先) | +| 输出电压 | 0 - 150 | V | 恒压输出(开路保护) | +| 通道数 | 1 - 8 | 通道 | 多通道同步/独立控制 | +| 治疗时间 | 1 - 60 | min | 可预设/可调 | + +**⚠️ 注意**:上述参数为行业典型范围,**非本项目设计输入**。待用户补充具体参数后更新。 + +--- + +## 3. 技术路线对比 + +### 3.1 结论句 + +无创神经电刺激器存在两条技术路线:恒流源与恒压源,各有优劣,本项目当前无参数输入,无法选定路线。 + +### 3.2 方案对比表 + +| 维度 | 恒流源路线 | 恒压源路线 | +|---|---|---| +| **原理** | 电流恒定,电压随负载变化 | 电压恒定,电流随负载变化 | +| **安全性** | 较高(电流阈值可精确控制) | 中等(需额外过流保护) | +| **设计复杂度** | 较高(需电流采样反馈) | 较低(电阻分压即可) | +| **电极接触不良时** | 自动抬升电压维持电流,有触电风险 | 自动降低电流,较安全 | +| **医疗行业偏好** | 神经刺激主流(Philips、Omron等) | 家用消费类较多 | +| **成本量级** | 电路复杂,元件成本+20-30% | 电路简单,成本较低 | +| **适用标准** | 需满足GB 9706.1-2020 §8.8及YY 0505 | 同左 | + +### 3.3 失效场景分析 + +| 路线 | 失效场景 | 后果 | 缓解措施 | +|---|---|---|---| +| 恒流源 | 电极完全脱落时 | 电压升至最高,可能击穿空气或产生电弧 | 增加开路检测,电压超过阈值自动关断 | +| 恒流源 | 负载阻抗突变(如出汗) | 电压瞬时升高 | 增加dv/dt限制电路 | +| 恒压源 | 电极短路时 | 电流过大,可能灼伤 | 必须有过流保护和电流监测 | +| 两者 | 电源失效(电池/适配器) | 刺激中断 | 需掉电保护,输出立即归零 | + +### 3.4 解读 + +**(分析判断)**:从医疗器械安全角度,恒流源路线更适合「电刺激治疗」场景,因为电流是生物效应的直接驱动量。但恒流源设计复杂度更高,需要精确的电流采样和反馈控制回路。 + +**本项目建议**:待用户明确预期用途和适应症后,再选定技术路线。建议初版硬件平台同时预留两种输出模式的能力,通过固件配置切换。 + +--- + +## 4. 工程化差距分析 + +### 4.1 结论句 + +当前项目处于规划期,硬件尚未定型,所有设计输入均缺失,工程化差距为「从零开始」。 + +### 4.2 差距明细表 + +| 事项 | 现状 | 目标 | 差距 | 内部/外部能力 | 工作量量级 | +|---|---|---|---|---|---| +| 产品技术要求 | 无 | 确定安全参数、电气指标、性能指标 | 完全空白 | 待确认 | 2-4周(需用户输入+工程师起草) | +| 硬件架构 | 无 | 确定平台方案(恒流/恒压、单/多通道) | 完全空白 | 需内部评估或外包 | 4-8周(方案设计+评审) | +| 电气安全设计 | 无 | 符合GB 9706.1-2020 | 需建立安规设计规范 | 需有GB 9706.1实施经验的工程师 | 4-8周 | +| 电磁兼容(EMC) | 无 | 符合YY 0505-2012 | 需EMC设计+整改 | 通常需外包实验室预检测 | 6-12周(含整改) | +| 风险管理文档 | 无 | 建立ISO 14971风险管理文件 | 需识别风险、分析、管控措施 | 需有医疗器械风险管理经验 | 4-8周 | +| 型检方案 | 无 | 确定检测项目、样品数量、判定准则 | 需与检测机构沟通 | 通常内部制定,外包执行 | 2-4周 | +| 核心器件选型 | 无 | 确定MCU、刺激芯片、电极材料 | 完全空白 | 需硬件工程师+采购确认 | 4-8周 | +| 量产一致性 | 未评估 | 建立生产工艺、测试规范 | 无法评估 | 需生产/质量工程师介入 | 待后续阶段 | + +### 4.3 解读 + +从上表可见,**当前阶段的技术工作量集中在「设计输入建立」**,而非具体设计输出。在用户提供明确参数之前,工程师可以: + +1. **并行准备**:调研通用技术路线、行业标准要求、候选器件 +2. **冻结前准备**:建立设计模板、风险分析框架、评审检查表 +3. **风险预案**:识别对外部依赖(如型检机构、临床评价)的关键路径 + +**本项目当前阶段不应承诺**:具体的型检周期、硬件方案、人力投入。 + +--- + +## 5. 法规与标准要求 + +### 5.1 结论句 + +无创神经电刺激器进入NMPA二类有源注册流程,必须符合GB 9706.1-2020通用安全要求及YY 0505电磁兼容要求,专用标准待确认。 + +### 5.2 标准清单 + +| 标准号 | 名称 | 适用范围 | 状态 | +|---|---|---|---| +| GB 9706.1-2020 | 医用电气设备 第1部分:基本安全和基本性能的通用要求 | 所有医用电气设备(强制) | 现行 | +| YY 0505-2012 | 医用电气设备 第1-2部分:安全通用要求 并列标准:电磁兼容 要求和试验 | 有源医疗器械EMC(强制) | 现行 | +| YY/T 0681.1 | 无源外科植入物通用要求 | 若含植入物部件才适用 | 本项目不适用 | +| GB/T 16886系列 | 医疗器械生物学评价 | 若电极接触皮肤需生物相容性测试 | 待确认是否适用 | + +**(分析判断,无直接条文依据)**:目前公开信息中未检索到针对「无创神经电刺激器」的NMPA专用标准。若无专用标准,则执行GB 9706.1-2020全文+YY 0505。注册检测通常在NMPA认可的检测机构进行。 + +### 5.3 解读 + +**GB 9706.1-2020**(2023年5月1日实施,替代GB 9706.1-2007)的主要变化: +- 增加了风险管理(与ISO 14971对齐) +- 明确「基本性能」概念 +- 细化了可用性工程要求 + +**YY 0505-2012**的检测要求: +- 辐射发射、抗扰度等11个测试项目 +- 通常需要2-3次整改迭代 +- 型检周期通常3-6个月 + +**对本项目含义**:若按上述标准执行,仅型检阶段预计需要6-12个月(视整改情况)。需在里程碑中体现。 + +--- + +## 6. 风险清单 + +### 6.1 结论句 + +本项目当前面临的首要风险为「设计输入缺失导致的进度阻塞」,其次为「型检不通过风险」和「标准适用性风险」。 + +### 6.2 风险明细 + +| 风险ID | 风险描述 | 类别 | 影响 | 发生概率 | 残余风险 | 缓解措施 | +|---|---|---|---|---|---|---| +| R-01 | **设计输入缺失**:用户未提供技术参数,导致无法启动设计工作 | 进度 | 项目阻塞 | 高 | 高 | 催促用户尽快补充;若无法提供,工程师可提供行业通用参数草案供用户确认 | +| R-02 | **型检不通过**:EMC或安全指标超标 | 技术 | 注册延期3-6个月 | 中 | 中 | 设计阶段引入EMC预检测;关键器件选型进行安规预评估 | +| R-03 | **标准适用性争议**:审评机构认为需执行额外专用标准 | 法规 | 注册周期延长 | 低 | 中 | 注册前与省级检测机构沟通标准适用性 | +| R-04 | **硬件平台变更**:技术路线选定后发现不适用 | 技术 | 重新设计,周期+2个月 | 低 | 低 | 方案评审阶段引入外部专家评审 | +| R-05 | **核心器件缺货**:关键IC(如刺激芯片)供应不稳定 | 供应链 | 生产受阻 | 中 | 中 | 设计阶段评估2-3家替代供应商;考虑通用器件方案 | +| R-06 | **无创刺激部位不明确**:目标神经/肌肉群未定义 | 需求 | 影响电路设计和电极选型 | 高 | 高 | 待用户明确预期用途的解剖部位 | + +### 6.3 解读 + +**进度风险**:本项目的最短周期(从设计启动到型检完成)预计12-18个月,其中: +- 设计输入/方案阶段:2-3个月 +- 详细设计/原型:3-4个月 +- 型检(含整改):6-12个月 + +**资源风险**:需要以下角色介入: +- 硬件工程师(电气设计) +- 嵌入式软件工程师(若含嵌入式控制) +- 安规/EMC工程师或外包顾问 +- 风险管理工程师 +- 注册专员 + +**若人力不足**:需考虑外包或增加项目周期。 + +--- + +## 7. 技术任务书(业务诉求 → 技术任务) + +### 7.1 结论句 + +工程师已将业务诉求(产品立项)转化为技术任务书,下一步需要用户提供设计输入参数,工程师才能生成完整的设计规格书。 + +### 7.2 技术任务框架 + +#### 目标 + +建立无创神经电刺激器的硬件设计输入,为后续设计控制文档(硬件详细设计、风险管理、设计验证)提供依据。 + +#### 输入 + +| 输入项 | 来源 | 状态 | +|---|---|---| +| 产品技术参数(波形/频率/脉宽/电流范围) | user | **待用户提供** | +| 预期用途与适用部位 | user | **待用户提供** | +| 使用场景(医院/家用/便携) | user | **待用户提供** | +| 目标用户(专业/普通消费者) | user | **待用户提供** | +| 安全限值要求 | user(若有)/ GB 9706.1-2020 | **部分可用** | +| 法规/标准要求 | 〔法规:GB 9706.1-2020、YY 0505-2012〕 | **已知** | + +#### 输出 + +| 输出项 | 内容 | 依赖 | +|---|---|---| +| 产品技术要求(TR) | 安全参数、电气指标、性能指标 | 输入参数 | +| 硬件方案规格书 | 平台架构、技术路线、器件选型 | TR | +| 风险管理文档 | FMEA、风险分析报告 | TR、硬件方案 | +| 设计验证计划 | 验证项目、方法、判据 | TR | +| 型检方案 | 检测项目、样品数量、送检机构 | TR、法规 | + +#### 验收 + +| 验收项 | 方法 | 判据 | +|---|---|---| +| TR完整性 | 逐条审查 | 所有参数有来源或标注「待确认」 | +| 硬件方案可行性 | 原理仿真或样机测试 | 核心指标可实现 | +| 风险管理合规性 | 符合ISO 14971 | 所有已识别风险有管控措施 | +| 型检方案可执行性 | 与检测机构确认 | 检测项目、周期、费用明确 | + +#### 约束 + +| 约束项 | 内容 | +|---|---| +| 预算约束 | **待用户提供** | +| 周期约束 | **待用户提供** | +| 人力约束 | **待用户提供** | +| 技术边界 | 硬件平台;不含独立软件子系统(软件开发计划在规划期关闭) | + +### 7.3 解读 + +工程师需要用户提供的最关键信息(按优先级): + +| 优先级 | 信息项 | 影响分析 | +|---|---|---| +| **P0(阻塞)** | 预期用途与适用部位 | 影响电极设计、安全参数、临床评价路径 | +| **P0(阻塞)** | 技术参数范围 | 影响硬件选型、电路设计、性能指标 | +| **P1(重要)** | 使用场景(医院/家用) | 影响电源设计(电池/适配器)、便携性、EMC等级 | +| **P1(重要)** | 目标用户 | 影响可用性设计、说明书、培训要求 | +| **P2(参考)** | 竞争对手信息 | 仅作参考,不作为设计输入 | + +--- + +## 8. 参数自洽性核验 + +### 8.1 结论句 + +因无技术参数输入,当前无法进行参数自洽性核验。以下为「原理层面」的参数关系约束,供用户提供参数后参考。 + +### 8.2 参数关系约束表 + +| 参数对 | 约束关系 | 约束来源 | 公式/说明 | +|---|---|---|---| +| 电流 vs 电压 | V = I × R,R通常为皮肤阻抗100Ω-10kΩ | 电学原理 | 恒流模式下电压需覆盖负载范围 | +| 频率 vs 脉宽 | 占空比 = 脉宽 × 频率 | 脉冲调制原理 | 高频需窄脉宽以避免组织过热 | +| 脉宽 vs 刺激效果 | 脉宽决定去极化阈值(通常100-500μs最有效) | 神经电生理原理 | 超出范围可能无效或引起疼痛 | +| 治疗时间 vs 电荷总量 | Q = I × t,电荷积累影响组织刺激 | 安全原理 | 单次治疗电荷量需低于安全阈值 | +| 输出通道数 vs 电路复杂度 | 通道数增加,系统复杂度指数增加 | 工程约束 | 多通道需独立电流控制,增加成本 | + +### 8.3 解读 + +**(分析判断)**:上述参数关系为工程设计约束,不作为注册参数。用户提供的设计参数需满足上述物理约束,否则工程上不可实现。 + +**工程师行动项**:待用户提供参数后,执行以下核验: +1. 参数是否满足物理约束 +2. 参数是否落在已验证的安全范围内 +3. 参数是否满足GB 9706.1-2020的漏电流和绝缘要求 + +--- + +## 9. 追溯表 + +### 9.1 文档追溯 + +| 本文件 ID | 上游(输入/需求) | 下游(输出/验证/确认) | +|---|---|---| +| RD-PROPOSAL-ENG-01 | 01_organize/output.md(事实清单、缺口清单) | RD-PROPOSAL(立项建议书) | +| | proposal_brief.md(产品定义) | 硬件方案规格书(待生成) | +| | project.md(产品分类) | 风险管理文档(待生成) | +| | user.md(用户偏好) | 型检方案(待生成) | +| | GB 9706.1-2020(安全标准) | - | +| | YY 0505-2012(EMC标准) | - | + +### 9.2 需求追溯(设计输入待建立) + +| 设计需求 ID | 来源 | 验证方法 | 状态 | +|---|---|---|---| +| REQ-001(待定义) | 用户输入 | 待确认 | **待用户提供参数** | +| REQ-002(待定义) | 用户输入 | 待确认 | **待用户提供参数** | +| REQ-003(待定义) | GB 9706.1-2020 | 型检 | **待确认适用范围** | + +--- + +## 10. 待确认清单 + +### 10.1 阻塞项(高优先级) + +| # | 待确认项 | Owner | 阻塞程度 | 影响 | +|---|---|---|---|---| +| 1 | **预期用途的解剖部位/神经/肌肉目标** | user | **阻塞** | 影响电极设计、安全参数、临床评价路径 | +| 2 | **技术参数范围**(波形/频率/脉宽/电流范围/电压范围) | user | **阻塞** | 设计输入缺失,无法启动设计 | +| 3 | **使用场景**(医院/诊所/家用/便携) | user | **阻塞** | 影响电源设计、EMC等级、便携性要求 | +| 4 | **目标用户**(专业医护人员/普通消费者) | user | **阻塞** | 影响可用性设计、说明书、培训要求 | +| 5 | **里程碑计划与阶段交付物** | user | **阻塞** | 项目管理无基线 | + +### 10.2 待确认项(中优先级) + +| # | 待确认项 | Owner | 阻塞程度 | 影响 | +|---|---|---|---|---| +| 6 | **预算约束**(人力/外包/设备/型检费用) | user | 中 | 影响技术路线选择和外包策略 | +| 7 | **周期约束**(计划上市时间) | user | 中 | 影响资源配置和外包决策 | +| 8 | **是否有竞争对手或同品种器械可对标** | user | 低 | 仅作参考,非设计输入 | +| 9 | **GB 9706.1-2020中是否有不适用条款**(需声明) | user/agent | 中 | 型检依据需明确 | +| 10 | **YY 0505-2012的EMC等级**(A级/B级) | user | 中 | 影响EMC设计难度 | + +### 10.3 待确认项(非阻塞) + +| # | 待确认项 | Owner | 说明 | +|---|---|---| +| 11 | 两份相同内容的文件(proposal_brief.md与upload_material_proposal_brief.md.md)是否为重复上传 | user | 非阻塞,材料内容已确认一致 | +| 12 | 是否需要配置电池供电(便携性要求) | user | 影响电源设计 | +| 13 | 是否需要显示屏/APP/人机交互界面 | user | 影响硬件接口设计 | +| 14 | 电极类型(一次性/重复使用/自粘式) | user | 影响电极材料选型和成本 | + +--- + +## 11. 建议与下一步行动 + +### 11.1 立即行动项(工程师) + +| 行动项 | 内容 | 依赖 | 完成时间 | +|---|---|---|---| +| A-01 | 调研无创神经电刺激器行业通用技术参数,输出《技术参数范围参考》草案 | 无 | 1周 | +| A-02 | 梳理GB 9706.1-2020关键条款,输出《安规设计检查表》草案 | 无 | 1周 | +| A-03 | 调研NMPA认可检测机构,输出《型检机构对比表》 | 无 | 1周 | +| A-04 | 若用户提供参数,立即启动《产品技术要求》起草 | 用户提供参数 | 2周 | + +### 11.2 需要用户决策的事项 + +| 决策项 | 选项 | 影响 | +|---|---|---| +| D-01 | 是否优先推进项目(若暂不推进,工程师暂停技术工作) | 资源配置 | +| D-02 | 技术路线选择(恒流源/恒压源) | 硬件设计 | +| D-03 | 是否接受行业通用参数作为设计输入起点(后续可调整) | 进度 vs 灵活性 | +| D-04 | 是否外包硬件设计/EMC整改 | 成本 vs 控制 | + +### 11.3 本阶段交付结论 + +**工程师结论**:当前阶段技术评估显示,无创神经电刺激器原理可行,但设计输入完全缺失。**下一步需要用户补充第1-5项阻塞信息(预期用途、技术参数、使用场景、目标用户、里程碑),工程师才能生成完整的设计规格书。** + +**无需决策事项**:无。 + +--- + +## 附录 A:术语定义 + +| 术语 | 定义 | 来源 | +|---|---|---| +| 无创神经电刺激 | 通过皮肤表面电极施加电流刺激神经或肌肉,不涉及穿刺或植入 | 行业通用 | +| TENS | 经皮神经电刺激(Transcutaneous Electrical Nerve Stimulation),一种无创镇痛技术 | 行业通用 | +| NMES | 神经肌肉电刺激(Neuromuscular Electrical Stimulation),刺激运动神经引发肌肉收缩 | 行业通用 | +| GB 9706.1-2020 | 医用电气设备安全通用要求国家标准 | 现行标准 | +| YY 0505-2012 | 医用电气设备电磁兼容要求行业标准 | 现行标准 | +| 型检 | 型式检验,由NMPA认可检测机构执行的强制性安全/性能检测 | NMPA法规 | + +## 附录 B:参考文件清单 + +| 文件名 | 内容 | 权威度 | +|---|---|---| +| proposal_brief.md | 立项输入:产品/用途/市场/软件说明 | primary_evidence | +| project.md | 项目全局基线 | authoritative | +| user.md | 用户偏好与决策约束 | authoritative | +| 01_organize/output.md | 前序资料整理报告 | prior_output | +| GB 9706.1-2020 | 医用电气设备 第1部分:基本安全和基本性能的通用要求 | reference_standard | +| YY 0505-2012 | 医用电气设备 第1-2部分:安全通用要求 并列标准:电磁兼容 要求和试验 | reference_standard | + +--- + +**生成时间**:2026-09-19 +**执行角色**:engineer +**阶段**:01 策划 +**交付物编号**:RD-PROPOSAL-ENG-01 diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rdev-stages.json b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rdev-stages.json new file mode 100644 index 00000000..14ef03cc --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rdev-stages.json @@ -0,0 +1,15 @@ +[ + "RD01_planning", + "RD02_planning_review", + "RD03_design_input", + "RD04_requirements_review", + "RD05_input_phase_review", + "RD06_design_output", + "RD07_output_review", + "RD08_verification", + "RD09_verification_review", + "RD10_transfer", + "RD11_transfer_review", + "RD12_validation", + "RD13_validation_report" +] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/run_p3_real_pi.py b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/run_p3_real_pi.py new file mode 100644 index 00000000..9a08b23b --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/run_p3_real_pi.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""P3: real PI for rd_proposal and rd_planning_review; rd_sw_plan stays planning-disabled.""" +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-p3-{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]}") + keys = [] + for stage in plan.get("stages") or []: + for dt in stage.get("doc_tasks") or []: + keys.append(dt.get("task_key") or dt.get("key")) + log(f"{workflow_key} task keys={keys}") + 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", "quality_failed", "finalizing_failed"): + 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: + raise SystemExit(f"{path} http={st} {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: + raise SystemExit(f"advance {sk} http={st} {raw[:400]}") + + +def main(): + admin_pass = os.environ["ADMIN_PASSWORD"] + token, admin = login(os.environ.get("ADMIN_USERNAME", "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": "P3 real PI three-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", ["bd_intake"], uid, reviewer_id, approver_id) + inst = act.get("instance") or act + main_iid = int(inst["id"]) + final = run_task(token, pid, "bd_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}_bd_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')}") + if inst.get("status") != "completed": + raise SystemExit(f"main workflow must complete before rdev, status={inst.get('status')}") + + keep = ["rd_proposal", "rd_planning_review"] + 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) + + def enabled_of(key: str): + for s in (rinst.get("config_snapshot") or {}).get("stages") or []: + for dt in s.get("doc_tasks") or []: + k = dt.get("task_key") or dt.get("key") + if k == key: + return bool(dt.get("enabled")) + return None + + proposal_on = enabled_of("rd_proposal") + review_on = enabled_of("rd_planning_review") + sw_on = enabled_of("rd_sw_plan") + log(f"plan flags proposal={proposal_on} review={review_on} sw_plan={sw_on}") + if sw_on is not False: + raise SystemExit(f"rd_sw_plan must stay disabled for planning N/A, got {sw_on}") + if proposal_on is not True or review_on is not True: + raise SystemExit(f"sample tasks must be enabled: proposal={proposal_on} review={review_on}") + + materials_prop = [{"filename": "proposal_brief.md", "content": b64( + "# 立项输入\n\n产品:无创神经电刺激器。\n用途:辅助改善目标症状,不做疗效宣称。\n市场:中国 NMPA 二类有源。\n软件:本项目不含独立软件子系统,软件开发计划在规划期关闭。\n" + )}] + materials_rev = [{"filename": "plan_brief.md", "content": b64( + "# 策划评审输入\n\n项目建议书已起草。\n关键风险:适应症边界待确认;电气安全摸底待排期。\n软件类文件已在规划期关闭,不进入编制。\n" + )}] + + fin1 = run_task(token, pid, "rd_proposal", fields, uid, reviewer_id, approver_id, backend="pi-agent", materials=materials_prop) + md1 = extract_md(fin1) + (ASSETS / "rd_proposal-final.md").write_text(md1, encoding="utf-8") + log(f"proposal chars={len(md1)} placeholder={'按本阶段专业结构写' in md1}") + approve_doc(token, reviewer_token, approver_token, f"doc_proj{pid}_rd_proposal", uid, reviewer_id, approver_id) + + advance_until(token, rid, "RD02_planning_review", rstages) + fin2 = run_task(token, pid, "rd_planning_review", fields, uid, reviewer_id, approver_id, backend="pi-agent", materials=materials_rev) + md2 = extract_md(fin2) + (ASSETS / "rd_planning_review-final.md").write_text(md2, encoding="utf-8") + log(f"planning_review chars={len(md2)} placeholder={'按本阶段专业结构写' in md2}") + + summary = { + "project_id": pid, + "project_name": PROJ_NAME, + "rd_sw_plan_na": {"enabled": sw_on, "close": "planning_disable"}, + "rd_proposal": { + "run_status": fin1.get("status") or fin1.get("Status"), + "chars": len(md1), + "no_placeholder": "按本阶段专业结构写" not in md1, + "has_document_sections": all(h in md1 for h in ("## 项目背景与立项依据", "## 产品定义与范围", "## 主要风险与对策概览")), + }, + "rd_planning_review": { + "run_status": fin2.get("status") or fin2.get("Status"), + "chars": len(md2), + "no_placeholder": "按本阶段专业结构写" not in md2, + "has_review_sections": all(h in md2 for h in ("## 评审范围与对象", "## 问题与决议", "## 评审结论")), + }, + } + save_json("summary-p3.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_sw_plan_na"]["enabled"] is False + and summary["rd_proposal"]["no_placeholder"] + and summary["rd_planning_review"]["no_placeholder"] + and summary["rd_proposal"]["has_document_sections"] + and summary["rd_planning_review"]["has_review_sections"] + and summary["rd_proposal"]["chars"] > 400 + and summary["rd_planning_review"]["chars"] > 400 + and summary["rd_proposal"]["run_status"] in ("completed", "pending_author_confirmation") + and summary["rd_planning_review"]["run_status"] in ("completed", "pending_author_confirmation") + ) + sys.exit(0 if ok else 2) + + +def resume_planning_review(): + """Continue project 6533: approve rd_proposal, then real-PI rd_planning_review.""" + pid = int(os.environ.get("P3_PROJECT_ID", "6533")) + rid = int(os.environ.get("P3_INSTANCE_ID", "361")) + admin_pass = os.environ["ADMIN_PASSWORD"] + token, admin = login(os.environ.get("ADMIN_USERNAME", "admin"), admin_pass) + uid = int(admin["id"]) + _, 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", + } + approve_doc(token, reviewer_token, approver_token, f"doc_proj{pid}_rd_proposal", uid, reviewer_id, approver_id) + rstages = json.loads((ASSETS / "rdev-stages.json").read_text(encoding="utf-8")) + advance_until(token, rid, "RD02_planning_review", rstages) + materials_rev = [{"filename": "plan_brief.md", "content": b64( + "# 策划评审输入\n\n项目建议书已起草。\n关键风险:适应症边界待确认;电气安全摸底待排期。\n软件类文件已在规划期关闭,不进入编制。\n" + )}] + fin2 = run_task(token, pid, "rd_planning_review", fields, uid, reviewer_id, approver_id, backend="pi-agent", materials=materials_rev) + md2 = extract_md(fin2) + (ASSETS / "rd_planning_review-final.md").write_text(md2, encoding="utf-8") + log(f"planning_review chars={len(md2)} placeholder={'按本阶段专业结构写' in md2}") + md1 = (ASSETS / "rd_proposal-final.md").read_text(encoding="utf-8") + summary = { + "project_id": pid, + "resumed": True, + "rd_sw_plan_na": {"enabled": False, "close": "planning_disable"}, + "rd_proposal": { + "run_status": "completed", + "chars": len(md1), + "no_placeholder": "按本阶段专业结构写" not in md1, + "template_headings": all(h in md1 for h in ("## 项目背景与立项依据", "## 产品定义与范围", "## 主要风险与对策概览")), + }, + "rd_planning_review": { + "run_status": fin2.get("status") or fin2.get("Status"), + "chars": len(md2), + "no_placeholder": "按本阶段专业结构写" not in md2, + "has_review_sections": all(h in md2 for h in ("## 评审范围与对象", "## 问题与决议", "## 评审结论")), + }, + } + save_json("summary-p3.json", summary) + (ASSETS / "console.log").write_text("\n".join(LOG) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + if os.environ.get("P3_RESUME") == "review": + resume_planning_review() + else: + main() diff --git a/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/summary-p3.json b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/summary-p3.json new file mode 100644 index 00000000..594f4a25 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/summary-p3.json @@ -0,0 +1,27 @@ +{ + "project_id": 6533, + "backend": "pi-agent", + "model": "MiniMax-M2.7-highspeed", + "rd_sw_plan_na": { + "enabled": false, + "close": "planning_disable" + }, + "rd_proposal": { + "run_status": "completed", + "elapsed_s": 430, + "chars": 11550, + "no_placeholder": true, + "template_headings": false + }, + "rd_planning_review": { + "run_status": "quality_failed", + "elapsed_s": 580, + "chars": 13225, + "no_placeholder": true, + "has_review_sections": true, + "quality_score": 20, + "violations": [ + "问题行缺少问题、决议、责任人或期限" + ] + } +} diff --git a/.doc/test-reports/assets/2026-09-19-remove-control-center/verify.txt b/.doc/test-reports/assets/2026-09-19-remove-control-center/verify.txt new file mode 100644 index 00000000..0a439d82 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-19-remove-control-center/verify.txt @@ -0,0 +1,312 @@ +=== vet === +vet=0 +=== build === +build=0 +=== short === +? chat-agent-system/cmd/artifact-vault [no test files] +ok chat-agent-system/cmd/bizdev-cli 2.260s +? 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/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.580s +ok chat-agent-system/internal/app 0.032s +ok chat-agent-system/internal/app/chatflow 0.005s +ok chat-agent-system/internal/app/docadvance 0.006s +ok chat-agent-system/internal/app/docflow 0.006s +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.003s +ok chat-agent-system/internal/app/intentflow 0.020s +ok chat-agent-system/internal/app/stageflow 0.007s +ok chat-agent-system/internal/app/uow 0.003s +ok chat-agent-system/internal/archtest 0.194s +ok chat-agent-system/internal/artifactvault 0.005s +ok chat-agent-system/internal/auth 0.989s +ok chat-agent-system/internal/authz 0.003s +ok chat-agent-system/internal/bizdev 0.166s +ok chat-agent-system/internal/chat 0.014s +ok chat-agent-system/internal/config 0.004s +ok chat-agent-system/internal/coordinator 0.011s +ok chat-agent-system/internal/ctxmgr 0.088s +ok chat-agent-system/internal/dataset 0.004s +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.025s +ok chat-agent-system/internal/docquery 0.004s +ok chat-agent-system/internal/docrevision 0.008s +ok chat-agent-system/internal/events 0.005s +ok chat-agent-system/internal/freedoc 0.025s +ok chat-agent-system/internal/fsutil 0.002s +ok chat-agent-system/internal/httpx 0.004s +ok chat-agent-system/internal/intent 0.022s +ok chat-agent-system/internal/kb 0.009s +ok chat-agent-system/internal/maturity 0.002s +ok chat-agent-system/internal/notification 0.085s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext 0.004s +ok chat-agent-system/internal/projectinstance 0.004s +ok chat-agent-system/internal/projectworkflow 0.003s +ok chat-agent-system/internal/quote 0.008s +ok chat-agent-system/internal/replyexport 0.003s +ok chat-agent-system/internal/server 14.027s +ok chat-agent-system/internal/squad 0.033s +ok chat-agent-system/internal/storage 0.015s +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.192s +ok chat-agent-system/internal/template 0.817s +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] +short=0 +=== archtest === +ok chat-agent-system/internal/archtest 0.154s +arch=0 +=== web === + +> 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.55s +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 +web=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/overlays/_shared/acceptance-rubric.md b/configs/overlays/_shared/acceptance-rubric.md new file mode 100644 index 00000000..26232de4 --- /dev/null +++ b/configs/overlays/_shared/acceptance-rubric.md @@ -0,0 +1,22 @@ +## 终稿验收清单(负责人必做) + +1. 模板每个 `##` 章节是否写完,无残留括号提示或未替换占位符。 +2. 关键数字、路径、参数前后一致,且可追溯至材料、前序文档或〔引用〕。 +3. 文中所有「待确认」是否在 `## 待确认清单` 汇总。 +4. 是否有未标注的推测、疗效宣称或绝对化用语。 +5. 专员标注的出处是否保留;无依据的分析是否被误删。 +6. 逻辑链完整:事实 → 分析 → 对本项目的含义 → 建议或待拍板;结论先行且后文不打架。 +7. 显式覆盖:适用与不适用、机会与风险、已知与待确认、现在要用户做什么(无决策则写「无需决策」)。 +8. 推荐须同时写否决或失效条件。 +9. 终稿前对照 template.md 每个 `##` 标题;delegate 摘要不能代替专员 output.md 原文合并。 +10. **粒度与编号**:主表编号连续无跳号;禁止合并多条款为一行;UR→RS / RS→TSYS 按**唯一编号**一一对应或充分展开(适用阶段)。 +11. **可执行性**:需求可验证、验证方法可操作(仪器/步骤/判据,非仅标准号);资源/接受判据非空或标待确认。 +12. **优于典型人工稿**:不得出现跳号、空描述、「符合某某标准」合并行;关键字段(如预期用途)须写入正文而非只在字段表。 + +### role:leader + +以上 12 条逐项自检;未通过则退回 writer 说明具体章节与问题码,不要只写「再改改」。 + +### role:writer + +交 leader 前自行过一遍 1–6、10–12 条;Ralph 自动校验未通过项须在重试时逐条修正。 diff --git a/configs/overlays/_shared/anti-truncation.md b/configs/overlays/_shared/anti-truncation.md new file mode 100644 index 00000000..dc9034ae --- /dev/null +++ b/configs/overlays/_shared/anti-truncation.md @@ -0,0 +1,23 @@ +## 反截断与完整性 + +交付须为**完整 Markdown**,禁止半成品章节、表格写到一半停止、或末尾出现「待续」「…(略)」。 + +- 篇幅压力大时:**先完成当前 `##` 章节的四段式**(结论→表→解读→出处),再写下一章。 +- **禁止省略套话**:不得出现「不做展开」「不展开」「从略」「不赘述」「略过细节」「篇幅有限」「仅作概要」等;缺信息就写「待确认」及缺什么,而不是跳过展开。 +- 禁止在表格中途截断;续跑时从 snapshot 中首个不完整章节补全,不要重写已完成章节。 +- 法规检索结果在 `knowledge/` 已落盘 — **先读 knowledge/ 与 prior_docs/ 原文**,勿依赖对话内被截断的工具摘要。 +- delegate 摘要仅为索引;终稿合并前 leader **须回读** `agents//output.md` 或 `delegate/summary.md` 全文。 + +### role:writer + +对照 `template.md` 每个正文 `##` 标题写满;附录字段节放文末;交稿前自检无省略套话、末行非截断占位。 + +### role:leader + +终稿前逐条对照 `template.md` 的 `##` 标题;delegate 步产出被压缩时回读专员原文再合并。 + +### role:engineer + +### role:regulatory + +结论写入 output.md 须自包含;关键表格与编号清单完整,不得仅写摘要句。 diff --git a/configs/overlays/_shared/doc-structure.md b/configs/overlays/_shared/doc-structure.md new file mode 100644 index 00000000..be8e50e2 --- /dev/null +++ b/configs/overlays/_shared/doc-structure.md @@ -0,0 +1,33 @@ +## 章节四段式(所有撰写与整合岗必读) + +模板 `##` 章节是骨架,不是篇幅上限。每一节按以下顺序写满: + +1. **结论句** — 本节对本项目的一句话含义。 +2. **表格或列表** — 结构化事实;图表须标题;表格至少填已知行,确实无数据时写一行说明「为何为空」及待确认项。 +3. **解读段落** — 解释表格/列表对本项目的含义,不要通篇只有列表。 +4. **支撑明细或出处** — 数字写清口径、假设、来源与日期;能标出处的标〔…〕;无直接条文依据的分析保留并标「(分析判断,无直接条文依据)」。 + +禁止把模板括号提示原样留在终稿。 + +### 正文与附录顺序 + +1. 文首元数据(项目/时间/快照)与 `## 结论` +2. **正文**:只写文档主题章节(市场、场景、方案、风险等),内容充实详尽,**禁止**「不做展开」「从略」「不赘述」「篇幅有限」等省略套话 +3. `## 待确认清单` +4. **文末附录**(可有可无):`阻塞信息` / `非阻塞信息` / `输入摘要` / `本阶段材料` / `说明` — 字段快照与材料清单放这里,不得挤占正文前部,也不得用字段表代替正文叙述 + +### role:writer + +合并专员产出时,每个模板正文章节都要四段式写满;冲突事实并列列出;文末汇总全部「待确认」;字段表与材料清单仅作附录。 + +### role:leader + +终稿审核:逐章检查四段式是否齐全;结论与后文是否打架;表格是否空壳;待确认是否已汇总。 + +### role:organizer + +交给撰写岗的黑board/产出须含:事实清单、依据来源、待确认项、对本项目含义 — 让 writer 无需回看对话即可写稿。 + +### role:engineer + +技术/工程输入须含:约束与假设、方案要点、风险与失效条件、对模板各章的建议映射。 diff --git a/configs/overlays/_shared/granularity.md b/configs/overlays/_shared/granularity.md new file mode 100644 index 00000000..2cfb45a7 --- /dev/null +++ b/configs/overlays/_shared/granularity.md @@ -0,0 +1,25 @@ +## 产出粒度(宁可多行勿合并) + +标准条款、用户需求、验证项须**逐条展开**为独立表格行或编号条目;禁止用「等」「详见附录」「同上」合并多条款为一行。 + +- 产品/项目复杂度越高,主表行数应越多;不得为省篇幅合并 applies 条款。 +- 系统需求规格书:UR→RS 参照 PM 工作流,有源类 GB9706.1 条款应拆成多行 RS;按**唯一 ID**计 RS≥UR(目标 ≥UR×1.5)。 +- 系统验证方案:唯一 RS 数 = 唯一 TSYS 数;验证方法须可操作(仪器/步骤/判据),非仅标准号。 +- 若单章内容过多,**优先写完当前章节的四段式**,再开下一章;禁止在表格中途截断。 +- **质量靶点**:消除人工稿常见跳号、合并行、弱追溯、方法只写标准号。 + +### role:engineer + +每条 UR/标准条款/风险点拆成原子条目写入 output.md;合并多条款为一行视为不合格交付。 + +### role:regulatory + +宽召回后 applies/conditional 项逐条编号;not_applies 写入排除日志,不得静默删除。 + +### role:writer + +主表列完整填写;行数应反映 blackboard 与专员产出的条目数;禁止提纲式占位行。 + +### role:leader + +审查已拆条款是否被 writer 合并;GB9706.1 类有源产品若仅 1–2 行电气安全 RS/TSYS 则退回 engineer。 diff --git a/configs/overlays/_shared/rs-tsys-expansion.md b/configs/overlays/_shared/rs-tsys-expansion.md new file mode 100644 index 00000000..77238a78 --- /dev/null +++ b/configs/overlays/_shared/rs-tsys-expansion.md @@ -0,0 +1,24 @@ +## RS→TSYS 验证展开(共享) + +每个 **RS/RF/RP…** 测试项目须对应 **TSYS-nnnn** 用例;4.2 主表列:**测试项目 | 测试用例 | 测试内容 | 验证方法**。 + +- 验证方法须可操作(仪器、步骤、接受判据),非仅标准号。 +- RS 与 TSYS 一一对应、编号连续;仅收 applies/conditional 项。 +- 4.1 用例总数须与 4.2 行数一致。 +- **优于典型人工稿**:覆盖 prior_docs 中全部 RS(或写排除理由);方法写清「用什么测、怎么测、判什么」;资源表(样机/仪器/环境/人员)非空或标待确认;禁止把多条款并成一行「电气安全」。 + +### role:regulatory + +宽召回后将 applies/conditional 标准条款拆成原子 RS 系列;排除日志条条有依据。 + +### role:engineer + +为每个 RS 编写 TSYS 用例与验证方法、资源要求(样机/仪器表);方法不得只抄标准号。 + +### role:writer + +4.2 主表 RS/TSYS 一一对应;附录法规对照与排除日志闭环;4.1 的 N 与 4.2 行数一致。 + +### role:leader + +RS/TSYS 数量一致(硬条件:主表测试项目数 = TSYS 数 = 4.1 声明的 N);GB9706.1 类禁止合并为一行;验证方法非空且可操作;相对 prior SRS 有漏项则退回。数量不一致必须退回 writer 补齐,禁止带警告定稿。 diff --git a/configs/overlays/_shared/self-contained-handoff.md b/configs/overlays/_shared/self-contained-handoff.md new file mode 100644 index 00000000..6e0b87a3 --- /dev/null +++ b/configs/overlays/_shared/self-contained-handoff.md @@ -0,0 +1,27 @@ +## 自包含交接(Goose 式) + +执行下一步的专员**看不到**你的对话历史。交给下游的内容必须自包含: + +- **背景与约束** — 重述本项目、阶段目标、不可改动项、前序已批准结论(一句概括 + 引用 prior_docs 文件名)。 +- **编号要点** — 事实/分析按 1、2、3 编号;步骤间依赖写清(「第 2 点依赖第 1 点」)。 +- **条件分支** — 若 X 则 Y,否则 Z;不要假设下游已知隐含前提。 +- **待确认** — 阻塞与非阻塞分开;owner 标 user 或 agent。 +- **对本项目含义** — 每条要点写清「所以呢」。 + +写入 blackboard 时使用结构化 JSON(facts / gaps / implications),见 snapshot 内 `blackboard_schema.json`。 + +**高复杂度**:handoff 至少 2 条 facts、1 条 gaps(无缺口则写「无阻塞缺口」)、1 条 implications;否则阶段失败并要求重做 organizer。 + +### role:organizer + +fields.json 与 blackboard 同步;关键字段须在事实清单中有对应条目。 + +### role:engineer + +### role:market + +### role:regulatory + +### role:searcher + +检索纪要每条标〔来源〕或「未检索到」及已尝试路径;不写 executor 无法核验的 URL/专利号/标准号。 diff --git a/configs/overlays/_shared/ur-rs-expansion.md b/configs/overlays/_shared/ur-rs-expansion.md new file mode 100644 index 00000000..cd1b2d41 --- /dev/null +++ b/configs/overlays/_shared/ur-rs-expansion.md @@ -0,0 +1,20 @@ +## UR→RS 需求展开(共享) + +将用户需求 **UR-nnnn** 及适用标准条款拆成原子 **RS/RF/RP/RU…** 系统需求;每条 RS 至少关联一个 UR。 + +- GB9706.1 等有源水平标准:逐条展开,禁止合并为一行「符合 GB9706.1」。 +- RS 前缀与需求标识分类一致(S=RS 安全、F=RF 功能、P=RP 性能、U=RU 易用性等)。 +- 主表列:**用户需求ID | 系统需求ID | 系统需求描述 | 备注**(或模板等价列)。 +- **优于典型人工稿**:每条 UR 至少拆出可验证 RS;编号连续无跳号;主表描述可测、可追溯,禁止「符合某某标准」式合并行;电气/软件/性能分列展开。 + +### role:engineer + +编制基本法规项;从 UR 与标准条款展开 RS 池写入 output.md,供 writer 入主表。目标:**RS 行数 ≥ UR 数 × 1.5**(有源/含软件时优先更高)。 + +### role:writer + +严格按模板写入需求描述主表;UR/RS 编号连续;冲突 UR 并列保留,不擅自取舍。 + +### role:leader + +RS 数量应 ≥ UR 数量(同一 UR 可对应多 RS);合并条款、跳号、空描述则退回。 diff --git a/configs/overlays/adverse_events.md b/configs/overlays/adverse_events.md new file mode 100644 index 00000000..7f2ff037 --- /dev/null +++ b/configs/overlays/adverse_events.md @@ -0,0 +1,25 @@ +## 本阶段:同类产品不良事件信息 + +完整交付:检索范围与方法、命中汇总、与本品相关性分析、对风险控制的含义、结论与待确认。无公开命中时写清已检索库/关键词,仍给出「暂无公开同类严重事件」类结论及局限。 + +禁止编造事件编号或伤害结局。 + +### role:searcher + +检索公开不良事件/召回信息;保留出处。 + +### role:regulatory + +相关性与法规含义分析。 + +### role:engineer + +技术相关性与设计启示。 + +### role:writer + +按模板成稿;有分析结论。 + +### role:leader + +查有无虚构事件;结论是否过满。 diff --git a/configs/overlays/complexity_high.md b/configs/overlays/complexity_high.md new file mode 100644 index 00000000..813e9d62 --- /dev/null +++ b/configs/overlays/complexity_high.md @@ -0,0 +1,24 @@ +## 高复杂度项目策略 + +本项目推断为**高复杂度**(有源/III 类/含软件/多标准/材料详尽等)。须: + +- **宁可多行勿合并**:主表目标行数区间见 `context/complexity.md`;GB9706.1 类条款逐条展开。 +- 若本阶段 playbook 含 plan 步:writer(plan) 须列出各 `##` 章节要点与主表**预估行数区间**;execute 步严格按计划,不得合并条款。 +- regulatory/searcher delegate 摘要上限 8000 字;blackboard handoff JSON 必填 facts/gaps/implications。 +- Ralph 自动重试最多 3 次;模板章节缺失将阻塞定稿。 + +### role:organizer + +blackboard 列明复杂度信号、已知标准清单、prior_docs 需求/验证文档索引。 + +### role:engineer + +### role:regulatory + +### role:writer + +高复杂度:主表行数不得少于 plan 中预估下限;缺项写入待确认清单而非省略行。 + +### role:leader + +对照 complexity 策略与 template 全章节;RS/UR 或 RS/TSYS 稀疏则退回。 diff --git a/configs/overlays/complexity_medium.md b/configs/overlays/complexity_medium.md new file mode 100644 index 00000000..4bb7ffd5 --- /dev/null +++ b/configs/overlays/complexity_medium.md @@ -0,0 +1,11 @@ +## 中等复杂度项目策略 + +本项目为**中等复杂度**。主表须完整可追溯,标准条款勿合并;有源或含软件时倾向逐条展开。一次成稿即可,无需 plan_gate。 + +### role:writer + +每个模板章节四段式写满;主表至少覆盖 blackboard 中全部已确认条目。 + +### role:leader + +检查追溯表非空、表格非壳;与 PM/TM 标杆粒度相比无明显合并条款。 diff --git a/configs/overlays/design_vv.md b/configs/overlays/design_vv.md new file mode 100644 index 00000000..24d16dda --- /dev/null +++ b/configs/overlays/design_vv.md @@ -0,0 +1,23 @@ +## 本阶段:设计验证(VV) + +验证方案/记录须 **唯一 RS 数 = 唯一 TSYS 数**,验证方法可操作(仪器/步骤/判据)。参照测试经理 4.2 主表粒度。 + +### role:organizer + +对齐 SRS、风险管理与 prior_docs 验证输入。 + +### role:regulatory + +宽召回适用标准;applies 项拆成 RS 系列;排除日志闭环。 + +### role:engineer + +每个 RS 编写 TSYS 用例、步骤与接受判据;禁止只写标准号。 + +### role:writer + +主表与资源章节写满;排除项入附录;4.1 的 N 与主表行数一致。 + +### role:leader + +唯一 RS/TSYS 数量一致;方法非空可操作;相对 prior SRS 无静默漏项。 diff --git a/configs/overlays/hazard_analysis.md b/configs/overlays/hazard_analysis.md new file mode 100644 index 00000000..a858247f --- /dev/null +++ b/configs/overlays/hazard_analysis.md @@ -0,0 +1,19 @@ +## 本阶段:危险分析 + +完整交付:危险源识别尽可能全面、每条危险的原因/伤害/初始风险、控制措施方向、残余风险态度、分析充分性结论。缺临床或使用信息时列缺口,不编造伤害严重度数值(除非材料给出)。 + +### role:regulatory + +与法规/标准危险提示对齐。 + +### role:engineer + +技术危险与控制可行性。 + +### role:writer + +按模板成表并解读;结论与待确认完整。 + +### role:leader + +查明显危险遗漏;有无无据的定量风险评分编造。 diff --git a/configs/overlays/kind_design_record.md b/configs/overlays/kind_design_record.md new file mode 100644 index 00000000..da89a2df --- /dev/null +++ b/configs/overlays/kind_design_record.md @@ -0,0 +1,19 @@ +## 设计记录类(DHF 策划/输入/输出/方案) + +按 ISO 13485 设计控制写满:目的、范围、职责、输入输出、评审与变更。需求类文档须 **UR→RS 追溯主表**,禁止合并标准条款为一行。 + +### role:organizer + +对齐前序批准文档与设计输入清单;blackboard 列追溯链:用户需求/风险/标准 → 本记录章节。 + +### role:engineer + +写技术内容:需求/方案/验证指标/接口与约束;每项标追溯至前序 stage 或标准条款;标准条款逐条展开。 + +### role:writer + +按 template 四段式;设计输入/输出须表格化且至少一行已知项或说明为何为空;需求描述主表编号连续。 + +### role:leader + +查追溯完整、与风险/用户需求无未解释冲突;NA 声明须有依据;已拆 RS 不得被合并。 diff --git a/configs/overlays/kind_review_minutes.md b/configs/overlays/kind_review_minutes.md new file mode 100644 index 00000000..cc164d18 --- /dev/null +++ b/configs/overlays/kind_review_minutes.md @@ -0,0 +1,17 @@ +## 文档类型:评审记录 + +完整交付须含:评审目的与范围结论、出席/角色(材料有则写,无则待确认)、评审问题与证据、决议、行动项(责任/期限)、遗留风险与闭环逻辑。可短,但因果与待办必须闭环。 + +不编造签字、日期或未举行的会议结论。材料不足时写「待组织评审」并列出会前必补材料。 + +### role:organizer + +整理前序已批准材料与附件索引;列出待评审问题清单草案。 + +### role:writer + +按模板写决议与行动项;每条决议有依据指向;逻辑通顺。 + +### role:leader + +查有无空洞「同意通过」无问题记录;行动项是否可执行;有无伪造签字。 diff --git a/configs/overlays/kind_risk.md b/configs/overlays/kind_risk.md new file mode 100644 index 00000000..f8cc3a47 --- /dev/null +++ b/configs/overlays/kind_risk.md @@ -0,0 +1,21 @@ +## 风险管理类 + +危害识别 → 估计 → 评价 → 控制 → 残余风险 → 受益-风险。每条危害可追溯至设计输入或临床/使用场景。 + +### role:organizer + +汇总前序风险计划、危害分析、验证结果中的开放项。 + +### role:engineer + +### role:regulatory + +法规对风险管理、临床评价、PMCF 的要求线索;不写终裁分类。 + +### role:writer + +危害表格至少一行或说明为何为空;控制措施与验证方法对应;残余风险与受益分析完整。 + +### role:leader + +查危害-控制-验证三角闭环;控制措施须对应验证方法或 RS/TSYS;与 hazard_analysis / risk_plan 一致。 diff --git a/configs/overlays/kind_software.md b/configs/overlays/kind_software.md new file mode 100644 index 00000000..25a01ed4 --- /dev/null +++ b/configs/overlays/kind_software.md @@ -0,0 +1,15 @@ +## 软件生存周期类 + +IEC 62304 对齐:软件安全等级、SOUP、配置管理、问题跟踪、发布与维护。无软件成分时一页 NA 说明。 + +### role:engineer + +架构/单元/集成/系统测试与风险控制的对应;版本与配置项清单。 + +### role:writer + +需求-架构-测试追溯矩阵至少 skeleton 填行;发布清单与已知 anomaly 分开。 + +### role:leader + +查软件风险与系统风险管理一致;测试覆盖关键安全需求。 diff --git a/configs/overlays/kind_upload_index.md b/configs/overlays/kind_upload_index.md new file mode 100644 index 00000000..53829319 --- /dev/null +++ b/configs/overlays/kind_upload_index.md @@ -0,0 +1,21 @@ +## 文档类型:附件清单 / 上传索引 + +完整交付须含:应具备附件清单、已收到/缺失状态、缺失对后续阶段的影响分析、补齐优先级结论。索引全面,禁止伪造图纸、Gerber、照片或试验原始数据内容。 + +无附件时仍须成文:说明缺口与卡点,给出「待上传后更新」的明确结论。 + +### role:organizer + +核对 materials 与前序引用;建索引表;标缺失。 + +### role:engineer + +从技术角度说明缺失附件阻塞哪些设计/验证活动。 + +### role:writer + +按模板输出清单与影响分析;结论明确。 + +### role:leader + +查是否虚构附件内容;缺口影响是否说清。 diff --git a/configs/overlays/kind_verification.md b/configs/overlays/kind_verification.md new file mode 100644 index 00000000..2436b1c0 --- /dev/null +++ b/configs/overlays/kind_verification.md @@ -0,0 +1,19 @@ +## 验证/确认类 + +验证:方案 → 记录 → 报告,指标可测、样本/设备/环境写清。**唯一 RS 数 = 唯一 TSYS 数**,验证方法须可操作(仪器/步骤/判据),禁止只写标准号。 + +### role:organizer + +列验证/确认项与接受准则来源(标准、内部规范、用户需求);优先对齐 prior_docs 已批准 SRS。 + +### role:engineer + +方法、设备、样本量、偏差处理;为每个 RS 编写 TSYS 与验证步骤。 + +### role:writer + +每个验证项:准则、结果、结论、追溯;4.2 类主表行数反映复杂度;4.1 声明的 N 与主表行数一致。 + +### role:leader + +接受准则可验证;唯一 RS/TSYS 数量一致;与 design_vv / validation_plan / test_manager 无矛盾;相对 prior SRS 有漏项则退回。 diff --git a/configs/overlays/rdev.md b/configs/overlays/rdev.md new file mode 100644 index 00000000..3bb32000 --- /dev/null +++ b/configs/overlays/rdev.md @@ -0,0 +1,37 @@ +## 本工作流:研发 / 设计控制 + +按 ISO 13485 设计控制编制 DHF 记录。用一句话回述本阶段交付物及其 DHF 位置。与前序已批准文件保持一致,引用保留出处。 + +### 交付质量(全员) + +DHF 文件必须**全面、详细、有分析与结论、逻辑通顺**。模板章节写满;结论先行;表后有解读;禁止提纲、空表、括号提示残留。专员交给撰写岗的内容须带完整论据。「不适用」必须写成完整一页理由,走审核,不能空白跳过。 + +- 缺信息标「待确认」,不要编造试验数据、图纸、Gerber、BOM 数量或注册结论。 +- 需求、设计、验证、确认之间尽量保留 ID 追溯(UR/RS/TSYS 或本文件 ID ↔ 上游 ↔ 下游)。 +- 高复杂度项目(见 context/complexity.md):宁可多行勿合并;regulatory 步产出须落盘供 leader 回读。 +- 需要用户决策的事项:分类与临床路径、宣称口径、安全风险不可接受、范围与周期冲突、合规与法规判定。 +- 不做 CDO 报价核算,不对外做商务承诺。 + +### role:organizer + +抽取材料事实、前序批准要点与字段;blackboard 必填 facts/gaps/implications JSON;列冲突与缺口。 + +### role:searcher + +公开检索须标出处;未命中写路径。服务于本阶段主题,不扩写无关商务内容。 + +### role:regulatory + +法规与风险要点须有依据;不确定标待确认。不写最终获批承诺。 + +### role:engineer + +技术内容与可追溯性;参数有据或待确认。给出本阶段技术结论与风险。 + +### role:writer + +按模板成稿;写满各章;结论与追溯一致;文末汇总待确认。 + +### role:leader + +二审必查:是否写满、有无分析结论、逻辑是否通顺、不适用理由是否完整、有无编造或商务报价串入。不达标退回。 diff --git a/configs/overlays/rdev_ph01.md b/configs/overlays/rdev_ph01.md new file mode 100644 index 00000000..bdd71d23 --- /dev/null +++ b/configs/overlays/rdev_ph01.md @@ -0,0 +1,11 @@ +## 本阶段簇:策划(phase 01) + +应回答:项目为何立项、范围边界、主要交付与里程碑、关键假设与风险、与商务对接结论的一致性。完整稿有明确立项/策划结论;缺项待确认并说明影响。 + +### role:writer + +写满策划类章节;结论与范围表一致。 + +### role:leader + +查范围是否越权到未批准内容;结论是否可追溯到前序 bizdev 批准件。 diff --git a/configs/overlays/rdev_ph02.md b/configs/overlays/rdev_ph02.md new file mode 100644 index 00000000..6ad63aa6 --- /dev/null +++ b/configs/overlays/rdev_ph02.md @@ -0,0 +1,3 @@ +## 本阶段簇:策划评审(phase 02) + +应回答:策划文件是否充分、主要问题与决议、行动项闭环、是否具备进入设计输入的条件。评审结论明确(通过/有条件通过/不通过)。 diff --git a/configs/overlays/rdev_ph03.md b/configs/overlays/rdev_ph03.md new file mode 100644 index 00000000..d6b5ebd2 --- /dev/null +++ b/configs/overlays/rdev_ph03.md @@ -0,0 +1,5 @@ +## 本阶段簇:设计输入(phase 03) + +应回答:用户/系统/法规/风险等输入是否完整可追溯、相互冲突如何处理、软件相关输入是否适用、输入充分性结论。每份输入文件须有明确「输入是否可进入设计」的判断或待确认清单。 + +法规清单与不良事件类:条目完整 + 纳入/排除依据。风险计划与危险分析:风险过程结论与残余风险态度说清。 diff --git a/configs/overlays/rdev_ph04.md b/configs/overlays/rdev_ph04.md new file mode 100644 index 00000000..c2eab59b --- /dev/null +++ b/configs/overlays/rdev_ph04.md @@ -0,0 +1,3 @@ +## 本阶段簇:需求评审(phase 04) + +应回答:需求是否可验证、追溯是否闭合、主要争议与决议、是否批准进入后续输入/设计。结论明确,行动项可执行。 diff --git a/configs/overlays/rdev_ph05.md b/configs/overlays/rdev_ph05.md new file mode 100644 index 00000000..266f38f6 --- /dev/null +++ b/configs/overlays/rdev_ph05.md @@ -0,0 +1,3 @@ +## 本阶段簇:输入阶段评审(phase 05) + +应回答:设计输入包是否完整、遗留问题、是否批准启动设计输出。有条件通过须写清条件与责任人。 diff --git a/configs/overlays/rdev_ph06.md b/configs/overlays/rdev_ph06.md new file mode 100644 index 00000000..e6b9cd36 --- /dev/null +++ b/configs/overlays/rdev_ph06.md @@ -0,0 +1,5 @@ +## 本阶段簇:设计输出(phase 06) + +应回答:输出是否满足输入、关键设计决策与取舍、追溯是否建立、检验/工艺/包装等输出是否覆盖、软件输出是否适用。每份输出文件须有「与输入符合性」结论或明确缺口。 + +附件清单类只建索引与缺口影响,不伪造工程文件内容。 diff --git a/configs/overlays/rdev_ph07.md b/configs/overlays/rdev_ph07.md new file mode 100644 index 00000000..477406c6 --- /dev/null +++ b/configs/overlays/rdev_ph07.md @@ -0,0 +1,3 @@ +## 本阶段簇:输出评审(phase 07) + +应回答:设计输出是否可进入验证、主要问题与决议、未关闭项对验证的影响。结论明确。 diff --git a/configs/overlays/rdev_ph08.md b/configs/overlays/rdev_ph08.md new file mode 100644 index 00000000..27a13869 --- /dev/null +++ b/configs/overlays/rdev_ph08.md @@ -0,0 +1,5 @@ +## 本阶段簇:验证(phase 08) + +应回答:验证目的与范围、方法与接受准则、样品与环境、结果或计划状态、与设计输入的追溯、失败/偏差处理、验证结论(通过/不通过/未执行待补)。未执行试验不得编造数据;计划类文档须写清待执行项与风险。 + +软件测试类:无软件则完整不适用页。 diff --git a/configs/overlays/rdev_ph09.md b/configs/overlays/rdev_ph09.md new file mode 100644 index 00000000..847fc4c8 --- /dev/null +++ b/configs/overlays/rdev_ph09.md @@ -0,0 +1,3 @@ +## 本阶段簇:验证评审(phase 09) + +应回答:验证证据是否充分、遗留偏差、是否批准进入转换/确认。结论与行动项完整。 diff --git a/configs/overlays/rdev_ph10.md b/configs/overlays/rdev_ph10.md new file mode 100644 index 00000000..71453d2b --- /dev/null +++ b/configs/overlays/rdev_ph10.md @@ -0,0 +1,3 @@ +## 本阶段簇:转换(phase 10) + +应回答:生产/供应链/DMR 相关输出是否就绪、包装运输与供方是否满足、转换风险与缓解、是否具备转换条件。结论明确;缺生产数据标待确认并写影响。 diff --git a/configs/overlays/rdev_ph11.md b/configs/overlays/rdev_ph11.md new file mode 100644 index 00000000..22b8571c --- /dev/null +++ b/configs/overlays/rdev_ph11.md @@ -0,0 +1,3 @@ +## 本阶段簇:转换评审(phase 11) + +应回答:转换包是否充分、决议与行动项、是否批准进入确认活动。 diff --git a/configs/overlays/rdev_ph12.md b/configs/overlays/rdev_ph12.md new file mode 100644 index 00000000..5dda5c49 --- /dev/null +++ b/configs/overlays/rdev_ph12.md @@ -0,0 +1,3 @@ +## 本阶段簇:确认(phase 12) + +应回答:确认目的与方法、用户场景/临床/动物等路径是否适用、结果或方案状态、残余风险、确认结论。不适用路径须完整理由。不编造未开展的试验或临床结论。 diff --git a/configs/overlays/rdev_ph13.md b/configs/overlays/rdev_ph13.md new file mode 100644 index 00000000..bbcc3474 --- /dev/null +++ b/configs/overlays/rdev_ph13.md @@ -0,0 +1,3 @@ +## 本阶段簇:设计确认报告(phase 13) + +应回答:确认活动总览、与输入/输出/验证的符合性、遗留项与上市前关注点、总体确认结论。全面汇总,逻辑闭环,禁止只贴前序标题无综合分析。 diff --git a/configs/overlays/rdev_software.md b/configs/overlays/rdev_software.md new file mode 100644 index 00000000..c77ba161 --- /dev/null +++ b/configs/overlays/rdev_software.md @@ -0,0 +1,21 @@ +## 软件相关阶段 + +本阶段涉及软件生命周期文档。无软件成分时:必须按模板写清**不适用理由**(完整论述,非一句话),并走审核;不得空白跳过。 + +有软件时:完整交付须含范围、与系统需求/风险的追溯、本阶段结论与待确认。不编造版本号、测试通过率或未做试验的数据。 + +### role:organizer + +确认材料是否表明含软件;回填 applicability / na_rationale 相关字段线索。 + +### role:engineer + +有软件则写技术内容与追溯;无软件则起草不适用理由的技术依据。 + +### role:writer + +适用则按模板写满;不适用则输出完整不适用页。 + +### role:leader + +查不适用是否借口过短;适用稿是否缺追溯或结论。 diff --git a/configs/overlays/regs_stds.md b/configs/overlays/regs_stds.md new file mode 100644 index 00000000..1079f34c --- /dev/null +++ b/configs/overlays/regs_stds.md @@ -0,0 +1,23 @@ +## 本阶段:法规和标准清单 + +完整交付:适用法规/标准/指导原则清单完整;每条写适用性(适用/部分适用/不适用)与简短依据;总述覆盖是否充分及待确认。指导原则不可写成强制标准。禁止无依据删项。 + +### role:searcher + +宽召回公开目录与标准线索;出处可核验。 + +### role:regulatory + +逐项判定与排除理由;给出覆盖充分性结论。 + +### role:engineer + +从产品技术特征校验适用性判定是否遗漏能量源/软件/无菌等触发项。 + +### role:writer + +按模板输出清单与总述;逻辑通顺。 + +### role:leader + +查漏项、误标强制、空理由排除。 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/pi-agent/entrypoint.sh b/deploy/pi-agent/entrypoint.sh index 9f3e4638..49a2dded 100755 --- a/deploy/pi-agent/entrypoint.sh +++ b/deploy/pi-agent/entrypoint.sh @@ -72,6 +72,22 @@ cat < "${PI_DIR}/models.json" "input": ["text"], "contextWindow": 1000000, "maxTokens": 131072 + }, + { + "id": "MiniMax-M2.7-highspeed", + "name": "MiniMax-M2.7-highspeed", + "reasoning": true, + "input": ["text"], + "contextWindow": 1000000, + "maxTokens": 131072 + }, + { + "id": "MiniMax-M3", + "name": "MiniMax-M3", + "reasoning": true, + "input": ["text"], + "contextWindow": 1000000, + "maxTokens": 131072 } ] } diff --git a/deploy/postgres/migrations/2026-09-09-doc-edit-lease.sql b/deploy/postgres/archive/pre-goose/2026-09-09-doc-edit-lease.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-09-doc-edit-lease.sql rename to deploy/postgres/archive/pre-goose/2026-09-09-doc-edit-lease.sql diff --git a/deploy/postgres/migrations/2026-09-09-uq-context-memory-chunk-hash.sql b/deploy/postgres/archive/pre-goose/2026-09-09-uq-context-memory-chunk-hash.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-09-uq-context-memory-chunk-hash.sql rename to deploy/postgres/archive/pre-goose/2026-09-09-uq-context-memory-chunk-hash.sql diff --git a/deploy/postgres/migrations/2026-09-10-chat-daily-digests-and-search.sql b/deploy/postgres/archive/pre-goose/2026-09-10-chat-daily-digests-and-search.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-10-chat-daily-digests-and-search.sql rename to deploy/postgres/archive/pre-goose/2026-09-10-chat-daily-digests-and-search.sql diff --git a/deploy/postgres/migrations/2026-09-10-chat-messages-day-window.sql b/deploy/postgres/archive/pre-goose/2026-09-10-chat-messages-day-window.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-10-chat-messages-day-window.sql rename to deploy/postgres/archive/pre-goose/2026-09-10-chat-messages-day-window.sql diff --git a/deploy/postgres/migrations/2026-09-11-chat-read-positions.sql b/deploy/postgres/archive/pre-goose/2026-09-11-chat-read-positions.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-chat-read-positions.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-chat-read-positions.sql diff --git a/deploy/postgres/migrations/2026-09-11-chat-turns.sql b/deploy/postgres/archive/pre-goose/2026-09-11-chat-turns.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-chat-turns.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-chat-turns.sql diff --git a/deploy/postgres/migrations/2026-09-11-freeform-documents.sql b/deploy/postgres/archive/pre-goose/2026-09-11-freeform-documents.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-freeform-documents.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-freeform-documents.sql diff --git a/deploy/postgres/migrations/2026-09-11-message-export-policy.sql b/deploy/postgres/archive/pre-goose/2026-09-11-message-export-policy.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-message-export-policy.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-message-export-policy.sql diff --git a/deploy/postgres/migrations/2026-09-11-project-workflows-and-lineage.sql b/deploy/postgres/archive/pre-goose/2026-09-11-project-workflows-and-lineage.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-project-workflows-and-lineage.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-project-workflows-and-lineage.sql diff --git a/deploy/postgres/migrations/2026-09-11-seed-ten-projects-chat.sql b/deploy/postgres/archive/pre-goose/2026-09-11-seed-ten-projects-chat.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-seed-ten-projects-chat.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-seed-ten-projects-chat.sql diff --git a/deploy/postgres/migrations/2026-09-11-seed-ten-projects.sql b/deploy/postgres/archive/pre-goose/2026-09-11-seed-ten-projects.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-seed-ten-projects.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-seed-ten-projects.sql diff --git a/deploy/postgres/migrations/2026-09-11-task-run-lineage.sql b/deploy/postgres/archive/pre-goose/2026-09-11-task-run-lineage.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-11-task-run-lineage.sql rename to deploy/postgres/archive/pre-goose/2026-09-11-task-run-lineage.sql diff --git a/deploy/postgres/migrations/2026-09-16-retire-demo-ten-project-sessions.sql b/deploy/postgres/archive/pre-goose/2026-09-16-retire-demo-ten-project-sessions.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-16-retire-demo-ten-project-sessions.sql rename to deploy/postgres/archive/pre-goose/2026-09-16-retire-demo-ten-project-sessions.sql diff --git a/deploy/postgres/migrations/2026-09-16-retire-demo-ten-projects.sql b/deploy/postgres/archive/pre-goose/2026-09-16-retire-demo-ten-projects.sql similarity index 100% rename from deploy/postgres/migrations/2026-09-16-retire-demo-ten-projects.sql rename to deploy/postgres/archive/pre-goose/2026-09-16-retire-demo-ten-projects.sql diff --git a/deploy/postgres/archive/pre-goose/README.md b/deploy/postgres/archive/pre-goose/README.md new file mode 100644 index 00000000..ddcc29f9 --- /dev/null +++ b/deploy/postgres/archive/pre-goose/README.md @@ -0,0 +1,4 @@ +# Pre-goose SQL archive + +These files predate Goose and are not applied by env-service or any script. +Live schema is deploy/postgres/goose/. Kept for history only. diff --git a/deploy/postgres/goose/00049_2026-09-17-system-settings.sql b/deploy/postgres/goose/00049_2026-09-17-system-settings.sql new file mode 100644 index 00000000..8e078e3f --- /dev/null +++ b/deploy/postgres/goose/00049_2026-09-17-system-settings.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- 系统管理台(一期):白名单化系统设置存储。键空间由 internal/sysadmin SettingSpec 注册表约束。 + +CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_by BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS system_settings; 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/deploy/postgres/goose/00053_2026-09-19-document-content-projection.sql b/deploy/postgres/goose/00053_2026-09-19-document-content-projection.sql new file mode 100644 index 00000000..e4f45679 --- /dev/null +++ b/deploy/postgres/goose/00053_2026-09-19-document-content-projection.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- 人工修订账本与正文投影不是同一个存储。投影意图与账本同一事务写入, +-- 投影成功后才标 projected。进程在两次写之间退出时,未投影行仍可重放。 + +CREATE TABLE IF NOT EXISTS document_content_projections ( + doc_id TEXT NOT NULL, + snapshot_id BIGINT NOT NULL, + revision_no INT NOT NULL, + idempotency_key TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + projected_at TIMESTAMPTZ, + PRIMARY KEY (doc_id, snapshot_id), + CONSTRAINT chk_doc_content_projection_status CHECK (status IN ('pending', 'projected')) +); + +CREATE INDEX IF NOT EXISTS idx_doc_content_projection_pending + ON document_content_projections (created_at) + WHERE status = 'pending'; + +-- +goose Down +DROP TABLE IF EXISTS document_content_projections; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d823ab83..96bb07fa 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: . @@ -56,6 +41,7 @@ services: DATA_DIR: /app/data/workspaces MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + # dev iterates on highspeed; test/main pin MiniMax-M3. Do not align without a promotion decision. MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed} MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible} MINIMAX_PROVIDER: minimax @@ -101,7 +87,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,14 +110,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} + 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 + # W2 PU6: shadow semantic summary only. REPLACE stays unwired and is not declared here. SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} - SEMANTIC_SUMMARY_REPLACE: ${SEMANTIC_SUMMARY_REPLACE:-false} PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} # FAV U7: keep degraded read + in-process publisher off until separate approval FAV_DEGRADED_READ: ${FAV_DEGRADED_READ:-false} diff --git a/docker-compose.main.yml b/docker-compose.main.yml index 7a9dd74d..402d4f32 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: @@ -56,6 +41,7 @@ services: DATA_DIR: /app/data/workspaces MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + # Release pin. dev uses MiniMax-M2.7-highspeed on purpose. MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3} MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible} MINIMAX_PROVIDER: minimax @@ -86,8 +72,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 +88,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} @@ -108,7 +96,7 @@ services: KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required for main/production} - AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true} + AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-false} AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false} EMBEDDING_ENDPOINT_URL: ${EMBEDDING_ENDPOINT_URL:-} EMBEDDING_MODEL: ${EMBEDDING_MODEL:-bge-m3} @@ -124,7 +112,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 +156,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 +172,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..5fba70ff 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -14,7 +14,7 @@ services: command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_connections=150"] environment: POSTGRES_USER: ${SYSTEM_DB_USER:-app} - POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:-test_secret_db_pass} + POSTGRES_PASSWORD: ${SYSTEM_DB_PASSWORD:?required} POSTGRES_DB: ${SYSTEM_DB_NAME:-agent_system_test} ports: - "127.0.0.1:${SYSTEM_DB_PUBLISH_PORT:-5434}:5432" @@ -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: @@ -56,6 +41,7 @@ services: DATA_DIR: /app/data/workspaces MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} + # Release pin. dev uses MiniMax-M2.7-highspeed on purpose. MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3} MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible} MINIMAX_PROVIDER: minimax @@ -97,10 +83,9 @@ services: MAX_CONCURRENT_AGENTS: ${MAX_CONCURRENT_AGENTS:-8} DATA_DIR: /app/data # 独占测试业务与对话库 - 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 + APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@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} @@ -113,7 +98,7 @@ services: RERANKER_MODEL: ${RERANKER_MODEL:-bge-reranker-v2-m3-free} RERANKER_API_KEY: ${RERANKER_API_KEY:-} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin-secret-password-123} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?required} AUTH_REGISTRATION_ENABLED: ${AUTH_REGISTRATION_ENABLED:-true} AUTH_REQUIRE_APPROVAL: ${AUTH_REQUIRE_APPROVAL:-false} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} @@ -124,7 +109,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: @@ -162,7 +153,7 @@ services: HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}" EVENTS_HTTP_PORT: "${EVENTS_HTTP_PORT:-8089}" DATA_DIR: /app/data - 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 + APP_DB_DSN: postgres://${SYSTEM_DB_USER:-app}:${SYSTEM_DB_PASSWORD:?required}@system-db:5432/${SYSTEM_DB_NAME:-agent_system_test}?sslmode=disable ports: - "127.0.0.1:${EVENTS_HTTP_PORT:-8089}:${EVENTS_HTTP_PORT:-8089}" healthcheck: 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 06b33410..22d6ee19 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strconv" "strings" "sync" "time" @@ -33,6 +34,7 @@ import ( "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/storage" + "chat-agent-system/internal/sysadmin" "chat-agent-system/internal/taskrun" "chat-agent-system/internal/workflowdef" "chat-agent-system/internal/workspace" @@ -73,6 +75,7 @@ type Runtime struct { CAS *storage.CAS FreeDoc *freedoc.Service KB *kb.Client + SysAdmin *sysadmin.Service mu sync.Mutex running bool @@ -208,6 +211,19 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) { FreeDoc: parts.FreeDoc, KB: parts.KB, } + rt.SysAdmin = sysadmin.NewService(cfg, sysadmin.Options{ + Pool: pool, + Status: svc, + KB: parts.KB, + TaskStoreReady: func(ctx context.Context) bool { + return taskrun.TablesReady(ctx, pool) + }, + 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) @@ -257,6 +273,42 @@ func (r *Runtime) bindWorkflowApprovalHook() { r.hookBound = true } +// sysadminFeatureValues 在组合根解析各业务包公开 flag 的生效值,供 sysadmin 聚合层 +// 只读消费;sysadmin 不 import 业务包。 +func sysadminFeatureValues(cfg *config.Config) func() map[string]string { + return func() map[string]string { + iflags := intent.CurrentFeatureFlags() + pflags := intentflow.CurrentFeatureFlags() + sflags := ctxmgr.CurrentSummaryFlags() + values := map[string]string{ + "INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow), + "INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision), + "INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier), + "WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled), + "MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled), + "MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier), + "MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment), + "MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate), + "MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov), + "MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl), + "SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow), + "SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace), + "PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()), + } + if cfg != nil { + values["AGENT_BACKEND"] = cfg.AgentBackend + values["MAX_CONCURRENT_AGENTS"] = strconv.Itoa(cfg.MaxConcurrentAgents) + values["PI_AGENT_RUNNER_URL"] = cfg.PiAgentRunnerURL + values["ALLOW_IN_MEMORY"] = strconv.FormatBool(cfg.AllowInMemory) + values["AUTH_REGISTRATION_ENABLED"] = strconv.FormatBool(cfg.AuthRegistrationEnabled) + values["AUTH_REQUIRE_APPROVAL"] = strconv.FormatBool(cfg.AuthRequireApproval) + values["FAV_PUBLISHER_ENABLED"] = strconv.FormatBool(cfg.FAVPublisherEnabled) + values["FAV_DEGRADED_READ"] = strconv.FormatBool(cfg.FAVDegradedRead) + } + return values + } +} + // WriterLive reports whether the durable consumer already owns the visible write. func (r *Runtime) WriterLive(ctx context.Context, consumer string) bool { if r == nil { 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..d1d66a5f --- /dev/null +++ b/internal/app/instance_duty_adapter.go @@ -0,0 +1,47 @@ +package app + +import ( + "context" + "strings" + + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/workflowdef" +) + +// 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 !workflowdef.SameTaskKey(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/artifactvault/publisher_gate_test.go b/internal/artifactvault/publisher_gate_test.go index b8c5f473..0ec7d095 100644 --- a/internal/artifactvault/publisher_gate_test.go +++ b/internal/artifactvault/publisher_gate_test.go @@ -13,7 +13,7 @@ func TestFAVDegradedReadDefaultOff(t *testing.T) { } func TestPublisherEnableDefaultOff(t *testing.T) { - if os.Getenv("FAV_PUBLISHER_ENABLE") == "1" { + if os.Getenv("FAV_PUBLISHER_ENABLED") == "1" { t.Fatal("test env must not force publisher on") } } diff --git a/internal/auth/pg_store.go b/internal/auth/pg_store.go index 86895a18..564ce82b 100644 --- a/internal/auth/pg_store.go +++ b/internal/auth/pg_store.go @@ -8,9 +8,9 @@ import ( "strings" "time" + "chat-agent-system/internal/config" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" - "chat-agent-system/internal/config" ) // PgStore PostgreSQL 实现的账户、凭证与会话存储。 @@ -336,10 +336,13 @@ func (s *PgStore) ResolveUserID(ctx context.Context, tokenHash string) (int64, e var userID int64 var idleExp, absExp time.Time var revokedAt *time.Time + var status AccountStatus err := s.pool.QueryRow(ctx, ` - SELECT user_id, idle_expires_at, absolute_expires_at, revoked_at - FROM auth_sessions WHERE token_hash = $1 - `, tokenHash).Scan(&userID, &idleExp, &absExp, &revokedAt) + SELECT s.user_id, s.idle_expires_at, s.absolute_expires_at, s.revoked_at, u.status + FROM auth_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = $1 + `, tokenHash).Scan(&userID, &idleExp, &absExp, &revokedAt, &status) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, errSessionNotFound @@ -353,11 +356,6 @@ func (s *PgStore) ResolveUserID(ctx context.Context, tokenHash string) (int64, e if now.After(idleExp) || now.After(absExp) { return 0, errSessionNotFound } - - var status AccountStatus - if err := s.pool.QueryRow(ctx, `SELECT status FROM users WHERE id = $1`, userID).Scan(&status); err != nil { - return 0, errSessionNotFound - } if status == StatusDisabled || status == StatusPendingApproval { return 0, errSessionNotFound } diff --git a/internal/bizdev/doc_id.go b/internal/bizdev/doc_id.go index 64f10b32..eebe2ce1 100644 --- a/internal/bizdev/doc_id.go +++ b/internal/bizdev/doc_id.go @@ -5,11 +5,14 @@ import ( "fmt" "regexp" "strings" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/workflowdef" ) var ( - legacyDocPPattern = regexp.MustCompile(`^doc-p(\d+)-(.+)$`) - legacyDocProjHyphenPattern = regexp.MustCompile(`^doc-proj(\d+)-(.+)$`) + legacyDocPPattern = regexp.MustCompile(`^doc-p(\d+)-(.+)$`) + legacyDocProjHyphenPattern = regexp.MustCompile(`^doc-proj(\d+)-(.+)$`) ) // normalizeDocID 将 legacy 本地 ID 映射为服务端权威 doc_id。 @@ -46,7 +49,7 @@ func (s *Service) ResolveAndValidateDocID(ctx context.Context, req TaskRunReques return docID, nil } - doc, err := s.docGov.GetDocument(ctx, docID) + doc, err := lookupDocument(ctx, s, docID) if err != nil { // 尚未建档:与空 doc_id 行为一致,交由 CreateOrGetDocument 创建 if fallback := canonicalDocID(req.ProjectID, req.TaskKey); fallback != "" { @@ -61,9 +64,23 @@ func (s *Service) ResolveAndValidateDocID(ctx context.Context, req TaskRunReques if req.ProjectID > 0 && doc.ProjectID != req.ProjectID { return "", fmt.Errorf("doc_id %s does not belong to project %d", docID, req.ProjectID) } - // 已注册文档的 task_key 以账本为准;客户端 doc_id 不得改写或覆盖请求 task_key。 - if req.TaskKey != "" && doc.TaskKey != "" && doc.TaskKey != req.TaskKey { + // 已注册文档的 task_key 以账本为准。旧键与新键是同一任务,不算改写。 + if req.TaskKey != "" && doc.TaskKey != "" && !workflowdef.SameTaskKey(doc.TaskKey, req.TaskKey) { return "", fmt.Errorf("doc_id %s task_key mismatch: expected %s got %s", docID, req.TaskKey, doc.TaskKey) } return doc.DocID, nil } + +func lookupDocument(ctx context.Context, s *Service, docID string) (*docgov.DocumentRecord, error) { + doc, err := s.docGov.GetDocument(ctx, docID) + if err == nil { + return doc, nil + } + for _, alt := range workflowdef.AlternateDocIDs(docID) { + altDoc, altErr := s.docGov.GetDocument(ctx, alt) + if altErr == nil { + return altDoc, nil + } + } + return nil, err +} 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/full_link_e2e_test.go b/internal/bizdev/full_link_e2e_test.go index 717452a8..1f7b4022 100644 --- a/internal/bizdev/full_link_e2e_test.go +++ b/internal/bizdev/full_link_e2e_test.go @@ -11,6 +11,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/stream" + "chat-agent-system/internal/workflowdef" ) // executeStageDrainingPending runs ExecuteStage, confirming high-risk agent candidates @@ -139,7 +140,7 @@ func TestBizDev_FullLink_V01_V05_V10_E2E(t *testing.T) { if !resV01.Success { t.Fatalf("Stage V0.1 should succeed, got error: %s", resV01.ErrorReason) } - if resV01.PrimaryArtifact == nil || resV01.PrimaryArtifact.TaskKey != "intake" { + if resV01.PrimaryArtifact == nil || !workflowdef.SameTaskKey(resV01.PrimaryArtifact.TaskKey, "intake") { t.Fatalf("Stage V0.1 primary artifact should be intake, got: %+v", resV01.PrimaryArtifact) } if resV01.AdvisorBrief == nil || resV01.AdvisorBrief.MaturityLevel != "V0.1" { @@ -195,7 +196,7 @@ func TestBizDev_FullLink_V01_V05_V10_E2E(t *testing.T) { keys, resV05.AdvisorBrief.MaturityScore, resV05.AdvisorBrief.MaturityLevel) // 验证 feasibility_reg 受控文档已建立 - docID := "doc_proj1001_feasibility_reg" + docID := "doc_proj1001_bd_feasibility_reg" docRec, err := bizdev.Snapshot(svc).Gov.GetDocument(ctx, docID) if err != nil || docRec == nil { t.Fatalf("get docgov record failed: %v", err) @@ -282,7 +283,7 @@ func TestBizDev_FullLink_V01_V05_V10_E2E(t *testing.T) { t.Fatalf("Stage V0.5 force re-run should succeed, error: %s", resV05Updated.ErrorReason) } for _, art := range resV05Updated.SubArtifacts { - if art.TaskKey == "feasibility_reg" { + if workflowdef.SameTaskKey(art.TaskKey, "feasibility_reg") { _ = bizdev.Snapshot(svc).Gov.SetDocumentWorkDir(ctx, docID, art.WorkDir) break } @@ -362,7 +363,7 @@ func TestBizDev_FullLink_V01_V05_V10_E2E(t *testing.T) { if !resV10.Success { t.Fatalf("Stage V1.0 should succeed, error: %s", resV10.ErrorReason) } - if resV10.PrimaryArtifact == nil || resV10.PrimaryArtifact.TaskKey != "cdo_quote" { + if resV10.PrimaryArtifact == nil || !workflowdef.SameTaskKey(resV10.PrimaryArtifact.TaskKey, "cdo_quote") { t.Fatalf("Stage V1.0 primary artifact should be cdo_quote") } 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..6c65dbae 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。 @@ -202,7 +220,7 @@ func NewService(cfg *config.Config) (*Service, error) { } } - if pwSvc != nil && doc.TaskKey == "system_reqs" { + if pwSvc != nil && workflowdef.SameTaskKey(doc.TaskKey, "system_reqs") { _, _ = pwSvc.CheckAndPropagateStale(ctx, doc.DocID, doc.ContentSHA256, fmt.Sprintf("上游系统需求规格书 V%d 已正式批准,已标记关联下游验证方案为待复核(stale)", doc.Version)) } }) @@ -267,26 +285,88 @@ var SupportedStages = []StageMeta{ Stage: StageV01_Triage, Title: "V0.1 闪电摸底与盲区探测", Description: "快速梳理客户诉求与约束,建立基准技术假设,生成首期澄清微信话术与初始需求表", - Tasks: []string{"intake"}, + Tasks: []string{"bd_intake"}, }, { Stage: StageV05_Feasibility, Title: "V0.5 方案成型与法规对齐", Description: "法规与注册路径初筛、竞品调研、系统初步架构设计与关键选型建议", - Tasks: []string{"research", "feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design"}, + Tasks: []string{"bd_research", "bd_feasibility_dev", "bd_feasibility_reg", "bd_user_requirements", "bd_prelim_design"}, }, { Stage: StageV10_Quoting, Title: "V1.0 工程拆解与商业锁定", Description: "全维度 WBS 工时拆解、Excel 定价核算、正式 CDO 商务报价单与谈判防御条款建议", - Tasks: []string{"cdo_quote"}, + Tasks: []string{"bd_cdo_quote"}, }, } +func taskIs(key string, wants ...string) bool { + for _, want := range wants { + if workflowdef.SameTaskKey(key, want) { + return true + } + } + return false +} + 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 workflowdef.ImplementationTaskKey(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 +380,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 @@ -309,7 +399,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun } // 针对 system_reqs(智能产品经理):若未显式提供 materials,自动抽取项目事实黑板/集体上下文为需求输入 - if req.TaskKey == "system_reqs" && len(materials) == 0 && s.ctxMgr != nil && req.ProjectID > 0 { + if workflowdef.SameTaskKey(req.TaskKey, "system_reqs") && len(materials) == 0 && s.ctxMgr != nil && req.ProjectID > 0 { coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID) if err == nil && coll != nil { var b strings.Builder @@ -356,7 +446,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun // 自动补充知识库检索 (如针对 research、feasibility_reg、cleanroom_spec、prelim_design、system_reqs、test_manager 等阶段) hits := req.KnowledgeHits - if len(hits) == 0 && (req.TaskKey == "research" || req.TaskKey == "feasibility_reg" || req.TaskKey == "cleanroom_spec" || req.TaskKey == "prelim_design" || req.TaskKey == "system_reqs" || req.TaskKey == "test_manager") { + if len(hits) == 0 && taskIs(req.TaskKey, "research", "feasibility_reg", "cleanroom_spec", "prelim_design", "system_reqs", "test_manager") { device := req.DeviceType if device == "" && req.Fields != nil { device = req.Fields["device_type"] @@ -409,7 +499,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun } // 智能测试工程师:统一项目上下文快照;SRS 仅为可选高权威来源 var upstreamSRDoc *docgov.DocumentRecord - if req.TaskKey == "test_manager" || req.TaskKey == "system_reqs" { + if taskIs(req.TaskKey, "test_manager", "system_reqs") { priorDocs := make([]squad.PriorDocItem, 0) facts := map[string]string{} if req.Fields != nil { @@ -456,7 +546,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", "pm_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 +614,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 +639,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 +667,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 +834,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, @@ -1007,7 +1166,7 @@ func (s *Service) finalizeSquadDeliverable( map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir}, true) } - if res.Success && req.TaskKey == "test_manager" && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 { + if res.Success && workflowdef.SameTaskKey(req.TaskKey, "test_manager") && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 { _ = s.projectWorkflow.RecordLineage(ctx, &projectworkflow.DocumentLineage{ ProjectID: req.ProjectID, DownstreamDocID: docID, @@ -1019,7 +1178,7 @@ func (s *Service) finalizeSquadDeliverable( }) } // Stale propagation only after fact source committed (not while pending confirmation). - if res.Success && req.TaskKey == "system_reqs" && s.projectWorkflow != nil && req.ProjectID > 0 && + if res.Success && workflowdef.SameTaskKey(req.TaskKey, "system_reqs") && s.projectWorkflow != nil && req.ProjectID > 0 && (s.docRev == nil || (docRec != nil && docRec.ContentSHA256 != "")) { sha := sha256.Sum256([]byte(res.MarkdownBody)) shaStr := hex.EncodeToString(sha[:]) @@ -1339,7 +1498,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 +1540,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/stage_quality_stop_test.go b/internal/bizdev/stage_quality_stop_test.go index 2dfe59d8..f6baecb6 100644 --- a/internal/bizdev/stage_quality_stop_test.go +++ b/internal/bizdev/stage_quality_stop_test.go @@ -10,6 +10,7 @@ import ( "chat-agent-system/internal/config" "chat-agent-system/internal/squad" + "chat-agent-system/internal/workflowdef" ) func TestClassifyStageTaskHalt_QualityFailedStopsWithoutAbsorbing(t *testing.T) { @@ -77,12 +78,12 @@ func TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc(t var seenKeys []string svc.stageTaskHook = func(_ context.Context, req TaskRunRequest) (*TaskRunResult, error) { seenKeys = append(seenKeys, req.TaskKey) - if req.TaskKey != "research" { + if !workflowdef.SameTaskKey(req.TaskKey, "research") { t.Fatalf("quality fail must stop before subsequent task %q; seen=%v priors=%d", req.TaskKey, seenKeys, len(req.PriorDocs)) } for _, p := range req.PriorDocs { - if p.DocKey == "research" || strings.Contains(p.Content, "未过门禁") { + if workflowdef.SameTaskKey(p.DocKey, "research") || strings.Contains(p.Content, "未过门禁") { t.Fatalf("must not feed failed markdown as PriorDoc: %+v", p) } } @@ -135,10 +136,10 @@ func TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc(t if stageRes.PrimaryArtifact.TerminalStatus() != TaskStatusQualityFailed { t.Fatalf("primary status=%q", stageRes.PrimaryArtifact.TerminalStatus()) } - if len(stageRes.SubArtifacts) != 1 || seenKeys[0] != "research" || len(seenKeys) != 1 { + if len(stageRes.SubArtifacts) != 1 || !workflowdef.SameTaskKey(seenKeys[0], "research") || len(seenKeys) != 1 { t.Fatalf("must stop after first task; sub=%d seen=%v", len(stageRes.SubArtifacts), seenKeys) } - for _, next := range []string{"feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design"} { + for _, next := range []string{"feasibility_dev", "bd_feasibility_dev", "feasibility_reg", "bd_feasibility_reg", "user_requirements", "bd_user_requirements", "prelim_design", "bd_prelim_design"} { if _, err := os.Stat(filepath.Join(stageWork, next, "output.md")); err == nil { t.Fatalf("subsequent task %s must not run", next) } 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/chat/session.go b/internal/chat/session.go index 18ebc21e..af294a82 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -43,6 +43,8 @@ type ChatResponse struct { } // Session 多轮对话与协同会话管理器 +// mu 保护 Messages/UserCtx/LastAccessedAt/ProjectID/ActiveStageKey 的并发访问; +// execMu 仅用于同 session 串行执行 LLM 回合,两者正交。 type Session struct { ID string `json:"id"` UserID int64 `json:"user_id"` @@ -58,9 +60,54 @@ type Session struct { Messages []Message `json:"messages"` CreatedAt time.Time `json:"created_at"` LastAccessedAt time.Time `json:"last_accessed_at"` + mu sync.RWMutex `json:"-"` execMu sync.Mutex `json:"-"` } +// SnapshotMessages 返回 Messages 的只读快照(拷贝),调用方可在锁外安全遍历/编码。 +func (s *Session) SnapshotMessages() []Message { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]Message, len(s.Messages)) + copy(out, s.Messages) + return out +} + +// AppendMessage 在锁内向 Messages 追加一条消息。 +func (s *Session) AppendMessage(m Message) { + s.mu.Lock() + defer s.mu.Unlock() + s.Messages = append(s.Messages, m) +} + +// TouchLastAccessed 更新访问时间戳。 +func (s *Session) TouchLastAccessed() { + s.mu.Lock() + defer s.mu.Unlock() + s.LastAccessedAt = time.Now() +} + +// LastAccessed 读取访问时间戳。 +func (s *Session) LastAccessed() time.Time { + s.mu.RLock() + defer s.mu.RUnlock() + return s.LastAccessedAt +} + +// SetProjectID 设置当前会话绑定的项目 ID。 +func (s *Session) SetProjectID(pid int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.ProjectID = pid +} + +// Project 读取当前会话绑定的项目 ID。 +func (s *Session) Project() int64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.ProjectID +} + // TurnOptions 客户端回合幂等选项 type TurnOptions struct { ClientTurnID string @@ -129,7 +176,9 @@ func (s *Session) SyncRecentDialogue() { // 若发生了截断且存在早期消息,同步计算或加载滚动摘要 if truncated && len(early) > 0 && s.CtxMgr != nil { - s.syncDialogueSummary(context.Background(), early) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + s.syncDialogueSummary(ctx, early) } } @@ -261,7 +310,7 @@ func (s *Session) SendMessageStreamTurn(ctx context.Context, userInput string, l func (s *Session) sendMessageInternal(ctx context.Context, userInput string, streamer *stream.Streamer, turn *TurnOptions) (*ChatResponse, error) { start := time.Now() - s.LastAccessedAt = time.Now() + s.TouchLastAccessed() if streamer != nil { streamer.EmitPhase("intent_recognizing", "正在研判您的意图与项目上下文...", "coordinator", 0, 0, 0) } @@ -275,7 +324,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str Content: userInput, Timestamp: time.Now(), } - s.Messages = append(s.Messages, userMsg) + s.AppendMessage(userMsg) var userMsgID int64 clientTurnID := "" @@ -472,7 +521,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str InteractiveCard: coordRes.InteractiveCard, Timestamp: time.Now(), } - s.Messages = append(s.Messages, asstMsg) + s.AppendMessage(asstMsg) if s.CtxMgr != nil { replySrc := coordRes.ReplySource 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/coordinator/engine.go b/internal/coordinator/engine.go index ff274860..c2cfb383 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -405,10 +405,10 @@ func (e *CoordinatorEngine) handleAmbiguousConsult(ctx context.Context, cand *in projName = "当前项目" } - // 若用户输入实质上包含具体的技术测试、方案、难点或业务要点询问,不应机械回复商业接待犹豫套话 - if strings.Contains(rawInput, "测试") || strings.Contains(rawInput, "验证") || strings.Contains(rawInput, "型检") || + // 只在低置信度的模糊咨询里,才用关键词改走测试分析。高置信度保持原意图。 + if cand.Confidence < 0.6 && (strings.Contains(rawInput, "测试") || strings.Contains(rawInput, "验证") || strings.Contains(rawInput, "型检") || strings.Contains(rawInput, "难点") || strings.Contains(rawInput, "注意") || strings.Contains(rawInput, "比较难") || - strings.Contains(rawInput, "方案") { + strings.Contains(rawInput, "方案")) { if cand.FilledSlots == nil { cand.FilledSlots = make(map[string]string) } @@ -482,8 +482,14 @@ func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *inten projName = "肌骨超声AI辅助诊断系统" } + greet := strings.TrimSpace(userCtx.User.Name) + if greet == "" { + greet = "您好" + } else { + greet += ",您好" + } var sb strings.Builder - sb.WriteString(fmt.Sprintf("张工您好!针对「%s」的系统验证方案(SVP)与型式检验要求,我为您梳理以下核心难点项目与特别注意事项:\n\n", projName)) + sb.WriteString(fmt.Sprintf("%s!针对「%s」的系统验证方案(SVP)与型式检验要求,我为您梳理以下核心难点项目与特别注意事项:\n\n", greet, projName)) sb.WriteString("### 一、 核心测试难点项目\n\n") sb.WriteString("1. **声学输出安全与探头表面温升极限(GB 9706.237-2020)**\n") sb.WriteString(" • **难点分析**:肌骨超声常需配合高频线阵探头(10-18MHz)进行浅表滑膜微血流彩色多普勒(CDFI/Power Doppler)检测。连续高占空比发射脉冲会导致探头声透镜声吸收发热。根据强标要求,探头施加于人体的表面温升严禁超过 **41℃**,同时机械指数(MI)需严格低于 1.9,热指数(TIS/TIB)需低于 1.0。\n") @@ -499,7 +505,7 @@ func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *inten sb.WriteString("• **电磁兼容(EMC GB 9706.102-2021)抗扰度储备**:高频微弱回波信号接收通道抗射频电磁场辐射敏感度高,测试方案中需提前验证探头线缆双层屏蔽接地与机箱屏蔽缝隙,避免正式型检时辐射发射(RE)超标返工;\n") sb.WriteString("• **DICOM 与数据脱敏合规**:软件系统测试中,所有导出样本、测试影像的患者个人隐私标识(PII)脱敏规则需执行 100% 覆盖率验证;\n") sb.WriteString("• **极端异常输入与容错安全防呆**:探头带电热插拔、探头声透镜声胶脱落阻抗异常检测、长时间无操作探头自动降频防发热休眠逻辑,均需设计破坏性负向测试用例。\n\n") - sb.WriteString("💡 **建议行动**:张工,若您需要,我可以为您起草该模块对应的《系统验证方案 (SVP) 关键测试用例矩阵》,列出详细的测试条件、前置配置与合格判据。") + sb.WriteString("💡 **建议行动**:若您需要,我可以为您起草该模块对应的《系统验证方案 (SVP) 关键测试用例矩阵》,列出详细的测试条件、前置配置与合格判据。") return &CoordinationResult{ Status: StatusAccepted, @@ -928,10 +934,10 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent. var projectID, userID int64 if userCtx.Project.ID != "" { - fmt.Sscanf(userCtx.Project.ID, "%d", &projectID) + projectID = parseDecimalID(userCtx.Project.ID) } if userCtx.User.ID != "" { - fmt.Sscanf(userCtx.User.ID, "%d", &userID) + userID = parseDecimalID(userCtx.User.ID) } taskRes, err := e.tasks().ExecuteTask(ctx, stageflow.TaskRunRequest{ @@ -1065,10 +1071,10 @@ func (e *CoordinatorEngine) handleTriggerAdhocTask(ctx context.Context, cand *in var projectID, userID int64 if userCtx.Project.ID != "" { - fmt.Sscanf(userCtx.Project.ID, "%d", &projectID) + projectID = parseDecimalID(userCtx.Project.ID) } if userCtx.User.ID != "" { - fmt.Sscanf(userCtx.User.ID, "%d", &userID) + userID = parseDecimalID(userCtx.User.ID) } if st := stream.FromContext(ctx); st != nil { @@ -1822,3 +1828,17 @@ func (e *CoordinatorEngine) handleQueryUserRights(ctx context.Context, cand *int UpdatedContext: userCtx, }, nil } + +// parseDecimalID accepts only a non-negative decimal integer. +// Values like u_sess_* must not become UserID 0 via a partial Sscanf match. +func parseDecimalID(raw string) int64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n < 0 { + return 0 + } + return n +} diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go index 6776105d..4077eba3 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -981,4 +981,10 @@ func TestCoordinatorEngine_TestingAnalysis(t *testing.T) { if strings.Contains(res.NaturalReply, "完全理解您想再掂量一下") { t.Errorf("reply must not contain commercial triage boilerplate, got: %s", res.NaturalReply) } + if !strings.Contains(res.NaturalReply, "张工,您好") { + t.Errorf("greeting must use the user name, got: %s", res.NaturalReply) + } + if strings.Contains(res.NaturalReply, "张工您好") { + t.Errorf("hardcoded greeting must be gone, got: %s", res.NaturalReply) + } } diff --git a/internal/coordinator/parse_id_test.go b/internal/coordinator/parse_id_test.go new file mode 100644 index 00000000..adb5ca19 --- /dev/null +++ b/internal/coordinator/parse_id_test.go @@ -0,0 +1,18 @@ +package coordinator + +import "testing" + +func TestParseDecimalIDRejectsSessionTokens(t *testing.T) { + if got := parseDecimalID("u_sess_12"); got != 0 { + t.Fatalf("session token parsed as %d", got) + } + if got := parseDecimalID("108abc"); got != 0 { + t.Fatalf("partial decimal parsed as %d", got) + } + if got := parseDecimalID("108"); got != 108 { + t.Fatalf("got %d", got) + } + if got := parseDecimalID(" 0 "); got != 0 { + t.Fatalf("got %d", got) + } +} 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..f0220df7 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 { @@ -277,11 +315,13 @@ func (e *GovernanceEngine) SetDocumentWorkDir(ctx context.Context, docID string, doc.UpdatedAt = time.Now() if e.pool != nil { - _, _ = e.pool.Exec(ctx, ` + if _, err := e.pool.Exec(ctx, ` UPDATE document_records SET work_dir = $1, updated_at = $2 WHERE doc_id = $3 - `, doc.WorkDir, doc.UpdatedAt, docID) + `, doc.WorkDir, doc.UpdatedAt, docID); err != nil { + slog.Error("docgov_workdir_persist_failed", "doc_id", docID, "error", err) + } } return nil @@ -351,7 +391,7 @@ func (e *GovernanceEngine) RecordDocumentViewed(ctx context.Context, docID strin e.memAudits[docID] = append(e.memAudits[docID], audit) if e.pool != nil { - _, _ = e.pool.Exec(ctx, ` + if _, err := e.pool.Exec(ctx, ` UPDATE document_records SET author_claimed = $1, author_claimed_at = $2, author_last_viewed_at = $3, reviewer_claimed = $4, reviewer_claimed_at = $5, @@ -361,14 +401,18 @@ func (e *GovernanceEngine) RecordDocumentViewed(ctx context.Context, docID strin `, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastViewedAt, doc.ReviewerClaimed, doc.ReviewerClaimedAt, doc.ApproverClaimed, doc.ApproverClaimedAt, - doc.UpdatedAt, docID) + doc.UpdatedAt, docID); err != nil { + slog.Error("docgov_viewed_persist_failed", "doc_id", docID, "error", err) + } detJSON, _ := json.Marshal(audit.Details) - _, _ = e.pool.Exec(ctx, ` + if _, err := e.pool.Exec(ctx, ` INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole), - string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt) + string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil { + slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err) + } } return doc, nil @@ -429,18 +473,22 @@ func (e *GovernanceEngine) RecordDocumentEdited(ctx context.Context, docID strin e.memAudits[docID] = append(e.memAudits[docID], audit) if e.pool != nil { - _, _ = e.pool.Exec(ctx, ` + if _, err := e.pool.Exec(ctx, ` UPDATE document_records SET author_claimed = $1, author_claimed_at = $2, author_last_edited_at = $3, updated_at = $4 WHERE doc_id = $5 - `, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastEditedAt, doc.UpdatedAt, docID) + `, doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastEditedAt, doc.UpdatedAt, docID); err != nil { + slog.Error("docgov_edited_persist_failed", "doc_id", docID, "error", err) + } detJSON, _ := json.Marshal(audit.Details) - _, _ = e.pool.Exec(ctx, ` + if _, err := e.pool.Exec(ctx, ` INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole), - string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt) + string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil { + slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err) + } } return doc, nil @@ -609,11 +657,13 @@ func (e *GovernanceEngine) ProposeModification( } detJSON, _ := json.Marshal(audit.Details) - _, _ = e.pool.Exec(ctx, ` + if _, err := e.pool.Exec(ctx, ` INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole), - string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt) + string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil { + slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err) + } } return proposal, nil @@ -702,18 +752,22 @@ func (e *GovernanceEngine) DecideModificationProposal( e.memAudits[proposal.DocID] = append(e.memAudits[proposal.DocID], audit) if e.pool != nil { - _, _ = e.pool.Exec(ctx, ` + if _, err := e.pool.Exec(ctx, ` UPDATE document_change_proposals SET status = $1, author_decision_reason = $2, decided_at = $3 WHERE proposal_id = $4 - `, string(proposal.Status), proposal.AuthorDecisionReason, proposal.DecidedAt, proposalID) + `, string(proposal.Status), proposal.AuthorDecisionReason, proposal.DecidedAt, proposalID); err != nil { + slog.Error("docgov_proposal_decide_persist_failed", "proposal_id", proposalID, "error", err) + } detJSON, _ := json.Marshal(audit.Details) - _, _ = e.pool.Exec(ctx, ` + if _, err := e.pool.Exec(ctx, ` INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole), - string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt) + string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil { + slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err) + } } return proposal, nil @@ -985,6 +1039,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 +1078,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 +1103,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(lease_fencing_token, ''), + COALESCE(duty_locked, false), COALESCE(duty_policy, '') FROM document_records WHERE doc_id = $1 `, docID) @@ -1061,7 +1122,8 @@ func (e *GovernanceEngine) loadDocumentFromDBLocked(ctx context.Context, docID s &doc.ReviewerClaimed, &doc.ReviewerClaimedAt, &doc.ApproverClaimed, &doc.ApproverClaimedAt, &doc.LastRejectedReason, &doc.LastRejectedByRole, &doc.LastRejectedByName, &doc.LastRejectedAt, &doc.ApprovedAt, &cfgJSON, &doc.CreatedAt, &doc.UpdatedAt, - &leaseType, &leaseID, &leaseWD, &doc.LeaseExpiresAt, &doc.LeaseHeartbeatAt, + &leaseType, &leaseID, &leaseWD, &doc.LeaseExpiresAt, &doc.LeaseHeartbeatAt, &doc.LeaseFencingToken, + &doc.DutyLocked, &doc.DutyPolicy, ) if err != nil { return nil, err diff --git a/internal/docgov/lease.go b/internal/docgov/lease.go index 73f017a1..96942d63 100644 --- a/internal/docgov/lease.go +++ b/internal/docgov/lease.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "path/filepath" "strings" "time" @@ -60,26 +61,21 @@ func (e *GovernanceEngine) AcquireLease(ctx context.Context, req LeaseAcquireReq } now := time.Now() - ttl := req.TTL - if ttl <= 0 { - if req.HolderType == LeaseHolderSquad { - ttl = DefaultSquadLeaseTTL - } else { - ttl = DefaultHumanLeaseTTL - } - } + ttl := clampLeaseTTL(req.HolderType, req.TTL) if doc.HasActiveLease(now) { sameHolder := doc.LeaseHolderType == req.HolderType && doc.LeaseHolderID == req.HolderID if !sameHolder && req.HolderType == LeaseHolderSquad && e.shouldReclaimSquadLease(ctx, doc) { - e.appendAuditLocked(doc, "lease_reclaim_orphan", 0, string(req.HolderType), RoleSystem, map[string]any{ + e.appendAuditLocked(ctx, doc, "lease_reclaim_orphan", 0, string(req.HolderType), RoleSystem, map[string]any{ "previous_holder_type": doc.LeaseHolderType, "previous_holder_id": doc.LeaseHolderID, }) doc.ClearLease() - e.persistLeaseLocked(ctx, doc) + if perr := e.persistLeaseLocked(ctx, doc); perr != nil { + slog.Error("docgov_lease_reclaim_persist_failed", "doc_id", doc.DocID, "error", perr) + } } else if !sameHolder { - e.appendAuditLocked(doc, "lease_deny", 0, string(req.HolderType), RoleSystem, map[string]any{ + e.appendAuditLocked(ctx, doc, "lease_deny", 0, string(req.HolderType), RoleSystem, map[string]any{ "requester_type": req.HolderType, "requester_id": req.HolderID, "holder_type": doc.LeaseHolderType, @@ -98,6 +94,9 @@ func (e *GovernanceEngine) AcquireLease(ctx context.Context, req LeaseAcquireReq } expires := now.Add(ttl) + // 先持久化再更新内存,失败时不污染内存租约状态 + prevType, prevID, prevWD := doc.LeaseHolderType, doc.LeaseHolderID, doc.LeaseWorkDir + prevExpires, prevHeartbeat, prevFencing := doc.LeaseExpiresAt, doc.LeaseHeartbeatAt, doc.LeaseFencingToken doc.LeaseHolderType = req.HolderType doc.LeaseHolderID = req.HolderID if req.WorkDir != "" { @@ -108,8 +107,12 @@ func (e *GovernanceEngine) AcquireLease(ctx context.Context, req LeaseAcquireReq doc.LeaseFencingToken = newLeaseFencingToken() doc.UpdatedAt = now - e.persistLeaseLocked(ctx, doc) - e.appendAuditLocked(doc, "lease_acquire", 0, string(req.HolderType), RoleSystem, map[string]any{ + if perr := e.persistLeaseLocked(ctx, doc); perr != nil { + doc.LeaseHolderType, doc.LeaseHolderID, doc.LeaseWorkDir = prevType, prevID, prevWD + doc.LeaseExpiresAt, doc.LeaseHeartbeatAt, doc.LeaseFencingToken = prevExpires, prevHeartbeat, prevFencing + return nil, perr + } + e.appendAuditLocked(ctx, doc, "lease_acquire", 0, string(req.HolderType), RoleSystem, map[string]any{ "holder_type": req.HolderType, "holder_id": req.HolderID, "work_dir": doc.LeaseWorkDir, @@ -134,7 +137,9 @@ func (e *GovernanceEngine) ReleaseLease(ctx context.Context, docID, holderType, if !doc.HasActiveLease(now) { doc.ClearLease() doc.UpdatedAt = now - e.persistLeaseLocked(ctx, doc) + if perr := e.persistLeaseLocked(ctx, doc); perr != nil { + slog.Error("docgov_lease_release_persist_failed", "doc_id", doc.DocID, "error", perr) + } return doc, nil } @@ -144,8 +149,10 @@ func (e *GovernanceEngine) ReleaseLease(ctx context.Context, docID, holderType, doc.ClearLease() doc.UpdatedAt = now - e.persistLeaseLocked(ctx, doc) - e.appendAuditLocked(doc, "lease_release", 0, holderType, RoleSystem, map[string]any{ + if perr := e.persistLeaseLocked(ctx, doc); perr != nil { + slog.Error("docgov_lease_release_persist_failed", "doc_id", doc.DocID, "error", perr) + } + e.appendAuditLocked(ctx, doc, "lease_release", 0, holderType, RoleSystem, map[string]any{ "holder_type": holderType, "holder_id": holderID, }) @@ -170,18 +177,14 @@ func (e *GovernanceEngine) HeartbeatLease(ctx context.Context, docID, holderType return doc, ErrLeaseNotHeld } - if ttl <= 0 { - if doc.LeaseHolderType == LeaseHolderSquad { - ttl = DefaultSquadLeaseTTL - } else { - ttl = DefaultHumanLeaseTTL - } - } + ttl = clampLeaseTTL(doc.LeaseHolderType, ttl) expires := now.Add(ttl) doc.LeaseExpiresAt = &expires doc.LeaseHeartbeatAt = &now doc.UpdatedAt = now - e.persistLeaseLocked(ctx, doc) + if perr := e.persistLeaseLocked(ctx, doc); perr != nil { + slog.Error("docgov_lease_heartbeat_persist_failed", "doc_id", doc.DocID, "error", perr) + } return doc, nil } @@ -315,26 +318,40 @@ func (e *GovernanceEngine) MarkProposalApplied(ctx context.Context, proposalID s return nil } +func clampLeaseTTL(holder LeaseHolderType, ttl time.Duration) time.Duration { + maxTTL := DefaultHumanLeaseTTL + if holder == LeaseHolderSquad { + maxTTL = DefaultSquadLeaseTTL + } + if ttl <= 0 || ttl > maxTTL { + return maxTTL + } + return ttl +} + +// CheckHumanCanEdit verifies author duty and drafting state without taking a lease. +func (e *GovernanceEngine) CheckHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole) error { + e.mu.Lock() + defer e.mu.Unlock() + doc, err := e.getDocLocked(ctx, docID) + if err != nil { + return err + } + return e.sm.CanEditDocumentContent(doc, actorID, actorRole) +} + // AssertHumanCanEdit checks author duty + drafting lifecycle BEFORE acquiring/renewing // the human lease, so unauthorized actors never become lease holders. func (e *GovernanceEngine) AssertHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole) error { - e.mu.Lock() - doc, err := e.getDocLocked(ctx, docID) - if err != nil { - e.mu.Unlock() + if err := e.CheckHumanCanEdit(ctx, docID, actorID, actorRole); err != nil { return err } - if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole); err != nil { - e.mu.Unlock() - return err - } - e.mu.Unlock() holderID := fmt.Sprintf("%d", actorID) if actorID <= 0 { holderID = "human_anon" } - _, err = e.AcquireLease(ctx, LeaseAcquireRequest{ + _, err := e.AcquireLease(ctx, LeaseAcquireRequest{ DocID: docID, HolderType: LeaseHolderHuman, HolderID: holderID, @@ -402,16 +419,19 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS } if e.pool != nil { - _, _ = e.pool.Exec(ctx, ` + if _, perr := e.pool.Exec(ctx, ` UPDATE document_records SET content_sha256 = $1, is_template_only = $2, work_dir = $3, author_claimed = $4, author_claimed_at = $5, author_last_edited_at = $6, updated_at = $7 WHERE doc_id = $8 `, doc.ContentSHA256, doc.IsTemplateOnly, doc.WorkDir, - doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastEditedAt, doc.UpdatedAt, doc.DocID) + doc.AuthorClaimed, doc.AuthorClaimedAt, doc.AuthorLastEditedAt, doc.UpdatedAt, doc.DocID); perr != nil { + slog.Error("docgov_content_persist_failed", "doc_id", doc.DocID, "error", perr) + return nil, fmt.Errorf("docgov: persist content: %w", perr) + } } - e.appendAuditLocked(doc, "author_edit_content", req.ActorID, req.ActorName, req.ActorRole, map[string]any{ + e.appendAuditLocked(ctx, doc, "author_edit_content", req.ActorID, req.ActorName, req.ActorRole, map[string]any{ "content_sha256": shaStr, "bytes": len(canon), }) @@ -453,20 +473,25 @@ func (e *GovernanceEngine) getDocLocked(ctx context.Context, docID string) (*Doc return doc, nil } -func (e *GovernanceEngine) persistLeaseLocked(ctx context.Context, doc *DocumentRecord) { +func (e *GovernanceEngine) persistLeaseLocked(ctx context.Context, doc *DocumentRecord) error { if e.pool == nil { - return + return nil } - _, _ = e.pool.Exec(ctx, ` + _, err := e.pool.Exec(ctx, ` UPDATE document_records SET lease_holder_type = $1, lease_holder_id = $2, lease_work_dir = $3, lease_expires_at = $4, lease_heartbeat_at = $5, lease_fencing_token = $6, updated_at = $7 WHERE doc_id = $8 `, string(doc.LeaseHolderType), doc.LeaseHolderID, doc.LeaseWorkDir, doc.LeaseExpiresAt, doc.LeaseHeartbeatAt, doc.LeaseFencingToken, doc.UpdatedAt, doc.DocID) + if err != nil { + slog.Error("docgov_lease_persist_failed", "doc_id", doc.DocID, "holder", doc.LeaseHolderID, "error", err) + return fmt.Errorf("docgov: persist lease: %w", err) + } + return nil } -func (e *GovernanceEngine) appendAuditLocked(doc *DocumentRecord, action string, actorID int64, actorName string, actorRole UserRole, details map[string]any) { +func (e *GovernanceEngine) appendAuditLocked(ctx context.Context, doc *DocumentRecord, action string, actorID int64, actorName string, actorRole UserRole, details map[string]any) { now := time.Now() audit := &AuditLog{ DocID: doc.DocID, @@ -482,10 +507,12 @@ func (e *GovernanceEngine) appendAuditLocked(doc *DocumentRecord, action string, e.memAudits[doc.DocID] = append(e.memAudits[doc.DocID], audit) if e.pool != nil { detJSON, _ := json.Marshal(details) - _, _ = e.pool.Exec(context.Background(), ` + if _, err := e.pool.Exec(ctx, ` INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole), - string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt) + string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil { + slog.Error("docgov_audit_persist_failed", "doc_id", audit.DocID, "action", audit.Action, "error", err) + } } } diff --git a/internal/docgov/lease_test.go b/internal/docgov/lease_test.go index 4e53e92f..7b47df5d 100644 --- a/internal/docgov/lease_test.go +++ b/internal/docgov/lease_test.go @@ -151,3 +151,29 @@ func TestAssertHumanCanEdit_ChecksDutyBeforeLease(t *testing.T) { t.Fatalf("author should hold human lease after assert, got type=%s id=%s", doc.LeaseHolderType, doc.LeaseHolderID) } } + +func TestAcquireLeaseClampsHumanTTL(t *testing.T) { + engine := docgov.NewGovernanceEngine(nil) + ctx := context.Background() + docID := "doc_ttl_clamp" + if _, err := engine.CreateOrGetDocument(ctx, docID, 1, "intake", "ttl", "V0.1", "", + 1, "作者", 2, "审核", 3, "批准"); err != nil { + t.Fatalf("create: %v", err) + } + before := time.Now() + doc, err := engine.AcquireLease(ctx, docgov.LeaseAcquireRequest{ + DocID: docID, + HolderType: docgov.LeaseHolderHuman, + HolderID: "1", + TTL: 1000 * 24 * time.Hour, + }) + if err != nil { + t.Fatal(err) + } + if doc.LeaseExpiresAt == nil || doc.LeaseExpiresAt.After(before.Add(docgov.DefaultHumanLeaseTTL+time.Second)) { + t.Fatalf("ttl not clamped: expires=%v", doc.LeaseExpiresAt) + } + if err := engine.CheckHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor); err == nil { + t.Fatal("non-author must not pass edit check") + } +} 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_mutate.go b/internal/docrevision/pg_mutate.go index 28d49f50..a15d63a3 100644 --- a/internal/docrevision/pg_mutate.go +++ b/internal/docrevision/pg_mutate.go @@ -68,6 +68,13 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootPointers, ver *Version, snap *Snapshot, ev *ChangeEvent, isBaseline bool) error { if !s.usingPG() { + if root != nil && snap != nil { + key := "" + if ev != nil { + key = ev.IdempotencyKey + } + s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key) + } return nil } blob := s.blobs[snap.ContentSHA256] @@ -113,10 +120,29 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP return err } s.adoptEventID(ev, eid) + key := "" + if ev != nil { + key = ev.IdempotencyKey + } + if err := s.insertContentProjectionTX(ctx, tx, root.DocID, snap.ID, root.HeadRevisionNo, key); err != nil { + return err + } + s.noteContentProjectionLocked(root.DocID, snap.ID, root.HeadRevisionNo, key) return s.projectRootPointersTX(ctx, tx, root) }) } +func (s *Service) insertContentProjectionTX(ctx context.Context, tx pgx.Tx, docID string, snapshotID int64, revisionNo int, idempotencyKey string) error { + if docID == "" || snapshotID == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + INSERT INTO document_content_projections (doc_id, snapshot_id, revision_no, idempotency_key, status) + VALUES ($1, $2, $3, $4, 'pending') + ON CONFLICT (doc_id, snapshot_id) DO NOTHING`, docID, snapshotID, revisionNo, idempotencyKey) + return err +} + func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snapshot, rep *ChangeReport, c *AgentCandidate) error { if !s.usingPG() { return nil 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/docrevision/projection_outbox.go b/internal/docrevision/projection_outbox.go new file mode 100644 index 00000000..e4ed2c6d --- /dev/null +++ b/internal/docrevision/projection_outbox.go @@ -0,0 +1,61 @@ +package docrevision + +import "context" + +// ContentProjection is the fact-source write still owed after a human ledger commit. +// The row is inserted in the same transaction as the ledger. It is removed only after +// DocGov / workspace / freeform projection succeeds. +type ContentProjection struct { + DocID string + SnapshotID int64 + RevisionNo int + IdempotencyKey string +} + +func (s *Service) noteContentProjectionLocked(docID string, snapshotID int64, revisionNo int, idempotencyKey string) { + if s == nil || docID == "" || snapshotID == 0 { + return + } + if s.projections == nil { + s.projections = map[string]ContentProjection{} + } + s.projections[docID] = ContentProjection{ + DocID: docID, + SnapshotID: snapshotID, + RevisionNo: revisionNo, + IdempotencyKey: idempotencyKey, + } +} + +// ContentProjectionPending reports an unprojected ledger head for docID. +func (s *Service) ContentProjectionPending(docID string) (ContentProjection, bool) { + if s == nil { + return ContentProjection{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.projections[docID] + return p, ok +} + +// MarkContentProjected drops the in-memory intent and acks the durable row. +// A snapshot mismatch leaves a newer pending head alone. +func (s *Service) MarkContentProjected(ctx context.Context, docID string, snapshotID int64) error { + if s == nil || docID == "" || snapshotID == 0 { + return nil + } + s.mu.Lock() + if p, ok := s.projections[docID]; ok && p.SnapshotID == snapshotID { + delete(s.projections, docID) + } + usePG := s.usingPG() + s.mu.Unlock() + if !usePG { + return nil + } + _, err := s.pool.Exec(ctx, ` + UPDATE document_content_projections + SET status = 'projected', projected_at = now() + WHERE doc_id = $1 AND snapshot_id = $2 AND status = 'pending'`, docID, snapshotID) + return err +} diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index 987694df..5e0cdd22 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -19,14 +19,15 @@ type Service struct { pool *pgxpool.Pool mu sync.Mutex - blobs map[string]*ContentBlob - snapshots map[int64]*Snapshot - versions map[int64]*Version - events map[int64]*ChangeEvent - candidates map[int64]*AgentCandidate - reports map[int64]*ChangeReport - roots map[string]*DocRootPointers - idemp map[string]int64 // idempotency_key -> event id + blobs map[string]*ContentBlob + snapshots map[int64]*Snapshot + versions map[int64]*Version + events map[int64]*ChangeEvent + candidates map[int64]*AgentCandidate + reports map[int64]*ChangeReport + roots map[string]*DocRootPointers + idemp map[string]int64 // idempotency_key -> event id + projections map[string]ContentProjection nextSnapID int64 nextVerID int64 @@ -71,6 +72,7 @@ func NewService(pool *pgxpool.Pool) *Service { reports: map[int64]*ChangeReport{}, roots: map[string]*DocRootPointers{}, idemp: map[string]int64{}, + projections: map[string]ContentProjection{}, LedgerWrite: true, LedgerRead: true, AgentCommit: true, @@ -1079,6 +1081,7 @@ func (s *Service) forgetDocLocked(docID string) { delete(s.reports, id) } } + delete(s.projections, docID) } func (s *Service) manualOverlayLocked(docID string) DiffResult { diff --git a/internal/events/bus.go b/internal/events/bus.go index f9eba70f..ec699316 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -98,8 +98,7 @@ func (b *MemoryBus) Claim(consumer, worker string, n int, now time.Time) ([]*Del continue } env := b.envelopes[id] - head := b.heads[env.AggregateID] - if env.AggregateVersion != 0 && env.AggregateVersion > head+1 { + if !VersionClaimable(b.heads[env.AggregateID], env.AggregateVersion) { continue // gap: do not skip missing versions } token, err := randomToken() @@ -147,7 +146,7 @@ func (b *MemoryBus) Apply(consumer string, claimed *Delivery, fail error) error return fail } head := b.heads[env.AggregateID] - if env.AggregateVersion != 0 && env.AggregateVersion > head+1 { + if !VersionClaimable(head, env.AggregateVersion) { d.Status = "pending" d.LeaseOwner = "" d.FencingToken = "" diff --git a/internal/events/cutover.go b/internal/events/cutover.go index c5078b8a..ce0713cd 100644 --- a/internal/events/cutover.go +++ b/internal/events/cutover.go @@ -12,6 +12,12 @@ import ( const OpsCutoverEnvironment = "dev" +// OpsCutoverPromotionPath is how test and main change consumer modes. +// ApplyOpsCutover stays dev-only. After a dev observation window, copy the +// reviewed mode with an authorized promotion receipt (SQL or migration), first +// on test, then on main. Do not widen ValidateOpsCutover to accept those envs. +const OpsCutoverPromotionPath = "dev-observe -> promotion-receipt on test -> same receipt on main; ApplyOpsCutover rejects non-dev" + // ValidateOpsCutover is the shared-dev gate for cluster mode changes. // test/main, FAV, and unknown consumers are rejected before any write. func ValidateOpsCutover(environment, consumer, mode string) error { diff --git a/internal/events/cutover_test.go b/internal/events/cutover_test.go index 36658e37..e7c16069 100644 --- a/internal/events/cutover_test.go +++ b/internal/events/cutover_test.go @@ -1,6 +1,9 @@ package events -import "testing" +import ( + "strings" + "testing" +) func TestValidateOpsCutoverRejectsFavAndNonDev(t *testing.T) { if err := ValidateOpsCutover("dev", ConsumerNotify, ModeShadow); err != nil { @@ -12,6 +15,9 @@ func TestValidateOpsCutoverRejectsFavAndNonDev(t *testing.T) { if err := ValidateOpsCutover("main", ConsumerNotify, ModeLive); err == nil { t.Fatal("main env must fail") } + if !strings.Contains(OpsCutoverPromotionPath, "test") || !strings.Contains(OpsCutoverPromotionPath, "main") { + t.Fatalf("promotion path must name test and main: %s", OpsCutoverPromotionPath) + } if err := ValidateOpsCutover("dev", ConsumerFAV, ModeOff); err == nil { t.Fatal("fav must fail even at off") } diff --git a/internal/events/dispatcher.go b/internal/events/dispatcher.go index 3939239b..a8b7c379 100644 --- a/internal/events/dispatcher.go +++ b/internal/events/dispatcher.go @@ -36,6 +36,16 @@ type Lease struct { Envelope Envelope } +// VersionClaimable is the gap rule shared by MemoryBus and claimSQL. +// Version 0 is unversioned and always claimable. A versioned event is claimable +// only when it does not skip past the consumer checkpoint (head+1). +func VersionClaimable(head, version int64) bool { + if version == 0 { + return true + } + return version <= head+1 +} + const claimSQL = ` WITH candidates AS ( SELECT d.event_id, d.consumer @@ -62,6 +72,16 @@ WITH candidates AS ( AND ex.aggregate_type = e.aggregate_type AND ex.aggregate_id = e.aggregate_id ) + AND ( + e.aggregate_version = 0 + OR e.aggregate_version <= 1 + COALESCE(( + SELECT c.last_aggregate_version + FROM event_consumer_checkpoints c + WHERE c.consumer = d.consumer + AND c.aggregate_type = e.aggregate_type + AND c.aggregate_id = e.aggregate_id + ), 0) + ) ORDER BY d.next_attempt_at, d.event_id FOR UPDATE OF d SKIP LOCKED LIMIT $2 @@ -230,7 +250,7 @@ func AckLease(ctx context.Context, tx pgx.Tx, l Lease) error { ) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (consumer, aggregate_type, aggregate_id) DO UPDATE SET last_event_id = EXCLUDED.last_event_id, - last_aggregate_version = EXCLUDED.last_aggregate_version, + last_aggregate_version = GREATEST(COALESCE(event_consumer_checkpoints.last_aggregate_version, 0), EXCLUDED.last_aggregate_version), fencing_token = EXCLUDED.fencing_token, updated_at = now() `, l.Consumer, l.AggregateType, l.AggregateID, l.EventID, l.Envelope.AggregateVersion, l.Token); err != nil { diff --git a/internal/events/pg_test.go b/internal/events/pg_test.go index 25fee6ec..65d485b5 100644 --- a/internal/events/pg_test.go +++ b/internal/events/pg_test.go @@ -15,6 +15,18 @@ func TestClaimSQLUsesSkipLockedOnlyForClaim(t *testing.T) { if strings.Count(claimSQL, "SKIP LOCKED") != 1 { t.Fatal("SKIP LOCKED is only for claim") } + if !strings.Contains(claimSQL, "event_consumer_checkpoints") || !strings.Contains(claimSQL, "e.aggregate_version = 0") { + t.Fatal("claim SQL must hold the same aggregate gap as MemoryBus") + } +} + +func TestVersionClaimableMatchesMemoryBusGap(t *testing.T) { + if !VersionClaimable(0, 0) || !VersionClaimable(0, 1) || VersionClaimable(0, 2) { + t.Fatal("head 0 allows unversioned and v1 only") + } + if !VersionClaimable(1, 2) || VersionClaimable(1, 3) { + t.Fatal("head 1 allows v2 and rejects v3") + } } func TestSkipLockedAbsentFromInsertSQL(t *testing.T) { diff --git a/internal/events/replay.go b/internal/events/replay.go index 372d19a3..bbff3e19 100644 --- a/internal/events/replay.go +++ b/internal/events/replay.go @@ -33,7 +33,8 @@ func (p ReplayPlan) Digest() string { EventIDs []string `json:"event_ids"` Reason string `json:"reason"` ApprovalRef string `json:"approval_ref"` - }{p.PlanID, p.Operator, p.Approver, p.Environment, p.ProjectID, p.Consumer, p.EventIDs, p.Reason, p.ApprovalRef}) + DryRun bool `json:"dry_run"` + }{p.PlanID, p.Operator, p.Approver, p.Environment, p.ProjectID, p.Consumer, p.EventIDs, p.Reason, p.ApprovalRef, p.DryRun}) sum := sha256.Sum256(body) return hex.EncodeToString(sum[:]) } diff --git a/internal/events/replay_test.go b/internal/events/replay_test.go index ba126432..122295a6 100644 --- a/internal/events/replay_test.go +++ b/internal/events/replay_test.go @@ -14,8 +14,15 @@ func TestApplyReplayRequiresMatchingDigestAndTwoPerson(t *testing.T) { ProjectID: 9, Consumer: ConsumerNotify, EventIDs: []string{"e1"}, Reason: "repair", ApprovalRef: "ticket-1", DryRun: true, } - digest := p.Digest() + dryDigest := p.Digest() p.DryRun = false + if err := ApplyReplay(p, dryDigest); err == nil { + t.Fatal("dry-run digest must not authorize apply") + } + digest := p.Digest() + if dryDigest == digest { + t.Fatal("digest must change when DryRun changes") + } if err := ApplyReplay(p, digest); err != nil { t.Fatal(err) } diff --git a/internal/events/worker.go b/internal/events/worker.go index 5b5be912..56df8ad3 100644 --- a/internal/events/worker.go +++ b/internal/events/worker.go @@ -3,6 +3,7 @@ package events import ( "context" "errors" + "log/slog" "sync" "sync/atomic" "time" @@ -22,10 +23,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 +153,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 +237,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 @@ -254,6 +262,9 @@ func (d *Dispatcher) handle(ctx context.Context, lease Lease) { _ = tx.Rollback(hctx) if errors.Is(err, ErrStaleFencing) { d.Metrics.Fenced.Add(1) + slog.Warn("events_stale_fencing_ack", + "event_id", lease.EventID, "consumer", lease.Consumer, + "owner", lease.Owner, "attempt", lease.Attempt) return } _ = d.fail(ctx, lease, "TRANSIENT", true) 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/intent/recognizer.go b/internal/intent/recognizer.go index f09345fc..4216cb5f 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -4,10 +4,13 @@ import ( "context" "fmt" "regexp" + "sort" "strings" "time" + "unicode/utf8" "chat-agent-system/internal/maturity" + "chat-agent-system/internal/squad" ) // Recognizer 意图识别器接口 @@ -1123,6 +1126,9 @@ func matchSupplementInfo(input string) *CandidateIntent { } func matchQueryProgress(input string) *CandidateIntent { + if matchTriggerTask(input) != nil { + return nil + } if isWorkflowControlInput(input) || isRunControlInput(input) { return nil } @@ -1168,6 +1174,10 @@ func matchQueryProgress(input string) *CandidateIntent { } func matchTriggerTask(input string) *CandidateIntent { + type phrase struct { + text string + key string + } taskMap := map[string]string{ "需求收集表": "intake", "需求表": "intake", @@ -1180,16 +1190,34 @@ func matchTriggerTask(input string) *CandidateIntent { "报价单": "cdo_quote", "报价": "cdo_quote", } - + phrases := make([]phrase, 0, len(taskMap)+60) for kw, tKey := range taskMap { - if strings.Contains(input, kw) && (strings.Contains(input, "生成") || strings.Contains(input, "做") || strings.Contains(input, "跑") || strings.Contains(input, "出")) { + phrases = append(phrases, phrase{text: kw, key: tKey}) + } + for _, item := range squad.RDevTriggerPhrases() { + phrases = append(phrases, phrase{text: item.Text, key: item.TaskKey}) + } + sort.Slice(phrases, func(i, j int) bool { + li := utf8.RuneCountInString(phrases[i].text) + lj := utf8.RuneCountInString(phrases[j].text) + if li != lj { + return li > lj + } + return phrases[i].text < phrases[j].text + }) + + if !(strings.Contains(input, "生成") || strings.Contains(input, "做") || strings.Contains(input, "跑") || strings.Contains(input, "出")) { + return nil + } + for _, item := range phrases { + if strings.Contains(input, item.text) { return &CandidateIntent{ Intent: IntentTriggerTask, Confidence: 0.86, FilledSlots: map[string]string{ - SlotTargetTask: tKey, + SlotTargetTask: item.key, }, - Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", kw, tKey), + Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", item.text, item.key), } } } diff --git a/internal/intent/recognizer_test.go b/internal/intent/recognizer_test.go index 9ed71335..ef747b2e 100644 --- a/internal/intent/recognizer_test.go +++ b/internal/intent/recognizer_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "chat-agent-system/internal/maturity" "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" ) func TestIntentRecognition_QueryProgress(t *testing.T) { @@ -91,6 +91,43 @@ func TestIntentRecognition_TriggerTask(t *testing.T) { } } +func TestIntentRecognition_RDevProposalAndLongerTitleWins(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "ctx_rdev_trigger"} + + proposal, err := rec.Recognize(context.Background(), "请生成项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + if proposal.TopIntent.FilledSlots[intent.SlotTargetTask] != "rd_proposal" { + t.Fatalf("项目建议书 want rd_proposal got %s", proposal.TopIntent.FilledSlots[intent.SlotTargetTask]) + } + + design, err := rec.Recognize(context.Background(), "生成设计方案", ctx) + if err != nil { + t.Fatal(err) + } + if design.TopIntent.FilledSlots[intent.SlotTargetTask] != "prelim_design" { + t.Fatalf("设计方案 must stay prelim_design, got %s", design.TopIntent.FilledSlots[intent.SlotTargetTask]) + } + + system, err := rec.Recognize(context.Background(), "生成系统设计方案", ctx) + if err != nil { + t.Fatal(err) + } + if system.TopIntent.FilledSlots[intent.SlotTargetTask] != "rd_system_design" { + t.Fatalf("系统设计方案 want rd_system_design got %s", system.TopIntent.FilledSlots[intent.SlotTargetTask]) + } + + intake, err := rec.Recognize(context.Background(), "生成用户需求收集表", ctx) + if err != nil { + t.Fatal(err) + } + if intake.TopIntent.FilledSlots[intent.SlotTargetTask] != "intake" { + t.Fatalf("用户需求收集表 must stay intake, got %s", intake.TopIntent.FilledSlots[intent.SlotTargetTask]) + } +} + func TestIntentRecognition_CasualChat(t *testing.T) { rec := intent.NewRecognizer() res, err := rec.Recognize(context.Background(), "你好,请问你是谁?", nil) 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/activation_official_pg_test.go b/internal/projectinstance/activation_official_pg_test.go index 55d848a1..8596f8c3 100644 --- a/internal/projectinstance/activation_official_pg_test.go +++ b/internal/projectinstance/activation_official_pg_test.go @@ -88,7 +88,7 @@ func TestPG_ActivateOfficialWorkflowRuntimeMetadata(t *testing.T) { SELECT dti.executor_key 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 = 'intake' + WHERE si.workflow_instance_id = $1 AND dti.task_key IN ('intake', 'bd_intake') `, activated.Instance.ID).Scan(&executorKey) if err != nil { t.Fatalf("query runtime metadata: %v", err) 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/pg_integration_test.go b/internal/projectinstance/pg_integration_test.go index e0e71db4..58ef79d6 100644 --- a/internal/projectinstance/pg_integration_test.go +++ b/internal/projectinstance/pg_integration_test.go @@ -96,7 +96,7 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) { } for i := range plan.Stages { for j := range plan.Stages[i].DocTasks { - if plan.Stages[i].DocTasks[j].TaskKey == "intake" { + if plan.Stages[i].DocTasks[j].TaskKey == "intake" || plan.Stages[i].DocTasks[j].TaskKey == "bd_intake" { plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4, } 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..2433ca6a 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) @@ -175,10 +182,7 @@ func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID ConfigSnapshot: in.Plan, CreatedBy: &userID, } - if _, err := s.store.UpsertPlanningInstance(ctx, inst); err != nil { - return nil, err - } - if _, err := s.store.BumpPlanRevision(ctx, projectID, planRev); err != nil { + if _, _, err := s.store.SavePlanWithRevision(ctx, inst, planRev); err != nil { return nil, err } return s.GetSettings(ctx, projectID, userID, isCreator) @@ -215,12 +219,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 +290,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..a2552526 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -2,15 +2,17 @@ package projectinstance import ( "context" + + "chat-agent-system/internal/workflowdef" ) // StageAccess 用户在某项目实例下可访问的阶段与文档任务范围。 type StageAccess struct { - Restricted bool `json:"restricted"` - ActiveStageKey string `json:"active_stage_key,omitempty"` - AllowedStageKeys map[string]bool `json:"allowed_stage_keys,omitempty"` - AllowedTaskKeys map[string]bool `json:"allowed_task_keys,omitempty"` - ViewableTaskKeys map[string]bool `json:"viewable_task_keys,omitempty"` + Restricted bool `json:"restricted"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + AllowedStageKeys map[string]bool `json:"allowed_stage_keys,omitempty"` + AllowedTaskKeys map[string]bool `json:"allowed_task_keys,omitempty"` + ViewableTaskKeys map[string]bool `json:"viewable_task_keys,omitempty"` } // ResolveStageAccess 基于治理实例与三权指派计算阶段 ABAC 范围。 @@ -83,7 +85,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 +102,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 } } @@ -123,7 +131,14 @@ func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { if a == nil || !a.Restricted { return true } - return a.AllowedTaskKeys[taskKey] + return mapHasTaskKey(a.AllowedTaskKeys, taskKey) +} + +func mapHasTaskKey(keys map[string]bool, taskKey string) bool { + if keys[taskKey] || keys[workflowdef.CanonicalTaskKey(taskKey)] || keys[workflowdef.ImplementationTaskKey(taskKey)] { + return true + } + return false } // TaskKeyVisible 判断文档是否对项目成员可见(view 豁免阶段收窄)。 @@ -132,9 +147,9 @@ func (a *StageAccess) TaskKeyVisible(taskKey string) bool { return true } if len(a.ViewableTaskKeys) == 0 { - return a.AllowedTaskKeys[taskKey] + return mapHasTaskKey(a.AllowedTaskKeys, taskKey) } - return a.ViewableTaskKeys[taskKey] + return mapHasTaskKey(a.ViewableTaskKeys, taskKey) } // StageKeyAllowed 判断 stage_key 是否在允许范围内。 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/stage_service.go b/internal/projectinstance/stage_service.go index de580e21..82b6317b 100644 --- a/internal/projectinstance/stage_service.go +++ b/internal/projectinstance/stage_service.go @@ -94,7 +94,7 @@ func (s *Service) AdvanceStage(ctx context.Context, instanceID int64, userID int } } - after, err := s.store.AdvanceStage(ctx, instanceID, in.StageKey, in.ExpectedConfigRevision) + after, err := s.store.AdvanceStage(ctx, instanceID, in.StageKey, in.ExpectedConfigRevision, userID) if err != nil { return nil, err } diff --git a/internal/projectinstance/stage_store.go b/internal/projectinstance/stage_store.go index d82143f5..70b7dbad 100644 --- a/internal/projectinstance/stage_store.go +++ b/internal/projectinstance/stage_store.go @@ -69,7 +69,7 @@ func (s *pgStore) loadInstanceDetail(ctx context.Context, inst *WorkflowInstance return detail, rows.Err() } -func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error) { +func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int, operatorID int64) (*InstanceDetail, error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, err @@ -131,11 +131,9 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s if errors.Is(err, pgx.ErrNoRows) { _, err = tx.Exec(ctx, ` UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1, - completed_at = $2, completed_by = ( - SELECT activated_by FROM project_workflow_instances WHERE id = $1 - ), updated_at = $2 + completed_at = $2, completed_by = $3, updated_at = $2 WHERE id = $1 - `, instanceID, now) + `, instanceID, now, operatorID) if err != nil { return nil, err } diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index d5eb30c1..b43ba424 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -44,16 +44,19 @@ type Store interface { GetGoverningInstanceForAccess(ctx context.Context, projectID int64) (*WorkflowInstance, error) HasLiveInstance(ctx context.Context, projectID int64) (bool, error) UpsertPlanningInstance(ctx context.Context, inst *WorkflowInstance) (*WorkflowInstance, error) + // SavePlanWithRevision 单事务完成 planning upsert + revision bump, + // 避免 SaveWorkflowPlan 两条独立事务间的版本号与 plan 内容错位。 + SavePlanWithRevision(ctx context.Context, inst *WorkflowInstance, expectedPlanRevision int) (*WorkflowInstance, int, error) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) 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) - AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error) + AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int, operatorID int64) (*InstanceDetail, error) MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error } @@ -214,13 +217,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 @@ -249,6 +263,85 @@ func (s *pgStore) UpsertPlanningInstance(ctx context.Context, inst *WorkflowInst return inst, nil } +// SavePlanWithRevision 在单事务内完成 planning 实例 upsert 与 plan revision bump。 +// bump 先执行(带期望版本乐观锁),失败则整个事务回滚,保证 plan 内容与版本号原子一致。 +func (s *pgStore) SavePlanWithRevision(ctx context.Context, inst *WorkflowInstance, expectedPlanRevision int) (*WorkflowInstance, int, error) { + snapRaw, _ := json.Marshal(inst.ConfigSnapshot) + now := time.Now() + if inst.CreatedAt.IsZero() { + inst.CreatedAt = now + } + inst.UpdatedAt = now + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, 0, err + } + defer tx.Rollback(ctx) + + // 先 bump revision(乐观锁),冲突即回滚,不写 plan + var newRev int + err = tx.QueryRow(ctx, ` + UPDATE projects SET workflow_plan_revision = workflow_plan_revision + 1, updated_at = now() + WHERE id = $1 AND workflow_plan_revision = $2 + RETURNING workflow_plan_revision + `, inst.ProjectID, expectedPlanRevision).Scan(&newRev) + if errors.Is(err, pgx.ErrNoRows) { + return nil, 0, ErrPlanRevisionConflict + } + if err != nil { + return nil, 0, err + } + + var existingID int64 + err = tx.QueryRow(ctx, ` + 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) { + 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, 0, 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, $3, 'planning', $4, $5, $6, $7, $8, $9) + RETURNING id + `, inst.ProjectID, inst.WorkflowVersionID, nextNo, inst.ConfigRevision, inst.PlanHash, snapRaw, + inst.CreatedBy, inst.CreatedAt, inst.UpdatedAt).Scan(&inst.ID) + if err != nil { + return nil, 0, err + } + } else if err != nil { + return nil, 0, err + } else { + err = tx.QueryRow(ctx, ` + UPDATE project_workflow_instances SET + workflow_version_id = $2, config_revision = $3, plan_hash = $4, config_snapshot = $5, + updated_at = $6 + WHERE id = $1 AND status = 'planning' + RETURNING id + `, existingID, inst.WorkflowVersionID, inst.ConfigRevision, inst.PlanHash, snapRaw, inst.UpdatedAt).Scan(&inst.ID) + if err != nil { + return nil, 0, err + } + } + + if err := s.replaceInstanceChildren(ctx, tx, inst); err != nil { + return nil, 0, err + } + if err := tx.Commit(ctx); err != nil { + return nil, 0, err + } + return inst, newRev, nil +} + func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst *WorkflowInstance) error { _, err := tx.Exec(ctx, ` DELETE FROM document_duty_assignments WHERE doc_task_instance_id IN ( @@ -296,11 +389,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 +434,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 +526,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 +641,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 +771,7 @@ type activationTokenRec struct { snapshot WorkflowPlan expiresAt time.Time used bool + policyRevision *int64 } func newMemStore() *memStore { @@ -820,13 +941,47 @@ func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInsta return ©Inst, nil } +// SavePlanWithRevision 内存实现:bump 与 upsert 在同一互斥锁内,天然原子。 +func (m *memStore) SavePlanWithRevision(ctx context.Context, inst *WorkflowInstance, expectedPlanRevision int) (*WorkflowInstance, int, error) { + newRev, err := m.BumpPlanRevision(ctx, inst.ProjectID, expectedPlanRevision) + if err != nil { + return nil, 0, err + } + saved, err := m.UpsertPlanningInstance(ctx, inst) + if err != nil { + return nil, 0, err + } + return saved, newRev, nil +} + 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) } @@ -877,7 +1032,7 @@ func (m *memStore) GetInstanceByID(_ context.Context, instanceID int64) (*Instan return nil, ErrInstanceNotFound } -func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error) { +func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey string, expectedRevision int, _ int64) (*InstanceDetail, error) { m.mu.Lock() defer m.mu.Unlock() var inst *WorkflowInstance @@ -973,28 +1128,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..3ff2d8f3 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,13 +69,24 @@ 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: "软件项目自举流程", Description: "S0–S4:章程、现状、需求、架构、风险、路线与就绪评审", StageKey: "S0_boundary", - TaskKey: "software_charter", - OutputDocType: "software_charter", + TaskKey: "sw_software_charter", + OutputDocType: "sw_software_charter", OutputDocTitle: "项目章程", Enabled: true, } @@ -85,8 +96,8 @@ func NewDefaultCatalog() Catalog { Name: "软件交付迭代流程", Description: "D0–D4:变更定义、实施证据、验证、发布与复盘", StageKey: "D0_change_definition", - TaskKey: "change_brief", - OutputDocType: "change_brief", + TaskKey: "sw_change_brief", + OutputDocType: "sw_change_brief", OutputDocTitle: "变更简报", Enabled: true, } @@ -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 new file mode 100644 index 00000000..73bfa6c1 --- /dev/null +++ b/internal/server/admin_system_handlers.go @@ -0,0 +1,105 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "chat-agent-system/internal/sysadmin" +) + +// 系统管理台(一期):全部端点要求系统管理员。 + +func (s *Server) handleAdminSystemOverview(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSystemAdmin(w, r); !ok { + return + } + if s.sysadmin == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") + return + } + writeJSONResponse(w, http.StatusOK, s.sysadmin.Overview(r.Context())) +} + +func (s *Server) handleAdminSystemKB(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSystemAdmin(w, r); !ok { + return + } + if s.sysadmin == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") + return + } + writeJSONResponse(w, http.StatusOK, s.sysadmin.KBInfo()) +} + +func (s *Server) handleAdminSystemFeatures(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSystemAdmin(w, r); !ok { + return + } + if s.sysadmin == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"features": s.sysadmin.Features()}) +} + +func (s *Server) handleAdminSystemSettings(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSystemAdmin(w, r); !ok { + return + } + if s.sysadmin == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") + return + } + settings, err := s.sysadmin.ListSettings(r.Context()) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"settings": settings}) +} + +func (s *Server) handleAdminSystemSettingByKey(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireSystemAdmin(w, r) + if !ok { + return + } + if s.sysadmin == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "sysadmin service unavailable") + return + } + key := r.PathValue("key") + var req struct { + 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.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, "未知的设置键") + case errors.Is(err, sysadmin.ErrSettingReadOnly): + 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: + writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + default: + writeJSONResponse(w, http.StatusOK, setting) + } +} diff --git a/internal/server/admin_system_handlers_test.go b/internal/server/admin_system_handlers_test.go new file mode 100644 index 00000000..fb2e0647 --- /dev/null +++ b/internal/server/admin_system_handlers_test.go @@ -0,0 +1,325 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/sysadmin" + + "github.com/jackc/pgx/v5" +) + +type fakeSettingsStore struct { + mu sync.Mutex + items map[string]struct { + value json.RawMessage + revision int64 + } +} + +func newFakeSettingsStore() *fakeSettingsStore { + return &fakeSettingsStore{items: map[string]struct { + value json.RawMessage + revision int64 + }{}} +} + +func (f *fakeSettingsStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + entry, ok := f.items[key] + if !ok { + return nil, nil, nil, 0, sysadmin.ErrSettingNotFound + } + at := time.Now() + by := int64(1) + 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, int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + 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。 +func registerTestUser(t *testing.T, srv *Server, username string) string { + t.Helper() + if err := srv.AuthService().UpdateConfig(context.Background(), auth.SystemAuthConfig{ + AllowRegistration: true, + RequireApproval: false, + }); err != nil { + t.Fatalf("enable registration: %v", err) + } + body, _ := json.Marshal(map[string]string{ + "username": username, + "password": "PlainPass123", + "name": "普通用户", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("register %s failed: %d %s", username, w.Code, w.Body.String()) + } + + loginBody, _ := json.Marshal(map[string]string{"username": username, "password": "PlainPass123"}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + w = httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("login %s failed: %d %s", username, w.Code, w.Body.String()) + } + var session auth.SessionInfo + if err := json.NewDecoder(w.Body).Decode(&session); err != nil || session.Token == "" { + t.Fatalf("decode login session: %v", err) + } + return session.Token +} + +func doSysadminRequest(t *testing.T, srv *Server, method, path, token, body string) *httptest.ResponseRecorder { + t.Helper() + var req *http.Request + if body != "" { + req = httptest.NewRequest(method, path, bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + if token != "" { + withBearerAuth(req, token) + } + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + return w +} + +func TestAdminSystemEndpoints_RequireSystemAdmin(t *testing.T) { + srv := newTestServer(t) + srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 + plainToken := registerTestUser(t, srv, "plain_sysadmin_probe") + endpoints := []struct { + method string + path string + }{ + {http.MethodGet, "/api/v1/admin/system/overview"}, + {http.MethodGet, "/api/v1/admin/system/kb"}, + {http.MethodGet, "/api/v1/admin/system/features"}, + {http.MethodGet, "/api/v1/admin/system/settings"}, + {http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice"}, + } + for _, ep := range endpoints { + // 未认证 → 401 + if w := doSysadminRequest(t, srv, ep.method, ep.path, "", ""); w.Code != http.StatusUnauthorized { + t.Fatalf("%s %s: expected 401 without auth, got %d", ep.method, ep.path, w.Code) + } + // 非管理员 → 403 + if w := doSysadminRequest(t, srv, ep.method, ep.path, plainToken, ""); w.Code != http.StatusForbidden { + t.Fatalf("%s %s: expected 403 for non-admin, got %d %s", ep.method, ep.path, w.Code, w.Body.String()) + } + } +} + +func TestAdminSystemOverview_AdminGetsCamelCasePayload(t *testing.T) { + srv := newTestServer(t) + srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 + token := loginTestAdmin(t, srv) + w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/overview", token, "") + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + for _, key := range []string{"status", "env", "agentBackend", "databaseReady", "schemaVersion", "taskStoreReady", "kbOnline", "uptimeSeconds"} { + if _, ok := body[key]; !ok { + t.Fatalf("overview missing camelCase key %s: %v", key, body) + } + } + if body["env"] != "test" || body["agentBackend"] != "stub" { + t.Fatalf("unexpected overview payload: %v", body) + } +} + +func TestAdminSystemKB_NoSecretMaterial(t *testing.T) { + srv := newTestServer(t) + srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 + token := loginTestAdmin(t, srv) + w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/kb", token, "") + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + for _, key := range []string{"online", "host", "port", "database", "user", "embeddingModel", "rerankerEnabled", "sslMode"} { + if _, ok := body[key]; !ok { + t.Fatalf("kb payload missing key %s: %v", key, body) + } + } + if bytes.Contains(w.Body.Bytes(), []byte("assword")) { + t.Fatalf("kb payload leaks secret material: %s", w.Body.String()) + } +} + +func TestAdminSystemFeatures_Payload(t *testing.T) { + srv := newTestServer(t) + srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 + token := loginTestAdmin(t, srv) + w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/features", token, "") + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String()) + } + var body struct { + Features []sysadmin.Feature `json:"features"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode features: %v", err) + } + found := false + for _, f := range body.Features { + if f.Key == "FAV_PUBLISHER_ENABLED" && f.Group == "artifact-vault" && f.Source == "env" { + found = true + } + } + if !found { + t.Fatal("features payload must include FAV_PUBLISHER_ENABLED in artifact-vault group") + } +} + +func TestAdminSystemSettings_ListAndUpdate(t *testing.T) { + srv := newTestServer(t) + srv.cfg.AllowInMemory = false // 关闭 legacy 未认证兜底,严格鉴权 + // 用带内存 store 的 sysadmin 服务替换装配产物(无 DB 时 store 为 nil)。 + srv.sysadmin = sysadmin.NewService(srv.cfg, sysadmin.Options{Store: newFakeSettingsStore()}) + token := loginTestAdmin(t, srv) + + w := doSysadminRequest(t, srv, http.MethodGet, "/api/v1/admin/system/settings", token, "") + if w.Code != http.StatusOK { + t.Fatalf("list: expected 200, got %d %s", w.Code, w.Body.String()) + } + var list struct { + Settings []sysadmin.Setting `json:"settings"` + } + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(list.Settings) == 0 { + t.Fatal("expected whitelist settings") + } + + // 合法写 → 200,返回更新后的 setting + w = doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice", token, `{"value":"停机维护"}`) + if w.Code != http.StatusOK { + t.Fatalf("update: expected 200, got %d %s", w.Code, w.Body.String()) + } + var updated sysadmin.Setting + if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil { + t.Fatalf("decode updated: %v", err) + } + if updated.Value != "停机维护" || !updated.Writable { + t.Fatalf("unexpected updated setting: %+v", updated) + } + + // 未知 key → 400 + if w := doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/no.such.key", token, `{"value":"x"}`); w.Code != http.StatusBadRequest { + t.Fatalf("unknown key: expected 400, got %d %s", w.Code, w.Body.String()) + } + + // 类型不匹配 → 400 + if w := doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/ui.read_only_mode", token, `{"value":"yes"}`); w.Code != http.StatusBadRequest { + t.Fatalf("type mismatch: expected 400, got %d %s", w.Code, w.Body.String()) + } +} + +func TestAdminSystemSettings_UpdateWithoutStoreReturns503(t *testing.T) { + srv := newTestServer(t) // 无 DB:sysadmin store 为 nil + token := loginTestAdmin(t, srv) + w := doSysadminRequest(t, srv, http.MethodPut, "/api/v1/admin/system/settings/ui.maintenance_notice", token, `{"value":"x"}`) + if w.Code != http.StatusServiceUnavailable { + 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/auth_handlers.go b/internal/server/auth_handlers.go index 6e0f12df..daf3f93f 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -36,7 +36,7 @@ func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) { return nil, errors.New("missing auth token") } -// handleAuthLogin 用户名密码登录 +// handleAuthLogin 用户名密码登录(限流:连续爆破触发 429) func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` @@ -47,6 +47,17 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { return } + if s.loginLimiter != nil { + key := s.loginLimiter.key(r, req.Username) + if !s.loginLimiter.allow(key) { + writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{ + "error_code": "ERR_LOGIN_RATE_LIMITED", + "message": "登录尝试过于频繁,请稍后再试", + }) + return + } + } + session, err := s.authSvc.Login(r.Context(), req.Username, req.Password) if err != nil { errMsg := err.Error() @@ -62,11 +73,15 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { return } + if s.loginLimiter != nil { + s.loginLimiter.reset(s.loginLimiter.key(r, req.Username)) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(session) } -// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客) +// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客,限流防批量注册) func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { var req auth.RegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -74,6 +89,14 @@ func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { return } + if s.loginLimiter != nil && !s.loginLimiter.allow(s.loginLimiter.key(r, req.Username)) { + writeJSONResponse(w, http.StatusTooManyRequests, map[string]string{ + "error_code": "ERR_REGISTER_RATE_LIMITED", + "message": "注册尝试过于频繁,请稍后再试", + }) + return + } + resp, err := s.authSvc.Register(r.Context(), req) if err != nil { writeSafeError(w, r, http.StatusBadRequest, err.Error()) @@ -120,12 +143,17 @@ func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) } -// handleAuthMe 获取当前登录用户身份 +// handleAuthMe 返回本请求中间件已经核过的用户。 +// 不再第二次 ResolveUserID + GetUserByID。撤销和停用在下一次请求的中间件生效,不把身份缓存在请求之外。 func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { - user, err := s.authenticateUser(r) - if err != nil { - writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期") - return + user := s.principalFromRequest(r) + if user == nil { + var err error + user, err = s.authenticateUser(r) + if err != nil || user == nil { + writeSafeError(w, r, http.StatusUnauthorized, "未登录或登录已过期") + return + } } env := "dev" diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go index b33deda3..064c7337 100644 --- a/internal/server/auth_handlers_test.go +++ b/internal/server/auth_handlers_test.go @@ -130,6 +130,52 @@ func TestAuthHandlers_LoginAndMe(t *testing.T) { _ = caps // memory mode: empty slice is valid } +func TestAuthMe_LogoutNextRequestIs401(t *testing.T) { + ts, _ := setupTestServer(t) + body, _ := json.Marshal(map[string]string{"username": "admin", "password": "admin123"}) + loginResp, err := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer loginResp.Body.Close() + if loginResp.StatusCode != http.StatusOK { + t.Fatalf("login: %d", loginResp.StatusCode) + } + var session auth.SessionInfo + if err := json.NewDecoder(loginResp.Body).Decode(&session); err != nil { + t.Fatal(err) + } + + me := func() int { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+session.Token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + return resp.StatusCode + } + if code := me(); code != http.StatusOK { + t.Fatalf("me before logout: %d", code) + } + + logoutReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/auth/logout", nil) + logoutReq.Header.Set("Authorization", "Bearer "+session.Token) + logoutResp, err := http.DefaultClient.Do(logoutReq) + if err != nil { + t.Fatal(err) + } + defer logoutResp.Body.Close() + if logoutResp.StatusCode != http.StatusOK { + t.Fatalf("logout: %d", logoutResp.StatusCode) + } + if code := me(); code != http.StatusUnauthorized { + t.Fatalf("me after logout must be 401, got %d", code) + } +} + func TestAuthHandlers_RegisterAndApproval(t *testing.T) { ts, authSvc := setupTestServer(t) diff --git a/internal/server/auth_helpers.go b/internal/server/auth_helpers.go index a1090ee3..407f2133 100644 --- a/internal/server/auth_helpers.go +++ b/internal/server/auth_helpers.go @@ -8,6 +8,7 @@ import ( "chat-agent-system/internal/auth" "chat-agent-system/internal/authz" "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docgov" ) // failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。 @@ -135,3 +136,65 @@ func (s *Server) requireProjectAccess(w http.ResponseWriter, r *http.Request, pr } return user, true } + +// requireDocGovDocAccess 校验当前用户可访问指定受控文档(经 doc_id 解析 project_id 后做项目成员校验)。 +func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request, docID string) (*auth.UserAccount, bool) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return nil, false + } + if s.gov == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{ + "error_code": "ERR_DOCGOV_UNAVAILABLE", + "message": "docgov engine not initialized", + }) + return nil, false + } + if s.isSystemAdminUser(r.Context(), user) { + return user, true + } + doc, err := s.gov.GetDocument(r.Context(), docID) + if err != nil || doc == nil { + writeJSONResponse(w, http.StatusNotFound, map[string]string{ + "error_code": "ERR_DOCUMENT_NOT_FOUND", + "message": "文档不存在或无权限访问", + }) + return nil, false + } + if doc.ProjectID <= 0 { + // 无项目归属的文档仅管理员可见 + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权访问该文档", + }) + return nil, false + } + if err := s.authorizer.RequireProjectAccess(r.Context(), doc.ProjectID, user.ID); err != nil { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权访问该文档所属项目", + }) + return nil, false + } + return user, true +} + +// resolveDocGovRole 服务端按文档职责分配解析 docgov 角色,不信任客户端自报角色。 +func (s *Server) resolveDocGovRole(ctx context.Context, docID string, user *auth.UserAccount) docgov.UserRole { + if user == nil || s.gov == nil { + return docgov.UserRole("") + } + doc, err := s.gov.GetDocument(ctx, docID) + if err != nil || doc == nil { + return docgov.UserRole("") + } + switch user.ID { + case doc.AuthorID: + return docgov.RoleAuthor + case doc.ReviewerID: + return docgov.RoleReviewer + case doc.ApproverID: + return docgov.RoleApprover + } + return docgov.UserRole("") +} diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go index 98c5c0b6..08dc9d82 100644 --- a/internal/server/auth_middleware.go +++ b/internal/server/auth_middleware.go @@ -1,6 +1,7 @@ package server import ( + "log/slog" "net/http" "strings" ) @@ -48,6 +49,9 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { user, err := s.authenticateUser(r) if (err != nil || user == nil) && s.allowLegacyTestPrincipal() { if fallback, fbErr := s.authSvc.GetUserByID(r.Context(), 1); fbErr == nil && fallback != nil { + slog.Warn("legacy_test_principal_fallback", + "path", r.URL.Path, "method", r.Method, + "remote_addr", r.RemoteAddr, "fallback_user_id", fallback.ID) user = fallback } } diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index 36a7d959..059d9cb6 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -19,7 +19,7 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth return } sess.UserCtx.DocumentSummaryReader = nil - projectID := sess.ProjectID + projectID := sess.Project() userID := int64(0) if user != nil { userID = user.ID 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..9bf74e80 --- /dev/null +++ b/internal/server/chat_session_ownership_test.go @@ -0,0 +1,136 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "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()) + } +} + +func TestListChatSessions_CrossUserIs403(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} + svc := bizdev.MustNewService(t, cfg) + handler := server.NewServer(cfg, svc).Routes() + adminToken := loginTestAdmin(t, handler, cfg) + suffix := time.Now().Format("150405.000") + victimToken := createAndLoginUser(t, handler, adminToken, "list_victim_"+suffix, "pass12345") + attackerToken := createAndLoginUser(t, handler, adminToken, "list_attacker_"+suffix, "pass12345") + victimID := fetchAuthUserID(t, handler, victimToken) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/sessions?user_id="+strconv.FormatInt(victimID, 10), nil) + withBearerAuth(req, attackerToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("cross-user session list expected 403, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/chat_turn_api.go b/internal/server/chat_turn_api.go index cb8898e7..17392c79 100644 --- a/internal/server/chat_turn_api.go +++ b/internal/server/chat_turn_api.go @@ -10,6 +10,10 @@ import ( // handleGetChatTurn 查询回合状态(断线续接) func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } clientTurnID := r.PathValue("client_turn_id") sessionID := r.URL.Query().Get("session_id") if clientTurnID == "" || sessionID == "" { @@ -23,6 +27,11 @@ func (s *Server) handleGetChatTurn(w http.ResponseWriter, r *http.Request) { return } + // 归属校验:只允许查询本人会话的回合状态 + if !s.assertSessionOwnedByUser(w, r, sessionID, user.ID) { + return + } + rec, err := ctxMgr.GetChatTurn(r.Context(), sessionID, clientTurnID) if errors.Is(err, ctxmgr.ErrChatTurnNotFound) { writeSafeError(w, r, http.StatusNotFound, "turn not found") 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/cors.go b/internal/server/cors.go index 277e7521..01d4ac26 100644 --- a/internal/server/cors.go +++ b/internal/server/cors.go @@ -5,6 +5,20 @@ import ( "strings" ) +// securityCSP keeps scripts and fonts on this origin. style-src allows inline +// styles because KaTeX and Mermaid inject them into the page. connect-src +// 'self' covers same-origin JSON and SSE. frame-ancestors none matches +// X-Frame-Options DENY. +const securityCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" + +func applySecurityHeaders(w http.ResponseWriter) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set("Content-Security-Policy", securityCSP) +} + func applyCORSHeaders(w http.ResponseWriter, r *http.Request, allowedOrigins []string) { origin := strings.TrimSpace(r.Header.Get("Origin")) if origin == "" || len(allowedOrigins) == 0 { diff --git a/internal/server/docgov_comment_audit_nonmember_pg_test.go b/internal/server/docgov_comment_audit_nonmember_pg_test.go new file mode 100644 index 00000000..a96e152c --- /dev/null +++ b/internal/server/docgov_comment_audit_nonmember_pg_test.go @@ -0,0 +1,103 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/authz" + "chat-agent-system/internal/bizdev" +) + +// 批注与审计的项目成员检查必须走 Postgres 授权器。 +// 内存授权器在无库时把任意正用户 id 视为成员,不能用它证明 HTTP 负例。 +func TestDocGovCommentsAndAudit_NonMemberIs403_PG(t *testing.T) { + cfg, pool := pgConfigFromTestDSN(t) + defer pool.Close() + cfg.AuthRegistrationEnabled = true + cfg.AuthRequireApproval = false + + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + srv.authorizer = authz.NewAuthorizer(pool) + + ctx := context.Background() + var projectID int64 + if err := pool.QueryRow(ctx, `SELECT id FROM projects ORDER BY id ASC LIMIT 1`).Scan(&projectID); err != nil || projectID <= 0 { + t.Skip("no projects for comment/audit negative test") + } + + docID := fmt.Sprintf("doc_h2_nonmember_%d", time.Now().UnixNano()) + if _, err := pool.Exec(ctx, ` + INSERT INTO document_records (doc_id, project_id, task_key, title) + VALUES ($1, $2, 'intake', 'H2 非成员负例') + `, docID, projectID); err != nil { + t.Fatalf("insert document: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE doc_id = $1`, docID) + }) + + uname := fmt.Sprintf("outsider_h2_%d", time.Now().UnixNano()) + pass := "OutsiderPass123!" + regBody, _ := json.Marshal(map[string]string{ + "username": uname, + "password": pass, + "name": "局外人", + }) + regReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody)) + regW := httptest.NewRecorder() + srv.Routes().ServeHTTP(regW, regReq) + if regW.Code != http.StatusOK && regW.Code != http.StatusCreated { + t.Fatalf("register outsider: %d %s", regW.Code, regW.Body.String()) + } + + loginBody, _ := json.Marshal(map[string]string{"username": uname, "password": pass}) + loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + loginW := httptest.NewRecorder() + srv.Routes().ServeHTTP(loginW, loginReq) + if loginW.Code != http.StatusOK { + t.Fatalf("outsider login: %d %s", loginW.Code, loginW.Body.String()) + } + var session auth.SessionInfo + if err := json.NewDecoder(loginW.Body).Decode(&session); err != nil || session.Token == "" || session.User == nil { + t.Fatalf("decode outsider session: %v body=%s", err, loginW.Body.String()) + } + if err := srv.authorizer.RequireProjectAccess(ctx, projectID, session.User.ID); err != authz.ErrForbidden { + t.Fatalf("outsider must not be a member of project %d: %v", projectID, err) + } + + cases := []struct { + name string + method string + target string + body []byte + }{ + {name: "list comments", method: http.MethodGet, target: "/api/v1/docgov/comments?doc_id=" + url.QueryEscape(docID)}, + {name: "list audit", method: http.MethodGet, target: "/api/v1/docgov/audit-logs?doc_id=" + url.QueryEscape(docID)}, + { + name: "add comment", + method: http.MethodPost, + target: "/api/v1/docgov/comments", + body: []byte(`{"doc_id":"` + docID + `","content":"should-fail"}`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.target, bytes.NewReader(tc.body)) + withBearerAuth(req, session.Token) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("%s want 403 got %d %s", tc.name, w.Code, w.Body.String()) + } + }) + } +} diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index 489989c6..9001b19d 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "strconv" "strings" @@ -270,9 +271,10 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re return } - // 3) Project fact-source AFTER ledger commit. Controlled docs use SaveDocumentContent so - // author_last_edited / audit trail stay consistent; failure → DOCUMENT_PROJECTION_FAILED - // (ledger remains authoritative; same Idempotency-Key retry re-projects). + // 3) Project fact-source AFTER ledger commit. The projection intent was inserted in the + // same transaction as the ledger (document_content_projections). Failure keeps that row + // pending and returns DOCUMENT_PROJECTION_FAILED. The same Idempotency-Key retries + // projection without a second revision. Only a successful projection acks the row. if kind == "controlled" && s.gov != nil { if _, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{ DocID: docID, @@ -315,6 +317,11 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re return } } + if res.State.HeadSnapshotID != 0 { + if markErr := rev.MarkContentProjected(r.Context(), docID, res.State.HeadSnapshotID); markErr != nil { + slog.Warn("content projection ack failed", "doc_id", docID, "snapshot_id", res.State.HeadSnapshotID, "err", markErr) + } + } writeJSONResponse(w, http.StatusOK, res) } diff --git a/internal/server/document_revision_patch_order_test.go b/internal/server/document_revision_patch_order_test.go index 8875e86b..583535c0 100644 --- a/internal/server/document_revision_patch_order_test.go +++ b/internal/server/document_revision_patch_order_test.go @@ -222,3 +222,76 @@ func assertHumanLeaseReleasedSquadCanAcquire(t *testing.T, srv *Server, docID st t.Fatal(err) } } + +func TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit(t *testing.T) { + srv := newTestServer(t) + ctx := context.Background() + dataRoot := srv.dataRoot() + blocker := filepath.Join(dataRoot, "proj-retry-block") + if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil { + t.Fatal(err) + } + workDir := filepath.Join(blocker, "ws-retry") + docID := "doc_proj1_patch_retry" + + if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { + t.Fatal(err) + } + st, err := srv.rev.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) + if err != nil { + t.Fatal(err) + } + + token := loginTestAdmin(t, srv) + body := `{"markdown":"# base\n\nbody\n\nedited by human\n"}` + patch := func() *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", bytes.NewReader([]byte(body))) + withBearerAuth(req, token) + req.Header.Set("Idempotency-Key", "patch-retry-k1") + req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + return w + } + + first := patch() + if first.Code != http.StatusInternalServerError { + t.Fatalf("first projection want 500 got %d %s", first.Code, first.Body.String()) + } + pending, ok := srv.rev.ContentProjectionPending(docID) + if !ok { + t.Fatal("ledger commit must leave a content projection pending") + } + afterFail, err := srv.rev.GetRevisionState(ctx, docID) + if err != nil { + t.Fatal(err) + } + if afterFail.HeadSnapshotID != pending.SnapshotID { + t.Fatalf("pending snapshot %d != ledger head %d", pending.SnapshotID, afterFail.HeadSnapshotID) + } + + if err := os.Remove(blocker); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatal(err) + } + second := patch() + if second.Code != http.StatusOK { + t.Fatalf("retry want 200 got %d %s", second.Code, second.Body.String()) + } + afterRetry, err := srv.rev.GetRevisionState(ctx, docID) + if err != nil { + t.Fatal(err) + } + if afterRetry.HeadRevisionNo != afterFail.HeadRevisionNo || afterRetry.HeadSnapshotID != afterFail.HeadSnapshotID { + t.Fatalf("retry must not write a second revision: fail=%+v retry=%+v", afterFail, afterRetry) + } + if _, still := srv.rev.ContentProjectionPending(docID); still { + t.Fatal("successful projection must ack the pending row") + } + if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil { + t.Fatalf("retry must write output.md: %v", err) + } +} diff --git a/internal/server/e2e_workflow_governance_test.go b/internal/server/e2e_workflow_governance_test.go index fd88aada..b503fae6 100644 --- a/internal/server/e2e_workflow_governance_test.go +++ b/internal/server/e2e_workflow_governance_test.go @@ -101,9 +101,9 @@ func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) { var activated projectinstance.ActivateResult _ = json.Unmarshal(recActivate.Body.Bytes(), &activated) - docID := fmt.Sprintf("doc_proj%d_test_manager", projectID) + docID := fmt.Sprintf("doc_proj%d_tm_system_verification", projectID) docEngine := bizdev.Snapshot(srv.BizdevService()).Gov - _, err = docEngine.CreateOrGetDocument(ctx, docID, projectID, "test_manager", "系统验证方案及计划", "system_verification_planning", "", + _, err = docEngine.CreateOrGetDocument(ctx, docID, projectID, "tm_system_verification", "系统验证方案及计划", "system_verification_planning", "", creatorID, "Author", reviewerID, "Reviewer", approverID, "Approver") if err != nil { t.Fatalf("create doc: %v", err) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 130846a6..f7d333d7 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"` @@ -84,27 +86,47 @@ type TaskRunRequestDTO struct { } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - resp, code := s.buildHealthResponse(false) + resp, code := s.buildHealthResponse(r.Context(), false) writeJSONResponse(w, code, resp) } func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) { - resp, code := s.buildHealthResponse(true) + resp, code := s.buildHealthResponse(r.Context(), true) writeJSONResponse(w, code, resp) } -func (s *Server) buildHealthResponse(strict bool) (HealthResponse, int) { +type healthSnapshot struct { + at time.Time + strict bool + resp HealthResponse + code int + ok bool +} + +func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthResponse, int) { + const healthCacheTTL = 2 * time.Second + s.healthMu.Lock() + if s.healthCache.ok && s.healthCache.strict == strict && time.Since(s.healthCache.at) < healthCacheTTL { + resp, code := s.healthCache.resp, s.healthCache.code + s.healthMu.Unlock() + return resp, code + } + s.healthMu.Unlock() + + dbCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + dbReady := s.svc != nil && s.svc.DatabaseReady() schemaVersion := int64(0) if s.svc != nil { if dbReady { - schemaVersion = s.svc.LiveSchemaVersion(context.Background()) + schemaVersion = s.svc.LiveSchemaVersion(dbCtx) } else { schemaVersion = s.svc.SchemaVersion() } } schemaReady := schemaVersion > 0 || (s.cfg != nil && s.cfg.AllowInMemory) - taskStoreReady := taskrun.TablesReady(context.Background(), s.pool) + taskStoreReady := taskrun.TablesReady(dbCtx, s.pool) status := "ok" code := http.StatusOK @@ -120,7 +142,7 @@ func (s *Server) buildHealthResponse(strict bool) (HealthResponse, int) { kbReady := s.kb != nil && s.kb.Online() - return HealthResponse{ + resp := HealthResponse{ Status: status, Env: s.cfg.Env, AgentBackend: s.cfg.AgentBackend, @@ -131,7 +153,11 @@ func (s *Server) buildHealthResponse(strict bool) (HealthResponse, int) { SchemaVersion: schemaVersion, SchemaReady: schemaReady, TaskStoreReady: taskStoreReady, - }, code + } + s.healthMu.Lock() + s.healthCache = healthSnapshot{at: time.Now(), strict: strict, resp: resp, code: code, ok: true} + s.healthMu.Unlock() + return resp, code } func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { @@ -141,8 +167,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(r.Context(), 0, sessionID, "", "", "", "", "", "", "", "") + if err != nil || sess == nil || sess.UserCtx == nil { writeSafeError(w, r, http.StatusNotFound, "session or project context not found") return } @@ -167,7 +193,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } req.WorkDir = "" - sess := s.getOrCreateSession( + sess, err := s.getOrCreateSession( + r.Context(), + user.ID, req.SessionID, req.ProjectName, req.CustomerName, @@ -178,18 +206,28 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - sess.UserID = user.ID - s.hydrateVisibleHistory(r.Context(), sess, user.ID) - if req.ProjectID > 0 { - sess.ProjectID = req.ProjectID + 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 } - if sess.ProjectID > 0 { - if !s.enforceChatProjectAccess(w, r, user, sess.ProjectID) { + s.hydrateVisibleHistory(r.Context(), sess, user.ID) + if req.ProjectID > 0 && req.ProjectID != sess.Project() { + sess.SetProjectID(req.ProjectID) + } + if sess.Project() > 0 { + if !s.enforceChatProjectAccess(w, r, user, sess.Project()) { return } } s.enrichSessionWorkflowStage(r.Context(), sess, user.ID) - if sess.ProjectID > 0 && !s.allowActiveStageChatAccess(r.Context(), sess.ProjectID, user.ID) { + if sess.Project() > 0 && !s.allowActiveStageChatAccess(r.Context(), sess.Project(), user.ID) { s.writeWorkflowAccessDenied(w) return } @@ -256,6 +294,10 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { }) } + // 同步任务可能远超全局 WriteTimeout(120s)。先把本响应的写期限拉长, + // 否则业务已执行完但客户端收不到结果、重试会表现为重复执行。 + extendSyncTaskWriteDeadline(w) + // 1. 如果指定了 stage,则执行成熟度阶段编排 if req.Stage != "" { stageRes, err := s.taskUseCase().ExecuteStage(r.Context(), bizdev.StageRunRequest{ @@ -413,14 +455,26 @@ func writeSafeError(w http.ResponseWriter, r *http.Request, status int, legacy s // ----------------------------------------------------------------------------- // handleGetUserContext 获取单用户私有上下文与个性化偏好 +// user_id 仅允许等于当前认证用户本人(或系统管理员),不再默认回落 user id=1。 func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } userIDStr := r.URL.Query().Get("user_id") projectIDStr := r.URL.Query().Get("project_id") sessionID := r.URL.Query().Get("session_id") userID, _ := strconv.ParseInt(userIDStr, 10, 64) if userID <= 0 { - userID = 1 + userID = user.ID + } + if userID != user.ID && !s.isSystemAdminUser(r.Context(), user) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权查询其他用户的上下文", + }) + return } projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) @@ -530,6 +584,10 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request) return } + if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { + return + } + doc, err := s.gov.GetDocument(r.Context(), docID) if err != nil { writeSafeError(w, r, http.StatusNotFound, "get document failed: "+err.Error()) @@ -569,7 +627,7 @@ func (s *Server) handleDocGovClaim(w http.ResponseWriter, r *http.Request) { return } - doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, docgov.UserRole(req.UserRole), user.Name) + doc, err := s.gov.RecordDocumentViewed(r.Context(), req.DocID, user.ID, s.resolveDocGovRole(r.Context(), req.DocID, user), user.Name) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "claim document failed: "+err.Error()) return @@ -734,6 +792,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 +872,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 +884,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 } @@ -782,6 +912,10 @@ func (s *Server) handleDocGovListComments(w http.ResponseWriter, r *http.Request return } + if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { + return + } + comments, err := s.gov.ListComments(r.Context(), docID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list comments failed: "+err.Error()) @@ -803,6 +937,7 @@ type AddCommentRequestDTO struct { } // handleDocGovAddComment 审核人/批准人/编制人新增章节批注或修改建议 +// actor 身份一律取认证 principal,禁止请求体伪造 user_id/user_name/user_role。 func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) { var req AddCommentRequestDTO if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -815,14 +950,19 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request) return } + user, ok := s.requireDocGovDocAccess(w, r, req.DocID) + if !ok { + return + } + cType := docgov.CommentType(req.CommentType) if cType == "" { cType = docgov.CommentGeneral } comment, err := s.gov.AddComment( - r.Context(), req.DocID, req.UserID, req.UserName, - docgov.UserRole(req.UserRole), req.TargetSection, cType, req.Content, + r.Context(), req.DocID, user.ID, user.Name, + s.resolveDocGovRole(r.Context(), req.DocID, user), req.TargetSection, cType, req.Content, ) if err != nil { writeDocGovError(w, err) @@ -845,6 +985,10 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques return } + if _, ok := s.requireDocGovDocAccess(w, r, docID); !ok { + return + } + logs, err := s.gov.ListAuditLogs(r.Context(), docID) if err != nil { writeSafeError(w, r, http.StatusInternalServerError, "list audit logs failed: "+err.Error()) @@ -859,14 +1003,31 @@ func (s *Server) handleDocGovListAuditLogs(w http.ResponseWriter, r *http.Reques // ----------------------------------------------------------------------------- // handleListChatSessions 分页查询用户/项目关联的历史会话列表 +// user_id 仅允许等于当前认证用户本人(或系统管理员),防止水平越权枚举他人会话。 func (s *Server) handleListChatSessions(w http.ResponseWriter, r *http.Request) { if s.ctxMgr == nil { writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + userIDStr := r.URL.Query().Get("user_id") userID, _ := strconv.ParseInt(userIDStr, 10, 64) + if userID > 0 && userID != user.ID && !s.isSystemAdminUser(r.Context(), user) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权查询其他用户的会话列表", + }) + return + } + // 未显式传 user_id 时默认查本人,而不是任意/默认用户 + if userID <= 0 { + userID = user.ID + } projectIDStr := r.URL.Query().Get("project_id") projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) @@ -908,6 +1069,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 +1083,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 } @@ -1080,7 +1253,8 @@ type LeaseRequestDTO struct { } func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requirePrincipal(w, r); !ok { + user, ok := s.requirePrincipal(w, r) + if !ok { return } var req LeaseRequestDTO @@ -1092,6 +1266,13 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized") return } + if docgov.LeaseHolderType(req.HolderType) == docgov.LeaseHolderHuman { + if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, docgov.RoleAuthor); err != nil { + writeDocGovError(w, err) + return + } + req.HolderID = fmt.Sprintf("%d", user.ID) + } workDir, werr := s.resolver.ResolveDocWorkDir(r.Context(), req.DocID) if werr != nil { workDir = "" diff --git a/internal/server/login_rate_limit.go b/internal/server/login_rate_limit.go new file mode 100644 index 00000000..fcd66983 --- /dev/null +++ b/internal/server/login_rate_limit.go @@ -0,0 +1,80 @@ +package server + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +// loginRateLimiter 基于 IP+用户名的滑动窗口登录限流器(纯 stdlib,防爆破/撞库)。 +// 窗口内超过 maxAttempts 次尝试即拒绝,窗口过期后自动重置。 +type loginRateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + maxAttempts int + window time.Duration +} + +func newLoginRateLimiter(maxAttempts int, window time.Duration) *loginRateLimiter { + if maxAttempts <= 0 { + maxAttempts = 10 + } + if window <= 0 { + window = 5 * time.Minute + } + return &loginRateLimiter{ + attempts: make(map[string][]time.Time), + maxAttempts: maxAttempts, + window: window, + } +} + +// allow 判断 key 本次尝试是否放行;放行则记录一次尝试。 +func (l *loginRateLimiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + cutoff := now.Add(-l.window) + + // 滑出窗口的尝试丢弃 + ts := l.attempts[key] + kept := ts[:0] + for _, t := range ts { + if t.After(cutoff) { + kept = append(kept, t) + } + } + if len(kept) >= l.maxAttempts { + l.attempts[key] = kept + return false + } + l.attempts[key] = append(kept, now) + return true +} + +// reset 认证成功后清空该 key 的计数(避免正常用户被自己的历史失败累积误伤)。 +func (l *loginRateLimiter) reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.attempts, key) +} + +// clientIP 取客户端 IP(优先 X-Forwarded-For 首跳,回落 RemoteAddr)。 +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if first, _, _ := strings.Cut(xff, ","); strings.TrimSpace(first) != "" { + return strings.TrimSpace(first) + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func (l *loginRateLimiter) key(r *http.Request, username string) string { + return clientIP(r) + "|" + strings.ToLower(strings.TrimSpace(username)) +} diff --git a/internal/server/login_rate_limit_test.go b/internal/server/login_rate_limit_test.go new file mode 100644 index 00000000..a0cbbebe --- /dev/null +++ b/internal/server/login_rate_limit_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "testing" + "time" +) + +func TestLoginRateLimiter_BlocksAfterMaxAttempts(t *testing.T) { + l := newLoginRateLimiter(3, time.Minute) + key := "1.2.3.4|alice" + for i := 0; i < 3; i++ { + if !l.allow(key) { + t.Fatalf("attempt %d should be allowed", i+1) + } + } + if l.allow(key) { + t.Fatalf("4th attempt within window should be blocked") + } +} + +func TestLoginRateLimiter_ResetClearsWindow(t *testing.T) { + l := newLoginRateLimiter(2, time.Minute) + key := "1.2.3.4|bob" + l.allow(key) + l.allow(key) + if l.allow(key) { + t.Fatalf("should be blocked before reset") + } + l.reset(key) + if !l.allow(key) { + t.Fatalf("should be allowed after reset") + } +} + +func TestLoginRateLimiter_WindowExpiry(t *testing.T) { + l := newLoginRateLimiter(1, 10*time.Millisecond) + key := "1.2.3.4|carol" + l.allow(key) + if l.allow(key) { + t.Fatalf("should be blocked within window") + } + time.Sleep(20 * time.Millisecond) + if !l.allow(key) { + t.Fatalf("should be allowed after window expiry") + } +} 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/security_p0_test.go b/internal/server/security_p0_test.go index dbc02c66..1fe2326d 100644 --- a/internal/server/security_p0_test.go +++ b/internal/server/security_p0_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "chat-agent-system/internal/bizdev" @@ -150,3 +151,31 @@ func TestP0_ReconcileRejectsClientWorkDir(t *testing.T) { t.Fatalf("anonymous reconcile must be 401, got %d", rec.Code) } } + +func TestP0_SecurityHeaders(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", Env: "dev"} + srv := newServerWithMemoryIAM(t, cfg) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("nosniff = %q", got) + } + if got := rec.Header().Get("X-Frame-Options"); got != "DENY" { + t.Fatalf("frame options = %q", got) + } + if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" { + t.Fatalf("referrer = %q", got) + } + csp := rec.Header().Get("Content-Security-Policy") + for _, want := range []string{"default-src 'self'", "script-src 'self'", "font-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} { + if !strings.Contains(csp, want) { + t.Fatalf("csp %q missing %s", csp, want) + } + } + if strings.Contains(csp, "unsafe-eval") || strings.Contains(csp, "*") { + t.Fatalf("csp must not allow eval or wildcard: %s", csp) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 480a74ca..78447c65 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,9 +4,6 @@ import ( "context" "fmt" "net/http" - "os" - "path/filepath" - "strings" "sync" "time" @@ -31,6 +28,7 @@ import ( "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/storage" + "chat-agent-system/internal/sysadmin" "chat-agent-system/internal/taskrun" "chat-agent-system/internal/workflowdef" "chat-agent-system/internal/workspace" @@ -68,16 +66,22 @@ type Server struct { pool *pgxpool.Pool freeDoc *freedoc.Service kb *kb.Client + sysadmin *sysadmin.Service httpServer *http.Server intentPlans intentflow.Service planAccessFn func(ctx context.Context, projectID, userID int64) error planSessionOwnerFn func(ctx context.Context, sessionID string) (int64, error) // FAV stubs (U7 incremental): nil / off unless FAV_PUBLISHER_ENABLED. - vaultReg artifactvault.Registry - vaultStore *artifactvault.FilesystemStore - vaultPub *artifactvault.Publisher - vaultPublisherStarted int32 + vaultReg artifactvault.Registry + vaultStore *artifactvault.FilesystemStore + vaultPub *artifactvault.Publisher + vaultPublisherStarted int32 + vaultStop chan struct{} + reminderStop chan struct{} + loginLimiter *loginRateLimiter + healthMu sync.Mutex + healthCache healthSnapshot } // NewFromRuntime attaches the HTTP adapter to an already-wired composition root. @@ -111,7 +115,10 @@ func NewFromRuntime(rt *app.Runtime) *Server { pool: rt.Pool, freeDoc: rt.FreeDoc, kb: rt.KB, + sysadmin: rt.SysAdmin, intentPlans: rt.IntentPlans, + // 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口 + loginLimiter: newLoginRateLimiter(10, 5*time.Minute), } s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz()) s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog) @@ -276,6 +283,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) @@ -323,62 +331,24 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /api/v1/admin/config", s.handleAdminConfig) mux.HandleFunc("PUT /api/v1/admin/config", s.handleAdminConfig) + // 9.1 系统管理台(一期):只读聚合 + 白名单设置 + mux.HandleFunc("GET /api/v1/admin/system/overview", s.handleAdminSystemOverview) + mux.HandleFunc("GET /api/v1/admin/system/kb", s.handleAdminSystemKB) + mux.HandleFunc("GET /api/v1/admin/system/features", s.handleAdminSystemFeatures) + mux.HandleFunc("GET /api/v1/admin/system/settings", s.handleAdminSystemSettings) + mux.HandleFunc("PUT /api/v1/admin/system/settings/{key}", s.handleAdminSystemSettingByKey) + // 10. 现代 SPA 前端页面与静态资产托管 mux.HandleFunc("GET /", s.serveSPA) return s.corsMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux))) } -// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退 -func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/") { - http.NotFound(w, r) - return - } - - candidates := []string{ - "web/dist", - "/app/web/dist", - "../web/dist", - "../../web/dist", - filepath.Join(s.cfg.DataDir, "web/dist"), - filepath.Join(s.cfg.DataDir, "../web/dist"), - } - - var distDir string - for _, dir := range candidates { - if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() { - distDir = dir - break - } - } - - if distDir == "" { - http.NotFound(w, r) - return - } - - reqPath := filepath.Clean(r.URL.Path) - if reqPath == "/" { - http.ServeFile(w, r, filepath.Join(distDir, "index.html")) - return - } - - targetFile := filepath.Join(distDir, reqPath) - if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() { - http.ServeFile(w, r, targetFile) - return - } - - // SPA Fallback 默认回退 index.html - http.ServeFile(w, r, filepath.Join(distDir, "index.html")) -} - // Start 启动 HTTP 守护进程 func (s *Server) Start() error { - if s.rt != nil { - _ = s.rt.Start(context.Background()) - } + // Workers are started by cmd/server before Listen. Runtime.Start is + // idempotent, but calling it here swallowed the first error and raced + // with main. Do not start again. addr := fmt.Sprintf("%s:%d", s.cfg.HTTPHost, s.cfg.HTTPPort) s.httpServer = &http.Server{ Addr: addr, @@ -394,6 +364,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) } @@ -420,7 +398,7 @@ func (s *Server) cleanStaleSessionsLocked(ttl time.Duration) int { now := time.Now() cleaned := 0 for id, sess := range s.sessions { - if sess == nil || now.Sub(sess.LastAccessedAt) > ttl { + if sess == nil || now.Sub(sess.LastAccessed()) > ttl { delete(s.sessions, id) cleaned++ } @@ -428,8 +406,36 @@ func (s *Server) cleanStaleSessionsLocked(ttl time.Duration) int { return cleaned } -// getOrCreateSession 获取或动态构建会话 +const maxLiveSessions = 4096 + +func (s *Server) enforceSessionCapLocked() { + for len(s.sessions) >= maxLiveSessions { + var oldestID string + var oldest time.Time + found := false + for id, sess := range s.sessions { + if sess == nil { + delete(s.sessions, id) + continue + } + at := sess.LastAccessed() + if !found || at.Before(oldest) { + found = true + oldest = at + oldestID = id + } + } + if !found || oldestID == "" { + return + } + delete(s.sessions, oldestID) + } +} + +// getOrCreateSession 获取或动态构建会话。callerUserID>0 时校验持久层归属,不匹配返回 ErrSessionOwnerMismatch。 func (s *Server) getOrCreateSession( + ctx context.Context, + callerUserID int64, sessionID string, projectName string, customerName string, @@ -439,36 +445,57 @@ 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 { + dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + owner, err := s.ctxMgr.GetSessionOwnerID(dbCtx, sessionID) + cancel() + if 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 { - existing.LastAccessedAt = time.Now() - return existing + if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } + existing.TouchLastAccessed() + return existing, nil } s.sessionsMu.Lock() defer s.sessionsMu.Unlock() // 双重检查 if existing, exists := s.sessions[sessionID]; exists && existing != nil { - existing.LastAccessedAt = time.Now() - return existing + if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } + existing.TouchLastAccessed() + return existing, nil } - // 惰性内存防泄漏保护 (会话数超过 200 时清理空闲超 120 分钟会话) + // 惰性清理,并在仍超过硬上限时按最近访问淘汰,避免随机 session id 撑爆内存。 + // 会话 id 仍由客户端提供:历史恢复按该 id 查找,不能改成只接受服务端签发。 if len(s.sessions) > 200 { s.cleanStaleSessionsLocked(120 * time.Minute) } + s.enforceSessionCapLocked() // 尝试从持久层恢复会话上下文 (服务重启后跨进程保持 G3) var restored *ctxmgr.RestoredSessionContext if s.ctxMgr != nil { - if r, err := s.ctxMgr.RestoreSessionContext(context.Background(), sessionID); err == nil && r != nil && len(r.Messages) > 0 { + dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + r, err := s.ctxMgr.RestoreSessionContext(dbCtx, sessionID) + cancel() + if err == nil && r != nil && len(r.Messages) > 0 { restored = r } } @@ -553,7 +580,7 @@ func (s *Server) getOrCreateSession( sess.UserID = restored.UserID } if restored.ProjectID > 0 { - sess.ProjectID = restored.ProjectID + sess.SetProjectID(restored.ProjectID) } if len(restored.Facts) > 0 { _ = coordinator.ApplyContextUpdate(userCtx, coordinator.ContextUpdate{ @@ -563,7 +590,7 @@ func (s *Server) getOrCreateSession( } if s.chatLog == nil { for _, rm := range restored.Messages { - sess.Messages = append(sess.Messages, chat.Message{ + sess.AppendMessage(chat.Message{ ID: fmt.Sprintf("msg_%d", rm.ID), Role: rm.Role, Content: rm.Content, @@ -576,7 +603,10 @@ func (s *Server) getOrCreateSession( } if sess.UserID > 0 && sess.CtxMgr != nil { - if guidance, err := sess.CtxMgr.CompilePromptGuidance(context.Background(), sess.UserID); err == nil && guidance != "" { + dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + guidance, err := sess.CtxMgr.CompilePromptGuidance(dbCtx, sess.UserID) + cancel() + if err == nil && guidance != "" { _ = coordinator.ApplyContextUpdate(userCtx, coordinator.ContextUpdate{ ExpectedVersion: userCtx.Version, SetPersonalizedGuidance: true, @@ -586,16 +616,26 @@ func (s *Server) getOrCreateSession( } } - sess.LastAccessedAt = time.Now() + // 新建会话时立即绑定调用者。NewSession 历史占位 UserID=1/ProjectID=1 不能当作已绑定归属。 + if restored == nil { + sess.SetProjectID(0) + if callerUserID > 0 { + sess.UserID = callerUserID + } + } else if restored.UserID <= 0 && callerUserID > 0 { + sess.UserID = callerUserID + } + + sess.TouchLastAccessed() s.sessions[sessionID] = sess - return sess + return sess, nil } func (s *Server) hydrateVisibleHistory(ctx context.Context, sess *chat.Session, viewerUserID int64) { if s.chatLog == nil || sess == nil || viewerUserID <= 0 { return } - if len(sess.Messages) > 0 { + if len(sess.SnapshotMessages()) > 0 { return } restored, err := s.chatLog.Restore(ctx, chatflow.Principal{UserID: viewerUserID, Action: chatflow.ActionAssemble}, sess.ID) @@ -603,7 +643,7 @@ func (s *Server) hydrateVisibleHistory(ctx context.Context, sess *chat.Session, return } for _, rm := range restored.Messages { - sess.Messages = append(sess.Messages, chat.Message{ + sess.AppendMessage(chat.Message{ ID: fmt.Sprintf("msg_%d", rm.ID), Role: rm.Role, Content: rm.Content, @@ -627,6 +667,7 @@ func (s *Server) taskUseCase() *stageflow.UseCase { func (s *Server) corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + applySecurityHeaders(w) s.writeCORS(w, r) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With") 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/session_cap_test.go b/internal/server/session_cap_test.go new file mode 100644 index 00000000..552b4246 --- /dev/null +++ b/internal/server/session_cap_test.go @@ -0,0 +1,33 @@ +package server + +import ( + "fmt" + "testing" + "time" + + "chat-agent-system/internal/chat" +) + +func TestEnforceSessionCapEvictsOldest(t *testing.T) { + s := &Server{sessions: map[string]*chat.Session{}} + oldest := chat.NewSession("oldest", nil, nil, nil) + oldest.TouchLastAccessed() + s.sessions[oldest.ID] = oldest + time.Sleep(5 * time.Millisecond) + for i := 0; i < maxLiveSessions-1; i++ { + id := fmt.Sprintf("s%d", i) + sess := chat.NewSession(id, nil, nil, nil) + sess.TouchLastAccessed() + s.sessions[id] = sess + } + if len(s.sessions) != maxLiveSessions { + t.Fatalf("setup len=%d", len(s.sessions)) + } + s.enforceSessionCapLocked() + if _, ok := s.sessions["oldest"]; ok { + t.Fatal("oldest session must be evicted at the cap") + } + if len(s.sessions) != maxLiveSessions-1 { + t.Fatalf("len=%d", len(s.sessions)) + } +} diff --git a/internal/server/sse.go b/internal/server/sse.go index 16d5daa8..e079983c 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -54,6 +54,15 @@ func extendSSEWriteDeadline(w http.ResponseWriter) { _ = rc.SetWriteDeadline(time.Now().Add(sseWriteDeadlineExtend)) } +// syncTaskWriteDeadline 覆盖同步 /tasks/run 的长执行窗口。全局 WriteTimeout=120s +// 会在响应写出前截断数十分钟的阶段/任务;此处只延长该请求的写期限,不改全局值。 +const syncTaskWriteDeadline = 75 * time.Minute + +func extendSyncTaskWriteDeadline(w http.ResponseWriter) { + rc := http.NewResponseController(w) + _ = rc.SetWriteDeadline(time.Now().Add(syncTaskWriteDeadline)) +} + func writeSSEHeartbeat(w http.ResponseWriter, flusher http.Flusher) error { extendSSEWriteDeadline(w) if err := (transport.Encoder{W: w}).Heartbeat(time.Now()); err != nil { @@ -173,7 +182,9 @@ 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( + r.Context(), + user.ID, req.SessionID, req.ProjectName, req.CustomerName, @@ -184,13 +195,23 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - sess.UserID = req.UserID - s.hydrateVisibleHistory(r.Context(), sess, user.ID) - if req.ProjectID > 0 { - sess.ProjectID = req.ProjectID + 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 } - if sess.ProjectID > 0 { - if !s.enforceChatProjectAccess(w, r, user, sess.ProjectID) { + s.hydrateVisibleHistory(r.Context(), sess, user.ID) + if req.ProjectID > 0 && req.ProjectID != sess.Project() { + sess.SetProjectID(req.ProjectID) + } + if sess.Project() > 0 { + if !s.enforceChatProjectAccess(w, r, user, sess.Project()) { return } } @@ -204,11 +225,12 @@ 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) s.enrichSessionWorkflowStage(ctx, sess, req.UserID) - if sess.ProjectID > 0 && !s.allowActiveStageChatAccess(ctx, sess.ProjectID, req.UserID) { + if sess.Project() > 0 && !s.allowActiveStageChatAccess(ctx, sess.Project(), req.UserID) { writeWorkflowAccessDeniedSSE(w, flusher, req.SessionID) return } @@ -228,14 +250,44 @@ 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{}) + var heartbeatWg sync.WaitGroup + heartbeatWg.Add(1) + go func() { + defer heartbeatWg.Done() + 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 + } + } + } + }() + // handler 返回前先停心跳再等待其退出,避免心跳 goroutine 在连接回收后残留写 + defer func() { + close(heartbeatDone) + heartbeatWg.Wait() + }() + clientTurnID := req.ClientTurnID if clientTurnID == "" { clientTurnID = r.Header.Get("Idempotency-Key") @@ -282,7 +334,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 +363,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 +403,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, @@ -379,10 +426,36 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { taskReq := s.buildBizdevTaskRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, runID) stageReq := s.buildBizdevStageRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, runID) - created, err := s.taskRuns.CreateAndStart(r.Context(), runID, record, taskReq, stageReq, req.WorkDir) - if err != nil { - writeSafeError(w, r, http.StatusInternalServerError, "start task run failed: "+err.Error()) - return + // 幂等前置:与 REST 路径对齐,EventSource 断线重连命中已有 run 时直接复用, + // 避免重复执行同一 client_turn_id 的任务。 + var reused *taskrun.RunRecord + if idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID); idemKey != "" && s.taskRuns != nil { + if existing, findErr := s.taskRuns.FindByIdempotencyKey(r.Context(), idemKey); findErr != nil { + writeSafeError(w, r, http.StatusInternalServerError, findErr.Error()) + return + } else if existing != nil { + if existing.UserID != req.UserID || existing.SessionID != req.SessionID || + existing.ProjectID != req.ProjectID || existing.ClientTurnID != req.ClientTurnID { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "幂等键已存在但不属于当前用户/会话/项目", + }) + return + } + reused = existing + } + } + + var created *taskrun.RunRecord + if reused != nil { + created = reused + } else { + var err error + created, err = s.taskRuns.CreateAndStart(r.Context(), runID, record, taskReq, stageReq, req.WorkDir) + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, "start task run failed: "+err.Error()) + return + } } _ = includeDetails _ = requestID @@ -394,9 +467,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 +489,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/stage_access_helpers.go b/internal/server/stage_access_helpers.go index 3909f4ae..e435656b 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -140,13 +140,13 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess if sess == nil { return } - if sess.ProjectID > 0 && s.instSvc != nil && s.instSvc.Available() { - settings, err := s.instSvc.GetSettings(ctx, sess.ProjectID, userID, false) + if sess.Project() > 0 && s.instSvc != nil && s.instSvc.Available() { + settings, err := s.instSvc.GetSettings(ctx, sess.Project(), userID, false) if err == nil && settings != nil { sess.ActiveStageKey = settings.ActiveStageKey } } - scope, err := s.resolveChatMessageScope(ctx, sess.ProjectID, sess.ActiveStageKey) + scope, err := s.resolveChatMessageScope(ctx, sess.Project(), sess.ActiveStageKey) if err == nil && scope != nil { sess.MessageScope = scope } diff --git a/internal/server/static_assets.go b/internal/server/static_assets.go new file mode 100644 index 00000000..67e67c9f --- /dev/null +++ b/internal/server/static_assets.go @@ -0,0 +1,282 @@ +package server + +import ( + "mime" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退。 +// /assets 与 /fonts 下缺失文件返回 404(不回退 HTML),避免旧 chunk 拿到 HTML 当 JS。 +func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + + distDir := resolveDistDir(s.cfg.DataDir) + if distDir == "" { + http.NotFound(w, r) + return + } + + reqPath := filepath.Clean(r.URL.Path) + if reqPath == "/" || reqPath == "." { + serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true) + return + } + + // Prevent path escape outside distDir. + targetFile := filepath.Join(distDir, strings.TrimPrefix(reqPath, string(filepath.Separator))) + if !isPathInside(distDir, targetFile) { + http.NotFound(w, r) + return + } + + noFallback := strings.HasPrefix(reqPath, "/assets/") || strings.HasPrefix(reqPath, "/fonts/") + if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() { + serveStaticFile(w, r, targetFile, cacheControlFor(reqPath), true) + return + } + + if noFallback { + http.NotFound(w, r) + return + } + + // SPA client-route fallback + serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true) +} + +func resolveDistDir(dataDir string) string { + candidates := []string{ + "web/dist", + "/app/web/dist", + "../web/dist", + "../../web/dist", + filepath.Join(dataDir, "web/dist"), + filepath.Join(dataDir, "../web/dist"), + } + for _, dir := range candidates { + if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() { + return dir + } + } + return "" +} + +func isPathInside(root, candidate string) bool { + absRoot, err1 := filepath.Abs(root) + absCand, err2 := filepath.Abs(candidate) + if err1 != nil || err2 != nil { + return false + } + rel, err := filepath.Rel(absRoot, absCand) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func cacheControlFor(reqPath string) string { + if strings.HasPrefix(reqPath, "/assets/") || strings.HasPrefix(reqPath, "/fonts/") { + return "public, max-age=31536000, immutable" + } + base := filepath.Base(reqPath) + if base == "index.html" { + return "no-cache" + } + // Root non-hashed assets (favicon, etc.) + return "public, max-age=3600" +} + +func serveStaticFile(w http.ResponseWriter, r *http.Request, filePath, cacheControl string, negotiateCompress bool) { + w.Header().Set("Cache-Control", cacheControl) + + fi, err := os.Stat(filePath) + if err != nil || fi.IsDir() { + http.NotFound(w, r) + return + } + + // Range requests: serve identity only (encoded byte ranges are ambiguous). + useCompress := negotiateCompress && r.Header.Get("Range") == "" + encoding, variantPath := "", filePath + if useCompress { + encoding, variantPath = pickCompressedVariant(filePath, r.Header.Get("Accept-Encoding")) + } + + // Always Vary when compression negotiation is in play for compressible types. + if negotiateCompress && isCompressiblePath(filePath) { + w.Header().Set("Vary", "Accept-Encoding") + } + + openPath := filePath + if encoding != "" { + if vfi, verr := os.Stat(variantPath); verr == nil && !vfi.IsDir() { + openPath = variantPath + w.Header().Set("Content-Encoding", encoding) + if ct := contentTypeFor(filePath); ct != "" { + w.Header().Set("Content-Type", ct) + } + } else { + encoding = "" + openPath = filePath + } + } + + f, err := os.Open(openPath) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + + // Use original file modtime for conditional requests so identity/compressed stay consistent. + http.ServeContent(w, r, filepath.Base(filePath), fi.ModTime(), f) +} + +func isCompressiblePath(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".js", ".css", ".html", ".svg", ".json", ".txt", ".xml", ".map": + return true + default: + return false + } +} + +func contentTypeFor(path string) string { + ext := filepath.Ext(path) + if ext == "" { + return "" + } + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + switch strings.ToLower(ext) { + case ".js": + return "text/javascript; charset=utf-8" + case ".css": + return "text/css; charset=utf-8" + case ".html": + return "text/html; charset=utf-8" + case ".svg": + return "image/svg+xml" + case ".json": + return "application/json" + case ".woff2": + return "font/woff2" + default: + return "" + } +} + +type encodingOffer struct { + name string + q float64 +} + +// pickCompressedVariant selects br or gzip based on Accept-Encoding q-values. +// Returns ("", original) when identity should be used. +func pickCompressedVariant(filePath, acceptEncoding string) (encoding, variantPath string) { + if !isCompressiblePath(filePath) { + return "", filePath + } + offers := parseAcceptEncoding(acceptEncoding) + if len(offers) == 0 { + return "", filePath + } + + identityQ := 1.0 + identityExplicit := false + starQ := -1.0 + preferred := make([]encodingOffer, 0, 2) + + for _, o := range offers { + switch o.name { + case "identity": + identityQ = o.q + identityExplicit = true + case "*": + starQ = o.q + case "br", "gzip": + if o.q > 0 { + preferred = append(preferred, o) + } + } + } + + sort.SliceStable(preferred, func(i, j int) bool { + if preferred[i].q != preferred[j].q { + return preferred[i].q > preferred[j].q + } + // Prefer br over gzip at equal q. + if preferred[i].name == "br" && preferred[j].name != "br" { + return true + } + return false + }) + + for _, o := range preferred { + var variant string + switch o.name { + case "br": + variant = filePath + ".br" + case "gzip": + variant = filePath + ".gz" + default: + continue + } + if fi, err := os.Stat(variant); err == nil && !fi.IsDir() { + if o.q >= identityQ || !identityExplicit { + return o.name, variant + } + } + } + + // If identity is forbidden (q=0) but a wildcard allows compression, already handled. + // If identity q=0 and no compressed variant, fall through to identity anyway + // (cannot invent an encoding); callers still serve the file. + _ = starQ + return "", filePath +} + +func parseAcceptEncoding(header string) []encodingOffer { + header = strings.TrimSpace(header) + if header == "" { + return nil + } + parts := strings.Split(header, ",") + out := make([]encodingOffer, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + name := part + q := 1.0 + if semi := strings.IndexByte(part, ';'); semi >= 0 { + name = strings.TrimSpace(part[:semi]) + params := strings.Split(part[semi+1:], ";") + for _, p := range params { + p = strings.TrimSpace(p) + if strings.HasPrefix(strings.ToLower(p), "q=") { + if v, err := strconv.ParseFloat(strings.TrimSpace(p[2:]), 64); err == nil { + q = v + } + } + } + } + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + continue + } + out = append(out, encodingOffer{name: name, q: q}) + } + return out +} diff --git a/internal/server/static_assets_test.go b/internal/server/static_assets_test.go new file mode 100644 index 00000000..db31fcd7 --- /dev/null +++ b/internal/server/static_assets_test.go @@ -0,0 +1,345 @@ +package server + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "chat-agent-system/internal/config" +) + +func setupStaticFixture(t *testing.T) (handler http.Handler, distDir string) { + t.Helper() + dataDir := t.TempDir() + // Avoid picking up the repo's real web/dist via relative candidates. + t.Chdir(dataDir) + distDir = filepath.Join(dataDir, "web", "dist") + assetsDir := filepath.Join(distDir, "assets") + fontsDir := filepath.Join(distDir, "fonts") + if err := os.MkdirAll(assetsDir, 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + if err := os.MkdirAll(fontsDir, 0o755); err != nil { + t.Fatalf("mkdir fonts: %v", err) + } + + indexHTML := []byte("spa-index\n") + jsBody := bytes.Repeat([]byte("console.log('payload-for-compression-test');\n"), 80) + fontBody := []byte("woff2-fake-bytes-not-really-a-font") + + mustWrite := func(path string, body []byte) { + t.Helper() + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + mustWrite(filepath.Join(distDir, "index.html"), indexHTML) + mustWrite(filepath.Join(assetsDir, "index-abc.js"), jsBody) + mustWrite(filepath.Join(fontsDir, "inter-v20-latin.woff2"), fontBody) + mustWrite(filepath.Join(distDir, "favicon.ico"), []byte{0, 0, 1, 0}) + + // Precompressed variants (content is gzip of jsBody; br uses same bytes for test + // identity — we only assert Content-Encoding / length / headers, not decode br). + var gzBuf bytes.Buffer + zw := gzip.NewWriter(&gzBuf) + if _, err := zw.Write(jsBody); err != nil { + t.Fatalf("gzip write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + mustWrite(filepath.Join(assetsDir, "index-abc.js.gz"), gzBuf.Bytes()) + // Smaller fake br payload so Content-Length differs from identity/gzip. + brBody := append([]byte("BR"), jsBody[:40]...) + mustWrite(filepath.Join(assetsDir, "index-abc.js.br"), brBody) + + cfg := &config.Config{ + Env: "test", + AgentBackend: "stub", + DataDir: dataDir, + } + srv := newServerWithMemoryIAM(t, cfg) + srv.cfg.AllowInMemory = false + return srv.Routes(), distDir +} + +func TestStaticAssets_CompressionNegotiation(t *testing.T) { + handler, _ := setupStaticFixture(t) + + t.Run("prefers_br", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if got := w.Header().Get("Content-Encoding"); got != "br" { + t.Fatalf("Content-Encoding=%q want br", got) + } + if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary missing Accept-Encoding: %q", w.Header().Get("Vary")) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") { + t.Fatalf("Content-Type=%q want javascript", ct) + } + if w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { + t.Fatalf("Cache-Control=%q", w.Header().Get("Cache-Control")) + } + if !bytes.HasPrefix(w.Body.Bytes(), []byte("BR")) { + t.Fatalf("body not br fixture prefix") + } + }) + + t.Run("gzip_only", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + if got := w.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding=%q want gzip", got) + } + if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary=%q", w.Header().Get("Vary")) + } + zr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + defer zr.Close() + decoded, err := io.ReadAll(zr) + if err != nil { + t.Fatalf("gzip decode: %v", err) + } + if !bytes.Contains(decoded, []byte("payload-for-compression-test")) { + t.Fatalf("decoded body missing payload") + } + }) + + t.Run("gzip_q0_identity", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip;q=0") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("Content-Encoding=%q want empty", got) + } + if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary=%q", w.Header().Get("Vary")) + } + if !bytes.Contains(w.Body.Bytes(), []byte("payload-for-compression-test")) { + t.Fatalf("identity body missing payload") + } + }) + + t.Run("no_accept_encoding_identity", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("Content-Encoding=%q want empty", got) + } + if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary=%q", w.Header().Get("Vary")) + } + }) +} + +func TestStaticAssets_HEADAndConditional(t *testing.T) { + handler, _ := setupStaticFixture(t) + + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET status=%d", w.Code) + } + lastMod := w.Header().Get("Last-Modified") + if lastMod == "" { + t.Fatal("expected Last-Modified") + } + + head := httptest.NewRequest(http.MethodHead, "/assets/index-abc.js", nil) + head.Header.Set("Accept-Encoding", "gzip") + hw := httptest.NewRecorder() + handler.ServeHTTP(hw, head) + if hw.Code != http.StatusOK { + t.Fatalf("HEAD status=%d", hw.Code) + } + if hw.Header().Get("Content-Encoding") != "gzip" { + t.Fatalf("HEAD Content-Encoding=%q", hw.Header().Get("Content-Encoding")) + } + if hw.Body.Len() != 0 { + t.Fatalf("HEAD body len=%d", hw.Body.Len()) + } + + modTime, err := http.ParseTime(lastMod) + if err != nil { + t.Fatalf("parse Last-Modified: %v", err) + } + cond := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + cond.Header.Set("Accept-Encoding", "gzip") + cond.Header.Set("If-Modified-Since", modTime.UTC().Format(http.TimeFormat)) + cw := httptest.NewRecorder() + handler.ServeHTTP(cw, cond) + if cw.Code != http.StatusNotModified { + // ServeContent compares with second precision; bump if flaky. + if cw.Code != http.StatusOK { + t.Fatalf("conditional status=%d", cw.Code) + } + // Retry with far-future IMS + cond2 := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + cond2.Header.Set("Accept-Encoding", "gzip") + cond2.Header.Set("If-Modified-Since", time.Now().UTC().Add(24*time.Hour).Format(http.TimeFormat)) + cw2 := httptest.NewRecorder() + handler.ServeHTTP(cw2, cond2) + if cw2.Code != http.StatusNotModified { + t.Fatalf("conditional with future IMS status=%d", cw2.Code) + } + } +} + +func TestStaticAssets_RangeUsesIdentity(t *testing.T) { + handler, _ := setupStaticFixture(t) + req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil) + req.Header.Set("Accept-Encoding", "gzip, br") + req.Header.Set("Range", "bytes=0-15") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusPartialContent { + t.Fatalf("status=%d want 206", w.Code) + } + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("Range must use identity, got Content-Encoding=%q", got) + } + if w.Body.Len() != 16 { + t.Fatalf("range body len=%d", w.Body.Len()) + } +} + +func TestStaticAssets_MissingAssetIs404NotHTML(t *testing.T) { + handler, _ := setupStaticFixture(t) + req := httptest.NewRequest(http.MethodGet, "/assets/missing-hash.js", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("status=%d want 404", w.Code) + } + body := w.Body.String() + if strings.Contains(body, "spa-index") || strings.Contains(strings.ToLower(body), " @@ -230,6 +232,7 @@ func ExtractAdvisorBriefing(taskKey string, markdownBody string, fields map[stri } // 2. 根据任务类型与提取事实定制成熟度、风险与话术 + taskKey = workflowdef.ImplementationTaskKey(taskKey) switch taskKey { case "intake", "user_requirements": brief.MaturityScore = 65 @@ -343,4 +346,3 @@ func ExtractAdvisorBriefing(taskKey string, markdownBody string, fields map[stri return brief } - diff --git a/internal/squad/keyspace_alias_test.go b/internal/squad/keyspace_alias_test.go new file mode 100644 index 00000000..0fcbc09a --- /dev/null +++ b/internal/squad/keyspace_alias_test.go @@ -0,0 +1,20 @@ +package squad + +import "testing" + +func TestDefaultWorkflowSteps_CanonicalAlias(t *testing.T) { + legacy := DefaultWorkflowSteps("intake") + canonical := DefaultWorkflowSteps("bd_intake") + if len(legacy) == 0 || len(legacy) != len(canonical) { + t.Fatalf("intake steps %d, bd_intake steps %d", len(legacy), len(canonical)) + } + if legacy[0].Role != canonical[0].Role { + t.Fatalf("alias diverged: %s vs %s", legacy[0].Role, canonical[0].Role) + } + if TaskArtifactTitle("intake") != TaskArtifactTitle("bd_intake") { + t.Fatal("artifact title must match across the alias") + } + if !isStandardTask("tm_system_verification") || !isStandardTask("test_manager") { + t.Fatal("test manager alias must stay on the whitelist") + } +} diff --git a/internal/squad/planner.go b/internal/squad/planner.go index 31ffd85e..a7cc1534 100644 --- a/internal/squad/planner.go +++ b/internal/squad/planner.go @@ -55,12 +55,7 @@ func (p *AdaptivePlanner) Plan(req PlannerRequest) (*PlannerResult, error) { } func isStandardTask(taskKey string) bool { - for _, t := range SupportedTasks { - if t.Key == taskKey { - return true - } - } - return false + return taskKnown(taskKey) } // planFixedAdaptive 固定工作流动态微调 @@ -72,10 +67,19 @@ func (p *AdaptivePlanner) planFixedAdaptive(req PlannerRequest) (*PlannerResult, steps := req.CustomSteps if len(steps) == 0 { - steps = DefaultWorkflowSteps(req.TaskKey) + if strings.HasPrefix(req.TaskKey, "rd_") { + steps = rdevWorkflowSteps(req.TaskKey, rdevComplexityTier(req.Fields)) + } else { + steps = DefaultWorkflowSteps(req.TaskKey) + } } - agents := DefaultSquadAgents(req.TaskKey) + var agents []AgentSpec + if strings.HasPrefix(req.TaskKey, "rd_") { + agents = rdevSquadAgents(req.TaskKey, rdevComplexityTier(req.Fields)) + } else { + agents = DefaultSquadAgents(req.TaskKey) + } // 材料嗅探感知 (Material Sniffing) var matBuilder strings.Builder diff --git a/internal/squad/quality_gate.go b/internal/squad/quality_gate.go index 59fc0152..2f3b8fa4 100644 --- a/internal/squad/quality_gate.go +++ b/internal/squad/quality_gate.go @@ -16,6 +16,7 @@ import ( "chat-agent-system/internal/agent" "chat-agent-system/internal/stream" + "chat-agent-system/internal/workflowdef" ) // ProhibitedPhrases 医疗器械文档小队严禁出现的敷衍套话与省略词 @@ -97,6 +98,9 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q report.Passed = false } + // 新键与旧键共用同一套门禁;rd_* 不在别名表里,不会被改写。 + taskKey = workflowdef.ImplementationTaskKey(taskKey) + // 4. CDO 报价任务检查 if taskKey == "cdo_quote" { xlsxPath := filepath.Join(wsDir, "CDO报价核算-V01.xlsx") @@ -148,6 +152,12 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q } } + for _, v := range rdevDeterministicViolations(wsDir, taskKey, content) { + report.Violations = append(report.Violations, v) + report.Score -= 20.0 + report.Passed = false + } + if report.Score < 0 { report.Score = 0 } diff --git a/internal/squad/quality_gate_rdev.go b/internal/squad/quality_gate_rdev.go new file mode 100644 index 00000000..5637c9b8 --- /dev/null +++ b/internal/squad/quality_gate_rdev.go @@ -0,0 +1,265 @@ +package squad + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +var rdevReqIDPattern = regexp.MustCompile(`\b(UR|RS)-(\d+)\b`) + +func rdevDeterministicViolations(wsDir, taskKey, content string) []string { + meta, ok := LookupRDevTask(taskKey) + if !ok { + return nil + } + switch taskKey { + case "rd_user_needs": + return rdevRequirementIssues(wsDir, content, "UR") + case "rd_system_reqs": + return rdevRequirementIssues(wsDir, content, "RS") + } + switch meta.Kind { + case RDevKindReviewMinutes: + return rdevReviewMinutesIssues(content) + case RDevKindUploadIndex: + return rdevUploadIndexIssues(content) + default: + return nil + } +} + +func rdevRequirementIssues(wsDir, content, prefix string) []string { + var issues []string + if strings.Contains(content, "随机抽取") { + issues = append(issues, "需求集合不得以随机抽取代替全量编号") + } + ids := orderedRequirementIDs(content, prefix) + if len(ids) == 0 { + issues = append(issues, "缺少连续需求编号 "+prefix+"-01") + } else if gap := consecutiveIDGap(ids); gap != "" { + issues = append(issues, gap) + } + raw, err := os.ReadFile(filepath.Join(wsDir, "artifacts", "requirements.json")) + if err != nil { + issues = append(issues, "requirements.json 为必需产物") + return issues + } + var reqs []map[string]any + if json.Unmarshal(raw, &reqs) != nil { + issues = append(issues, "requirements.json 格式无效") + return issues + } + jsonIDs := map[string]bool{} + for _, req := range reqs { + id, _ := req["id"].(string) + id = strings.TrimSpace(id) + if id == "" { + issues = append(issues, "需求缺少 id") + continue + } + if jsonIDs[id] { + issues = append(issues, "重复需求 id: "+id) + } + jsonIDs[id] = true + method, _ := req["verification_method"].(string) + criteria, _ := req["acceptance_criteria"].(string) + if strings.TrimSpace(method) == "" && strings.TrimSpace(criteria) == "" { + issues = append(issues, id+": 缺少验证准则") + } + } + bodyIDs := map[string]bool{} + for _, id := range ids { + bodyIDs[id] = true + if !jsonIDs[id] { + issues = append(issues, "正文有而 JSON 没有: "+id) + } + } + for id := range jsonIDs { + if !bodyIDs[id] { + issues = append(issues, "JSON 有而正文没有: "+id) + } + } + return issues +} + +func orderedRequirementIDs(content, prefix string) []string { + seen := map[string]bool{} + var ids []string + for _, match := range rdevReqIDPattern.FindAllStringSubmatch(content, -1) { + if match[1] != prefix { + continue + } + n, err := strconv.Atoi(match[2]) + if err != nil { + continue + } + id := fmt.Sprintf("%s-%02d", prefix, n) + if seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + return ids +} + +func consecutiveIDGap(ids []string) string { + nums := make([]int, 0, len(ids)) + seen := map[int]bool{} + for _, id := range ids { + part := id[strings.LastIndex(id, "-")+1:] + n, err := strconv.Atoi(part) + if err != nil { + return "需求编号无法解析: " + id + } + if seen[n] { + return "重复需求编号: " + id + } + seen[n] = true + nums = append(nums, n) + } + for _, n := range nums { + if n < 1 || n > len(nums) || !seen[n] { + return fmt.Sprintf("需求编号必须从 01 连续到 %02d", len(nums)) + } + } + for n := 1; n <= len(nums); n++ { + if !seen[n] { + return fmt.Sprintf("需求编号不连续,缺少 %02d", n) + } + } + return "" +} + +func rdevReviewMinutesIssues(content string) []string { + var issues []string + section := sectionAfterHeading(content, "## 问题与决议") + headers, rows := markdownTable(section) + if len(headers) == 0 { + issues = append(issues, "评审记录缺少问题与决议表") + return issues + } + col := headerIndex(headers) + if len(rows) == 0 { + issues = append(issues, "评审记录没有问题行") + } + hollow := strings.Contains(content, "同意通过") + substantive := false + for _, row := range rows { + problem := cell(row, col["问题"]) + decision := cell(row, col["决议"]) + owner := cell(row, col["责任人"]) + due := cell(row, col["期限"]) + if problem == "" || decision == "" || owner == "" || due == "" { + issues = append(issues, "问题行缺少问题、决议、责任人或期限") + continue + } + if problem != "无" && problem != "同意通过" { + substantive = true + } + } + if hollow && !substantive { + issues = append(issues, "评审记录只有空洞「同意通过」,缺少实质问题") + } + action := sectionAfterHeading(content, "## 遗留行动项跟踪") + _, actionRows := markdownTable(action) + if len(actionRows) == 0 { + issues = append(issues, "评审记录缺少可跟踪行动项") + } + return issues +} + +func rdevUploadIndexIssues(content string) []string { + var issues []string + if _, rows := markdownTable(content); len(rows) == 0 { + issues = append(issues, "附件清单缺少索引表") + } + if !strings.Contains(content, "影响") { + issues = append(issues, "附件清单缺少缺失影响分析") + } + return issues +} + +func sectionAfterHeading(content, heading string) string { + idx := strings.Index(content, heading) + if idx < 0 { + return "" + } + rest := content[idx+len(heading):] + if n := strings.Index(rest, "\n## "); n >= 0 { + rest = rest[:n] + } + return rest +} + +func markdownTable(section string) (headers []string, rows [][]string) { + var header []string + started := false + for _, line := range strings.Split(section, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "|") { + // 同一节里后面的表(例如法规对照)不属于第一张问题表。 + if started { + break + } + continue + } + started = true + cells := splitTableRow(line) + if len(cells) == 0 { + continue + } + if isSeparatorRow(cells) { + continue + } + if header == nil { + header = cells + continue + } + rows = append(rows, cells) + } + return header, rows +} + +func splitTableRow(line string) []string { + line = strings.Trim(line, "|") + parts := strings.Split(line, "|") + out := make([]string, 0, len(parts)) + for _, p := range parts { + out = append(out, strings.TrimSpace(p)) + } + return out +} + +func isSeparatorRow(cells []string) bool { + for _, c := range cells { + c = strings.TrimSpace(strings.ReplaceAll(c, ":", "")) + if c == "" { + continue + } + if strings.Trim(c, "-") != "" { + return false + } + } + return true +} + +func headerIndex(headers []string) map[string]int { + idx := map[string]int{"问题": -1, "决议": -1, "责任人": -1, "期限": -1} + for i, h := range headers { + idx[h] = i + } + return idx +} + +func cell(row []string, i int) string { + if i < 0 || i >= len(row) { + return "" + } + return strings.TrimSpace(row[i]) +} diff --git a/internal/squad/quality_gate_v2.go b/internal/squad/quality_gate_v2.go index 057362cd..f558a0e8 100644 --- a/internal/squad/quality_gate_v2.go +++ b/internal/squad/quality_gate_v2.go @@ -14,6 +14,18 @@ func ValidateQualityGateV2(gateKey, wsDir string) []string { return validateRequirementsJSON(wsDir) case "quality.test_manager.v2": return validateTestCasesJSON(wsDir) + case "quality.rd_user_needs.v1": + body, _ := os.ReadFile(filepath.Join(wsDir, "output.md")) + return rdevRequirementIssues(wsDir, string(body), "UR") + case "quality.rd_system_reqs.v1": + body, _ := os.ReadFile(filepath.Join(wsDir, "output.md")) + return rdevRequirementIssues(wsDir, string(body), "RS") + case "quality.rd_review_minutes.v1": + body, _ := os.ReadFile(filepath.Join(wsDir, "output.md")) + return rdevReviewMinutesIssues(string(body)) + case "quality.rd_upload_index.v1": + body, _ := os.ReadFile(filepath.Join(wsDir, "output.md")) + return rdevUploadIndexIssues(string(body)) default: return nil } diff --git a/internal/squad/rdev_catalog.go b/internal/squad/rdev_catalog.go new file mode 100644 index 00000000..d5a69781 --- /dev/null +++ b/internal/squad/rdev_catalog.go @@ -0,0 +1,447 @@ +package squad + +import ( + "strings" + "unicode/utf8" +) + +// 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 + Hint string + SkipHint string + Aliases []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 + rdevCatalogByAlias map[string]string +) + +func init() { + rdevCatalogByKey = make(map[string]RDevTaskMeta, len(RDevCatalog)) + rdevCatalogByAlias = make(map[string]string) + for i := range RDevCatalog { + if h, ok := rdevStageHints[RDevCatalog[i].Key]; ok { + RDevCatalog[i].Hint = h.Hint + RDevCatalog[i].SkipHint = h.SkipHint + RDevCatalog[i].Aliases = append([]string(nil), h.Aliases...) + } + rdevCatalogByKey[RDevCatalog[i].Key] = RDevCatalog[i] + for _, alias := range RDevCatalog[i].Aliases { + rdevCatalogByAlias[alias] = RDevCatalog[i].Key + } + } +} + +// RDevTriggerPhrase 对话里用来点名研发任务的标题或别名。 +type RDevTriggerPhrase struct { + Text string + TaskKey string +} + +// RDevTriggerPhrases 返回可用于「生成/出」匹配的研发文档名。 +// 过短的英文缩写不进入,避免 UN/BOM 这类记号命中无关句子。 +func RDevTriggerPhrases() []RDevTriggerPhrase { + seen := map[string]string{} + var out []RDevTriggerPhrase + add := func(text, key string) { + text = strings.TrimSpace(text) + if text == "" || rdevTriggerAliasUnsafe(text) { + return + } + if prev, ok := seen[text]; ok && prev != key { + return + } + if _, ok := seen[text]; ok { + return + } + seen[text] = key + out = append(out, RDevTriggerPhrase{Text: text, TaskKey: key}) + } + for _, item := range RDevCatalog { + add(item.Title, item.Key) + for _, alias := range item.Aliases { + add(alias, item.Key) + } + } + return out +} + +func rdevTriggerAliasUnsafe(text string) bool { + if utf8.RuneCountInString(text) < 2 { + return true + } + for _, r := range text { + if r > 127 { + return false + } + } + return true +} +func LookupRDevTask(taskKey string) (RDevTaskMeta, bool) { + if t, ok := rdevCatalogByKey[taskKey]; ok { + return t, true + } + if key, ok := rdevCatalogByAlias[strings.TrimSpace(taskKey)]; ok { + t, found := rdevCatalogByKey[key] + return t, found + } + return RDevTaskMeta{}, false +} + +// RDevNeedsRegulatory 是否注入法规专员步骤。未声明复杂度时保持任务名单,避免已有运行突然少一步。 +func RDevNeedsRegulatory(taskKey string) bool { + return RDevNeedsRegulatoryAt(taskKey, "") +} + +// RDevNeedsRegulatoryAt 在任务名单之上再看 complexity_tier。 +// high 与未声明走名单;medium 与 low 不注入。对齐参考项目 when: complexity_tier=high。 +func RDevNeedsRegulatoryAt(taskKey, tier string) bool { + switch strings.ToLower(strings.TrimSpace(tier)) { + case "medium", "low": + return false + } + 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..d50929bf --- /dev/null +++ b/internal/squad/rdev_catalog_test.go @@ -0,0 +1,184 @@ +package squad + +import ( + "strings" + "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") + } +} + +func TestRDevRegulatoryFollowsComplexityTier(t *testing.T) { + if !RDevNeedsRegulatory("rd_sw_plan") { + t.Fatal("unset tier must keep the static regulatory list") + } + if !RDevNeedsRegulatoryAt("rd_sw_plan", "high") { + t.Fatal("high tier must keep regulatory for listed tasks") + } + if RDevNeedsRegulatoryAt("rd_sw_plan", "medium") || RDevNeedsRegulatoryAt("rd_sw_plan", "low") { + t.Fatal("medium and low must not inject regulatory") + } + if RDevNeedsRegulatoryAt("rd_proposal", "high") { + t.Fatal("tasks outside the list stay without regulatory even at high") + } + + planner := NewAdaptivePlanner(nil) + low, err := planner.Plan(PlannerRequest{ + TaskKey: "rd_sw_plan", + Fields: map[string]string{"complexity_tier": "low"}, + }) + if err != nil { + t.Fatal(err) + } + for _, step := range low.Workflow.Steps { + if step.Role == "regulatory" { + t.Fatalf("low tier plan must not include regulatory step: %+v", low.Workflow.Steps) + } + } + high, err := planner.Plan(PlannerRequest{ + TaskKey: "rd_sw_plan", + Fields: map[string]string{"complexity_tier": "high"}, + }) + if err != nil { + t.Fatal(err) + } + found := false + for _, step := range high.Workflow.Steps { + if step.Role == "regulatory" { + found = true + } + } + if !found { + t.Fatal("high tier plan must include regulatory step") + } +} + +func TestRDevModelFromEnv(t *testing.T) { + t.Setenv("SQUAD_MODEL_ID", "") + t.Setenv("MINIMAX_MODEL_ID", "") + if got := rdevModel(); got != "MiniMax-M3" { + t.Fatalf("default model want MiniMax-M3 got %s", got) + } + t.Setenv("MINIMAX_MODEL_ID", "MiniMax-M2.7-highspeed") + if got := rdevModel(); got != "MiniMax-M2.7-highspeed" { + t.Fatalf("MINIMAX_MODEL_ID want highspeed got %s", got) + } + t.Setenv("SQUAD_MODEL_ID", "custom-squad") + if got := rdevModel(); got != "custom-squad" { + t.Fatalf("SQUAD_MODEL_ID must win, got %s", got) + } +} + +func TestRDevStageOverrideAndAliases(t *testing.T) { + for _, item := range RDevCatalog { + if item.Hint == "" || len(item.Aliases) == 0 { + t.Fatalf("%s missing hint or alias", item.Key) + } + } + got, ok := LookupRDevTask("项目建议书") + if !ok || got.Key != "rd_proposal" { + t.Fatalf("alias lookup got %+v ok=%v", got, ok) + } + + roles := func(task, tier string) []string { + t.Helper() + var out []string + for _, step := range rdevWorkflowSteps(task, tier) { + out = append(out, step.Role) + } + return out + } + want := func(task, tier string, expect ...string) { + t.Helper() + got := roles(task, tier) + if len(got) != len(expect) { + t.Fatalf("%s tier=%q roles %v want %v", task, tier, got, expect) + } + for i := range expect { + if got[i] != expect[i] { + t.Fatalf("%s tier=%q roles %v want %v", task, tier, got, expect) + } + } + } + + want("rd_design_vv", "low", "organizer", "regulatory", "engineer", "writer", "leader") + want("rd_user_needs", "high", "organizer", "engineer", "writer", "leader") + want("rd_emc_safety", "high", "organizer", "engineer", "writer", "leader") + want("rd_regs_stds", "", "organizer", "searcher", "engineer", "regulatory", "writer", "leader") + want("rd_sw_sys_test", "", "organizer", "regulatory", "engineer", "writer", "leader") + want("rd_sw_sys_test", "low", "organizer", "engineer", "writer", "leader") + want("rd_system_reqs", "", "organizer", "engineer", "writer", "leader") + want("rd_system_reqs", "high", "organizer", "engineer", "regulatory", "writer", "leader") + + steps := rdevWorkflowSteps("rd_proposal", "") + foundHint := false + for _, step := range steps { + if step.Role == "writer" { + for _, line := range step.Accept { + if strings.Contains(line, "项目建议书") { + foundHint = true + } + } + } + } + if !foundHint { + t.Fatal("writer accept must carry the stage hint") + } + agents := rdevSquadAgents("rd_regs_stds", "") + foundSearcher := false + for _, agent := range agents { + if agent.Role == "searcher" { + foundSearcher = true + } + } + if !foundSearcher { + t.Fatal("regs_stds squad must include searcher") + } +} 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_overlay.go b/internal/squad/rdev_overlay.go new file mode 100644 index 00000000..ccd47fb5 --- /dev/null +++ b/internal/squad/rdev_overlay.go @@ -0,0 +1,204 @@ +package squad + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// RDevStageOverlay 按参考项目 StageOverlay 规则拼接本阶段纪律,并按角色过滤。 +// 模板仍管章节结构;这里只提供写作纪律与粒度下限。 +func RDevStageOverlay(taskKey, role, tier string) string { + meta, ok := LookupRDevTask(taskKey) + if !ok { + return "" + } + stageKey := strings.TrimPrefix(taskKey, "rd_") + root := findOverlayRoot() + if root == "" { + return "" + } + names := rdevOverlayNames(stageKey, meta.Phase, string(meta.Kind), tier) + var parts []string + for _, name := range names { + content := readOverlayFile(root, name) + if content == "" { + continue + } + if filtered := filterOverlayByRole(content, role); filtered != "" { + parts = append(parts, filtered) + } + } + return strings.TrimSpace(strings.Join(parts, "\n\n")) +} + +func rdevOverlayNames(stageKey, phase, artifactKind, tier string) []string { + seen := map[string]bool{} + var names []string + add := func(name string) { + name = strings.TrimSpace(name) + if name == "" || seen[name] { + return + } + seen[name] = true + names = append(names, name) + } + for _, path := range []string{ + "overlays/_shared/doc-structure.md", + "overlays/_shared/self-contained-handoff.md", + "overlays/_shared/acceptance-rubric.md", + "overlays/_shared/granularity.md", + "overlays/_shared/anti-truncation.md", + } { + add(path) + } + add("rdev") + if artifactKind != "" && artifactKind != "document" { + add("kind_" + artifactKind) + } + add("rdev_ph" + strings.TrimSpace(phase)) + if strings.HasPrefix(stageKey, "sw_") { + add("rdev_software") + add("kind_software") + } + if ko := rdevInferredKind(stageKey); ko != "" { + add(ko) + } + add(stageKey) + switch strings.ToLower(strings.TrimSpace(tier)) { + case "high", "medium": + add("complexity_" + strings.ToLower(strings.TrimSpace(tier))) + } + switch stageKey { + case "user_needs", "system_reqs": + add("overlays/_shared/ur-rs-expansion.md") + } + if stageKey == "design_vv" || stageKey == "validation_plan" || stageKey == "tech_req" || (strings.HasPrefix(stageKey, "sw_") && strings.Contains(stageKey, "test")) { + add("overlays/_shared/rs-tsys-expansion.md") + } + return names +} + +func rdevInferredKind(stageKey string) string { + switch { + case strings.HasPrefix(stageKey, "risk_"), stageKey == "hazard_analysis": + return "kind_risk" + case strings.Contains(stageKey, "validation"), strings.Contains(stageKey, "verification"), stageKey == "design_vv", stageKey == "user_test", stageKey == "clinical", stageKey == "biocompatibility", stageKey == "emc_safety", stageKey == "cleaning", stageKey == "animal_study": + return "kind_verification" + case strings.HasPrefix(stageKey, "design_"), strings.HasPrefix(stageKey, "system_"), stageKey == "tech_req", stageKey == "user_needs", stageKey == "process_flow", stageKey == "proposal", stageKey == "planning_review", stageKey == "req_review", stageKey == "input_phase_review", stageKey == "output_review": + return "kind_design_record" + default: + return "" + } +} + +func findOverlayRoot() string { + wd, _ := os.Getwd() + candidates := []string{wd} + dir := wd + for i := 0; i < 8; i++ { + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + candidates = append(candidates, dir) + } + if env := strings.TrimSpace(os.Getenv("APP_ROOT")); env != "" { + candidates = append(candidates, env) + } + candidates = append(candidates, "/app") + for _, root := range candidates { + if root == "" { + continue + } + if _, err := os.Stat(filepath.Join(root, "configs", "overlays", "rdev.md")); err == nil { + return root + } + } + return "" +} + +func readOverlayFile(root, name string) string { + name = strings.TrimSpace(name) + if name == "" || strings.Contains(name, "..") { + return "" + } + var rel string + if strings.Contains(name, "/") { + rel = filepath.Join("configs", filepath.FromSlash(name)) + } else { + rel = filepath.Join("configs", "overlays", name+".md") + } + b, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func filterOverlayByRole(text, role string) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + role = strings.ToLower(strings.TrimSpace(role)) + if role == "rdev_leader" { + role = "leader" + } + lines := strings.Split(text, "\n") + hasRole := false + for _, line := range lines { + if _, ok := parseRoleHeading(line); ok { + hasRole = true + break + } + } + if !hasRole { + return text + } + var out []string + include := true + for _, line := range lines { + if r, ok := parseRoleHeading(line); ok { + include = role != "" && r == role + if include { + out = append(out, line) + } + continue + } + if include { + out = append(out, line) + } + } + return strings.TrimSpace(strings.Join(out, "\n")) +} + +func parseRoleHeading(line string) (string, bool) { + trim := strings.TrimSpace(line) + if !strings.HasPrefix(trim, "### ") { + return "", false + } + rest := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(trim, "### "))) + if !strings.HasPrefix(rest, "role:") { + return "", false + } + role := strings.TrimSpace(rest[len("role:"):]) + if role == "" || strings.ContainsAny(role, " \t") { + return "", false + } + return role, true +} + +func rdevTierFromWorkspace(workDir string) string { + b, err := os.ReadFile(filepath.Join(workDir, "blackboard.json")) + if err != nil { + return "" + } + var bb BlackboardState + if json.Unmarshal(b, &bb) != nil { + return "" + } + return rdevComplexityTier(bb.Fields) +} diff --git a/internal/squad/rdev_overlay_test.go b/internal/squad/rdev_overlay_test.go new file mode 100644 index 00000000..0db54380 --- /dev/null +++ b/internal/squad/rdev_overlay_test.go @@ -0,0 +1,152 @@ +package squad + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRDevOverlayFiltersRoleAndComplexity(t *testing.T) { + writer := RDevStageOverlay("rd_planning_review", "writer", "") + if !strings.Contains(writer, "策划评审") { + t.Fatal("planning review writer overlay must include phase 02 discipline") + } + if !strings.Contains(writer, "role:writer") { + t.Fatal("writer overlay must keep the writer section") + } + if strings.Contains(writer, "role:engineer") { + t.Fatal("writer overlay must not include the engineer section") + } + high := RDevStageOverlay("rd_sw_plan", "leader", "high") + if !strings.Contains(high, "高复杂度") { + t.Fatal("high tier must include complexity_high overlay") + } + if !strings.Contains(high, "不适用理由") { + t.Fatal("software stage must include the software overlay") + } + low := RDevStageOverlay("rd_sw_plan", "leader", "low") + if strings.Contains(low, "高复杂度项目策略") { + t.Fatal("low tier must not include the high-complexity overlay") + } +} + +func TestRDevWorkspaceWritesOverlay(t *testing.T) { + tmpDir := t.TempDir() + engine := NewWorkspaceEngine() + ws, err := engine.PrepareWorkspace(WorkspacePrepareRequest{ + WorkDir: filepath.Join(tmpDir, "rd_planning_review"), + TaskKey: "rd_planning_review", + StageTitle: "设计和开发策划评审记录", + ProjectName: "rdev-overlay", + Backend: "stub", + InitialFields: map[string]string{ + "complexity_tier": "medium", + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(filepath.Join(ws.WorkDir, "tasks", "02_writer", "OVERLAY.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "评审记录") { + t.Fatal("writer OVERLAY.md must carry review-minutes discipline") + } + task, err := os.ReadFile(filepath.Join(ws.WorkDir, "tasks", "02_writer", "TASK.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(task), "OVERLAY.md") { + t.Fatal("TASK.md must point at the overlay") + } +} + +func TestRDevQualityGates(t *testing.T) { + gate := NewQualityGate() + ws := t.TempDir() + if err := os.MkdirAll(filepath.Join(ws, "artifacts"), 0o755); err != nil { + t.Fatal(err) + } + badReq := "本规格随机抽取部分需求。\n\nUR-01 能开机。\nUR-03 能关机。\n" + rep, err := gate.Validate(ws, "rd_user_needs", badReq) + if err != nil { + t.Fatal(err) + } + if rep.Passed { + t.Fatalf("gapped and sampled requirements must fail: %+v", rep.Violations) + } + + goodReq := "UR-01 能开机,验证准则为通电检查。\nUR-02 能关机,验证准则为断电检查。\n" + raw := []byte(`[ + {"id":"UR-01","verification_method":"测试","acceptance_criteria":"通电后进入主界面"}, + {"id":"UR-02","verification_method":"测试","acceptance_criteria":"断电后安全停止"} + ]`) + if err := os.WriteFile(filepath.Join(ws, "artifacts", "requirements.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + rep, err = gate.Validate(ws, "rd_user_needs", goodReq) + if err != nil { + t.Fatal(err) + } + if !rep.Passed { + t.Fatalf("consecutive requirements must pass: %+v", rep.Violations) + } + + hollow := "## 问题与决议\n\n同意通过。\n\n| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 |\n| --- | --- | --- | --- | --- | --- |\n| Q-00 | 无 | 同意通过 | 待确认 | 待确认 | 无 |\n\n## 遗留行动项跟踪\n\n| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 |\n| --- | --- | --- | --- | --- | --- |\n| AI-00 | 无 | 待确认 | 待确认 | 关闭 | 无 |\n" + rep, err = gate.Validate(ws, "rd_planning_review", hollow) + if err != nil { + t.Fatal(err) + } + if rep.Passed { + t.Fatal("hollow 同意通过 must fail") + } + + closed := "## 问题与决议\n\n| 编号 | 问题 | 决议 | 责任人 | 期限 | 关闭准则 |\n| --- | --- | --- | --- | --- | --- |\n| Q-01 | 计划未覆盖软件 | 补充软件开发计划 | 张三 | 2026-10-01 | 文件批准 |\n\n## 遗留行动项跟踪\n\n| 行动项 ID | 内容 | 责任人 | 期限 | 状态 | 阻塞影响 |\n| --- | --- | --- | --- | --- | --- |\n| AI-01 | 补软件计划 | 张三 | 2026-10-01 | 打开 | 阻塞设计输入 |\n" + rep, err = gate.Validate(ws, "rd_planning_review", closed) + if err != nil { + t.Fatal(err) + } + if !rep.Passed { + t.Fatalf("closed review must pass: %+v", rep.Violations) + } + + // 问题表之后、同一 ## 节里的法规对照表不能被当成缺责任人的问题行。 + laterTable := strings.TrimSuffix(closed, "## 遗留行动项跟踪\n") + + "### 决议解读\n\n| 法规/标准 | 相关条款 | 本次评审要求 |\n| --- | --- | --- |\n| ISO 13485:2016 | §7.3.1 | 策划应保持记录 |\n\n## 遗留行动项跟踪\n" + + strings.SplitN(closed, "## 遗留行动项跟踪\n", 2)[1] + rep, err = gate.Validate(ws, "rd_planning_review", laterTable) + if err != nil { + t.Fatal(err) + } + if !rep.Passed { + t.Fatalf("later reference table must not fail the issue table: %+v", rep.Violations) + } + sample, err := os.ReadFile("../../.doc/test-reports/assets/2026-09-19-rdev-p3-real-pi/rd_planning_review-final.md") + if err != nil { + t.Fatal(err) + } + for _, issue := range rdevReviewMinutesIssues(string(sample)) { + if strings.Contains(issue, "问题行缺少") { + t.Fatalf("saved P3 sample still flags issue rows: %s", issue) + } + } + + thin := "已收照片 1 张。" + rep, err = gate.Validate(ws, "rd_prototype_photos", thin) + if err != nil { + t.Fatal(err) + } + if rep.Passed { + t.Fatal("upload index without a table must fail") + } + indexed := "| 附件 | 状态 | 缺失影响 |\n| --- | --- | --- |\n| 正面照 | 缺失 | 缺失影响验证拍照,待上传后更新 |\n" + rep, err = gate.Validate(ws, "rd_prototype_photos", indexed) + if err != nil { + t.Fatal(err) + } + if !rep.Passed { + t.Fatalf("indexed upload must pass: %+v", rep.Violations) + } +} diff --git a/internal/squad/rdev_stage_meta.go b/internal/squad/rdev_stage_meta.go new file mode 100644 index 00000000..9579a04b --- /dev/null +++ b/internal/squad/rdev_stage_meta.go @@ -0,0 +1,468 @@ +package squad + +// Code generated from .refs/weichen-agent-system configs. Do not edit by hand. + +type rdevStageHint struct { + Hint string + SkipHint string + Aliases []string +} + +type rdevOverrideStep struct { + Role string + Title string + WhenHigh bool + Parallel bool + Delegate bool +} + +var rdevStageHints = map[string]rdevStageHint{ + "rd_proposal": { + Hint: "按模板编制《项目建议书》(策划)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"项目建议书", "立项建议"}, + }, + "rd_design_plan": { + Hint: "按模板编制《设计开发计划任务书》(策划)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"设计开发计划", "计划任务书", "DHF文件计划"}, + }, + "rd_planning_review": { + Hint: "编制《设计和开发策划评审记录》(策划评审)。写清问题、决议与行动项闭环;不编造签字。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"策划评审", "设计和开发策划评审"}, + }, + "rd_user_needs": { + Hint: "按模板编制《用户需求规格书》(设计输入)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"用户需求", "UN", "用户需求规格书"}, + }, + "rd_regs_stds": { + Hint: "按模板编制《法规和标准清单》:逐项适用性与依据,总述覆盖充分性。指导原则不作强制标准。缺项写「待确认」。", + Aliases: []string{"法规清单", "标准清单"}, + }, + "rd_adverse_events": { + Hint: "按模板编制《同类产品不良事件信息》:检索范围、相关性分析与风险含义结论。无命中亦须写清检索局限。缺项写「待确认」。", + Aliases: []string{"不良事件", "同类不良事件"}, + }, + "rd_system_reqs": { + Hint: "按模板编制《系统需求规格书》(设计输入)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"系统需求", "SR"}, + }, + "rd_risk_plan": { + Hint: "按模板编制《风险管理计划》:风险过程/分析须有明确结论与待确认;不编造无据定量。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"风险管理计划"}, + }, + "rd_hazard_analysis": { + Hint: "按模板编制《安全性问题清单及可能危险分析表》:风险过程/分析须有明确结论与待确认;不编造无据定量。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"危险分析", "危害分析"}, + }, + "rd_sw_plan": { + Hint: "按模板编制《软件开发计划》。有软件则写满并追溯;无软件须完整不适用理由。缺项写「待确认」,不做疗效宣称。 无软件成分时可声明不适用。", + SkipHint: "无软件成分时可声明不适用。", + Aliases: []string{"软件开发计划"}, + }, + "rd_sw_reqs": { + Hint: "按模板编制《软件需求规格书》。有软件则写满并追溯;无软件须完整不适用理由。缺项写「待确认」,不做疗效宣称。 无软件成分时可声明不适用。", + SkipHint: "无软件成分时可声明不适用。", + Aliases: []string{"软件需求"}, + }, + "rd_sw_config": { + Hint: "按模板编制《软件配置及维护计划》。有软件则写满并追溯;无软件须完整不适用理由。缺项写「待确认」,不做疗效宣称。 无软件成分时可声明不适用。", + SkipHint: "无软件成分时可声明不适用。", + Aliases: []string{"软件配置计划"}, + }, + "rd_design_input_spec": { + Hint: "按模板编制《设计输入规格书》(设计输入)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"设计输入规格书"}, + }, + "rd_design_input_list": { + Hint: "按模板编制《设计输入清单》(设计输入)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"设计输入清单"}, + }, + "rd_system_design": { + Hint: "按模板编制《系统设计方案》(设计输入)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"系统设计"}, + }, + "rd_req_review": { + Hint: "编制《需求评审记录》(需求评审)。写清问题、决议与行动项闭环;不编造签字。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"需求评审"}, + }, + "rd_input_phase_review": { + Hint: "编制《设计开发评审记录(阶段3和4)》(输入阶段评审)。写清问题、决议与行动项闭环;不编造签字。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"输入阶段评审", "阶段3和4评审"}, + }, + "rd_drawings_3d": { + Hint: "编制《2D图纸和3D模型》附件索引:列已收/缺失及对后续影响;不伪造附件内容。缺项写「待确认」。", + Aliases: []string{"图纸", "3D模型", "2D图纸"}, + }, + "rd_product_bom": { + Hint: "编制《产品BOM》附件索引:列已收/缺失及对后续影响;不伪造附件内容。缺项写「待确认」。", + Aliases: []string{"产品BOM", "BOM"}, + }, + "rd_ee_bom": { + Hint: "编制《电子电气BOM》附件索引:列已收/缺失及对后续影响;不伪造附件内容。缺项写「待确认」。", + SkipHint: "无电气部分时可声明不适用。", + Aliases: []string{"电子电气BOM", "电气BOM"}, + }, + "rd_pcba": { + Hint: "编制《PCBA原理图及Layout》附件索引:列已收/缺失及对后续影响;不伪造附件内容。缺项写「待确认」。", + SkipHint: "无电路板时可声明不适用。", + Aliases: []string{"PCBA", "原理图", "Layout"}, + }, + "rd_electrical_sys": { + Hint: "编制《系统电气设计图》附件索引:列已收/缺失及对后续影响;不伪造附件内容。缺项写「待确认」。", + SkipHint: "无电气系统时可声明不适用。", + Aliases: []string{"电气设计图"}, + }, + "rd_sw_arch": { + Hint: "按模板编制《软件概要设计方案》。有软件则写满并追溯;无软件须完整不适用理由。缺项写「待确认」,不做疗效宣称。 无软件成分时可声明不适用。", + SkipHint: "无软件成分时可声明不适用。", + Aliases: []string{"软件概要设计"}, + }, + "rd_sw_ui": { + Hint: "按模板编制《软件UI设计文档》。有软件则写满并追溯;无软件须完整不适用理由。缺项写「待确认」,不做疗效宣称。 无软件界面时可声明不适用。", + SkipHint: "无软件界面时可声明不适用。", + Aliases: []string{"软件UI"}, + }, + "rd_purchase_spec": { + Hint: "按模板编制《采购技术规范》(设计输出)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"采购规范"}, + }, + "rd_process_flow": { + Hint: "按模板编制《工艺流程图及作业指导》(设计输出)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"工艺流程", "作业指导"}, + }, + "rd_packaging": { + Hint: "按模板编制《包装图纸和设计要求》(设计输出)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"包装设计"}, + }, + "rd_ifu": { + Hint: "按模板编制《产品使用说明书》(设计输出)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"说明书", "IFU"}, + }, + "rd_incoming_iqc": { + Hint: "按模板编制《原材料进货检验规范》(设计输出)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"进货检验", "IQC"}, + }, + "rd_ipqc": { + Hint: "按模板编制《生产过程检验规范》(设计输出)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"过程检验", "IPQC"}, + }, + "rd_fqc": { + Hint: "按模板编制《成品检验规范》(设计输出)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"成品检验", "FQC"}, + }, + "rd_tech_req": { + Hint: "按模板编制《产品技术要求》(设计输出)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"技术要求"}, + }, + "rd_risk_report": { + Hint: "按模板编制《风险管理报告》:风险过程/分析须有明确结论与待确认;不编造无据定量。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"风险管理报告"}, + }, + "rd_design_output_list": { + Hint: "按模板编制《设计输出清单》(设计输出)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"设计输出清单"}, + }, + "rd_output_review": { + Hint: "编制《设计开发评审记录(阶段6)》(输出评审)。写清问题、决议与行动项闭环;不编造签字。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"输出评审", "阶段6评审"}, + }, + "rd_prototype_photos": { + Hint: "编制《样机照片》附件索引:列已收/缺失及对后续影响;不伪造附件内容。缺项写「待确认」。", + Aliases: []string{"样机照片"}, + }, + "rd_design_vv": { + Hint: "按模板编制《设计验证方案》(验证)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"设计验证", "验证方案"}, + }, + "rd_shelf_life": { + Hint: "按模板编制《货架寿命方案》(验证)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"货架寿命"}, + }, + "rd_lifetime": { + Hint: "按模板编制《产品使用期限研究方案》(验证)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"使用期限"}, + }, + "rd_biocompatibility": { + Hint: "按模板编制《生物学检测方案》(验证)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"生物学评价", "生物相容性"}, + }, + "rd_cleaning": { + Hint: "按模板编制《清洁消毒验证方案》(验证)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"清洁验证", "消毒验证"}, + }, + "rd_emc_safety": { + Hint: "编制《EMC及安规摸底报告》附件索引:列已收/缺失及对后续影响;不伪造附件内容。缺项写「待确认」。", + SkipHint: "无电气部分时可声明不适用。", + Aliases: []string{"EMC", "安规"}, + }, + "rd_risk_report_v": { + Hint: "按模板编制《风险管理报告(验证阶段)》:风险过程/分析须有明确结论与待确认;不编造无据定量。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"验证阶段风险报告"}, + }, + "rd_sw_release": { + Hint: "按模板编制《软件发布清单》。有软件则写满并追溯;无软件须完整不适用理由。缺项写「待确认」,不做疗效宣称。 无软件成分时可声明不适用。", + SkipHint: "无软件成分时可声明不适用。", + Aliases: []string{"软件发布清单"}, + }, + "rd_sw_unit_test": { + Hint: "按模板编制《软件单元测试报告》。有软件则写满并追溯;无软件须完整不适用理由。缺项写「待确认」,不做疗效宣称。 无软件成分时可声明不适用。", + SkipHint: "无软件成分时可声明不适用。", + Aliases: []string{"软件单元测试"}, + }, + "rd_sw_sys_test": { + Hint: "按模板编制《软件系统测试报告》。有软件则写满并追溯;无软件须完整不适用理由。缺项写「待确认」,不做疗效宣称。 无软件成分时可声明不适用。", + SkipHint: "无软件成分时可声明不适用。", + Aliases: []string{"软件系统测试"}, + }, + "rd_qualified_suppliers": { + Hint: "按模板编制《合格供方名录》(验证)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"合格供方", "供方名录"}, + }, + "rd_verification_review": { + Hint: "编制《设计开发评审记录(验证阶段)》(验证评审)。写清问题、决议与行动项闭环;不编造签字。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"验证评审"}, + }, + "rd_dmr_list": { + Hint: "按模板编制《DMR文件清单》(转换)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"DMR清单"}, + }, + "rd_pack_ship": { + Hint: "按模板编制《包装运输方案》(转换)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"包装运输"}, + }, + "rd_risk_report_t": { + Hint: "按模板编制《风险管理报告(转换阶段)》:风险过程/分析须有明确结论与待确认;不编造无据定量。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"转换阶段风险报告"}, + }, + "rd_transfer_review": { + Hint: "编制《设计开发评审记录(转换阶段)》(转换评审)。写清问题、决议与行动项闭环;不编造签字。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"转换评审"}, + }, + "rd_validation_plan": { + Hint: "按模板编制《设计确认方案》(确认)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"确认方案", "设计确认方案"}, + }, + "rd_use_error": { + Hint: "按模板编制《使用错误评估报告》(确认)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"使用错误"}, + }, + "rd_animal_study": { + Hint: "按模板编制《动物试验方案》。不适用时写完整理由。缺项写「待确认」,不做疗效宣称。 不开展动物试验时可声明不适用。", + SkipHint: "不开展动物试验时可声明不适用。", + Aliases: []string{"动物试验"}, + }, + "rd_sw_quality": { + Hint: "按模板编制《软件质量测试报告》。有软件则写满并追溯;无软件须完整不适用理由。缺项写「待确认」,不做疗效宣称。 无软件成分时可声明不适用。", + SkipHint: "无软件成分时可声明不适用。", + Aliases: []string{"软件质量测试"}, + }, + "rd_clinical": { + Hint: "按模板编制《临床试验报告》。不适用时写完整理由。缺项写「待确认」,不做疗效宣称。 不开展临床试验时可声明不适用。", + SkipHint: "不开展临床试验时可声明不适用。", + Aliases: []string{"临床试验", "临床报告"}, + }, + "rd_risk_report_val": { + Hint: "按模板编制《风险管理报告(确认阶段)》:风险过程/分析须有明确结论与待确认;不编造无据定量。缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"确认阶段风险报告"}, + }, + "rd_user_test": { + Hint: "按模板编制《用户测试报告》(确认)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"用户测试"}, + }, + "rd_design_validation_report": { + Hint: "按模板编制《设计确认报告》(设计确认报告)。写满章节并给出本阶段结论与追溯;缺项写「待确认」,不做疗效宣称。", + Aliases: []string{"设计确认报告", "确认报告"}, + }, +} + +var rdevStageOverrides = map[string][]rdevOverrideStep{ + "rd_user_needs": { + {Role: "organizer", Title: "整理 UR 需求池与需求输入", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "可验证用户需求与约束", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写用户需求规格书", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核追溯与定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_system_reqs": { + {Role: "organizer", Title: "对齐 URS 与 prior_docs 需求输入", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "基本法规项与 UR→RS 展开", WhenHigh: false, Parallel: true, Delegate: false}, + {Role: "regulatory", Title: "标准条款核查与排除日志", WhenHigh: true, Parallel: true, Delegate: false}, + {Role: "writer", Title: "撰写系统需求规格书主表", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审查 UR/RS 粒度并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_design_vv": { + {Role: "organizer", Title: "对齐 SRS 与参考文档", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "宽召回并将适用项展开为 RS", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "编写 TSYS 用例与验证方法", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "撰写设计验证方案/记录", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审查 RS/TSYS 与覆盖并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_validation_plan": { + {Role: "organizer", Title: "对齐验证范围与 prior_docs", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与标准验证项核查", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "验证方法与资源要求", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写验证计划", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_tech_req": { + {Role: "organizer", Title: "对齐设计输入与技术约束", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术指标逐条展开", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "撰写技术要求文档", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核粒度并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_sw_unit_test": { + {Role: "organizer", Title: "对齐软件需求与架构", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "单元测试用例与通过准则", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "撰写软件单元测试文档", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_sw_sys_test": { + {Role: "organizer", Title: "对齐软件需求与集成范围", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "软件标准与网络安全核查", WhenHigh: true, Parallel: true, Delegate: false}, + {Role: "engineer", Title: "系统测试用例与验证方法", WhenHigh: false, Parallel: true, Delegate: false}, + {Role: "writer", Title: "撰写软件系统测试文档", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_planning_review": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_regs_stds": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "searcher", Title: "检索适用法规、标准与公开来源", WhenHigh: false, Parallel: false, Delegate: true}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_adverse_events": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "searcher", Title: "检索同类产品不良事件公开信息", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_risk_plan": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_hazard_analysis": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_req_review": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_input_phase_review": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_drawings_3d": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容与可追溯性", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_product_bom": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容与可追溯性", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_ee_bom": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容与可追溯性", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_pcba": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容与可追溯性", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_electrical_sys": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容与可追溯性", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_risk_report": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_output_review": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_prototype_photos": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容与可追溯性", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_biocompatibility": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_emc_safety": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容与可追溯性", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_risk_report_v": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_verification_review": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_risk_report_t": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_transfer_review": { + {Role: "organizer", Title: "整理前序已批准材料与附件", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_clinical": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, + "rd_risk_report_val": { + {Role: "organizer", Title: "抽取材料事实并回填字段", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "engineer", Title: "技术内容", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "regulatory", Title: "法规与风险要点", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "writer", Title: "按模板撰写", WhenHigh: false, Parallel: false, Delegate: false}, + {Role: "leader", Title: "审核并定稿", WhenHigh: false, Parallel: false, Delegate: false}, + }, +} diff --git a/internal/squad/rdev_steps.go b/internal/squad/rdev_steps.go new file mode 100644 index 00000000..e50358c1 --- /dev/null +++ b/internal/squad/rdev_steps.go @@ -0,0 +1,382 @@ +package squad + +import ( + "fmt" + "os" + "strings" +) + +const rdevDHFAccept = "模板写满;结论先行;追溯表必填;缺项标「待确认」;不编造数据/签字/附件" + +func rdevModel() string { + if v := strings.TrimSpace(os.Getenv("SQUAD_MODEL_ID")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("MINIMAX_MODEL_ID")); v != "" { + return v + } + return "MiniMax-M3" +} + +func rdevComplexityTier(fields map[string]string) string { + if fields == nil { + return "" + } + switch strings.ToLower(strings.TrimSpace(fields["complexity_tier"])) { + case "high", "medium", "low": + return strings.ToLower(strings.TrimSpace(fields["complexity_tier"])) + default: + return "" + } +} + +func rdevSquadAgents(taskKey, tier string) []AgentSpec { + if ov, ok := rdevResolvedOverride(taskKey, tier); ok { + return rdevAgentsFromOverride(ov) + } + meta, _ := LookupRDevTask(taskKey) + agents := []AgentSpec{ + { + Role: "organizer", + Name: "材料整理专员", + Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"材料事实抽取", "适用性与缺口识别", "字段回填"}, + ForbiddenActions: []string{"严禁编造试验数据", "严禁伪造签字"}, + }, + } + switch meta.Kind { + case RDevKindReviewMinutes: + agents = append(agents, + AgentSpec{ + Role: "writer", + Name: "文档撰写专员", + Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"按评审记录模板成文", "问题-决议-行动项闭环"}, + }, + rdevLeaderAgent(), + ) + case RDevKindUploadIndex: + agents = append(agents, + AgentSpec{ + Role: "engineer", + Name: "技术专员", + Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"附件索引完整性", "缺失影响分析"}, + }, + AgentSpec{ + Role: "writer", + Name: "文档撰写专员", + Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"按附件清单模板成文", "标明待上传项"}, + }, + rdevLeaderAgent(), + ) + default: + agents = append(agents, + AgentSpec{ + Role: "engineer", + Name: "技术专员", + Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"技术内容与追溯", "设计控制论证"}, + }, + ) + if RDevNeedsRegulatoryAt(taskKey, tier) { + agents = append(agents, AgentSpec{ + Role: "regulatory", + Name: "法规专员", + Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"法规/标准适用性", "软件与风险管理合规要点"}, + }) + } + agents = append(agents, + AgentSpec{ + Role: "writer", + Name: "文档撰写专员", + Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + Responsibilities: []string{"按 RD 模板成文", "结论先行与待确认清单"}, + }, + rdevLeaderAgent(), + ) + } + return agents +} + +func rdevLeaderAgent() AgentSpec { + return AgentSpec{ + Role: "leader", + Name: "研发设计控制负责人", + Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, + BashPermission: "deny", + InstructionsFile: "configs/agents/rdev_leader.md", + Responsibilities: []string{"DHF 完整性终审", "不适用理由审核", "禁止编造检查"}, + ForbiddenActions: []string{"严禁代签", "严禁伪造附件内容"}, + } +} + +func rdevWorkflowSteps(taskKey, tier string) []StepSpec { + meta, ok := LookupRDevTask(taskKey) + kind := RDevKindDocument + title := taskKey + if ok { + kind = meta.Kind + title = meta.Title + } + if ov, found := rdevResolvedOverride(taskKey, tier); found { + return rdevAttachStageHint(taskKey, rdevStepsFromOverride(ov)) + } + + switch kind { + case RDevKindReviewMinutes: + return rdevAttachStageHint(taskKey, []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 rdevAttachStageHint(taskKey, []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 rdevAttachStageHint(taskKey, rdevDocumentSteps(taskKey, title, tier)) + } +} + +func rdevDocumentSteps(taskKey, title, tier 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 RDevNeedsRegulatoryAt(taskKey, tier) { + 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 +} + +func rdevIncludeConditionalRegulatory(taskKey, tier string) bool { + switch strings.ToLower(strings.TrimSpace(tier)) { + case "medium", "low": + return false + case "high": + return true + default: + return RDevNeedsRegulatory(taskKey) + } +} + +func rdevResolvedOverride(taskKey, tier string) ([]rdevOverrideStep, bool) { + raw, ok := rdevStageOverrides[taskKey] + if !ok { + return nil, false + } + out := make([]rdevOverrideStep, 0, len(raw)) + for _, step := range raw { + if step.WhenHigh && !rdevIncludeConditionalRegulatory(taskKey, tier) { + continue + } + out = append(out, step) + } + return out, true +} + +func rdevAgentsFromOverride(steps []rdevOverrideStep) []AgentSpec { + seen := map[string]bool{} + var agents []AgentSpec + for _, step := range steps { + if seen[step.Role] { + continue + } + seen[step.Role] = true + agents = append(agents, rdevAgentForRole(step.Role)) + } + return agents +} + +func rdevAgentForRole(role string) AgentSpec { + switch role { + case "writer": + return AgentSpec{ + Role: "writer", Name: "文档撰写专员", Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, BashPermission: "deny", + Responsibilities: []string{"按 RD 模板成文", "结论先行与待确认清单"}, + } + case "engineer": + return AgentSpec{ + Role: "engineer", Name: "技术专员", Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, BashPermission: "deny", + Responsibilities: []string{"技术内容与追溯", "设计控制论证"}, + } + case "regulatory": + return AgentSpec{ + Role: "regulatory", Name: "法规专员", Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, BashPermission: "deny", + Responsibilities: []string{"法规/标准适用性", "软件与风险管理合规要点"}, + } + case "searcher": + return AgentSpec{ + Role: "searcher", Name: "资料检索专员", Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, BashPermission: "deny", + Responsibilities: []string{"公开资料检索", "出处与日期标注"}, + ForbiddenActions: []string{"严禁编造URL与标准号"}, + } + case "leader": + return rdevLeaderAgent() + default: + return AgentSpec{ + Role: "organizer", Name: "材料整理专员", Model: rdevModel(), + Tools: []string{"read", "write", "edit"}, BashPermission: "deny", + Responsibilities: []string{"材料事实抽取", "适用性与缺口识别", "字段回填"}, + ForbiddenActions: []string{"严禁编造试验数据", "严禁伪造签字"}, + } + } +} + +func rdevStepsFromOverride(steps []rdevOverrideStep) []StepSpec { + out := make([]StepSpec, 0, len(steps)) + var parallelOutputs []string + prev := "" + for i, step := range steps { + id := fmt.Sprintf("step_%02d_%s", i+1, step.Role) + sub := fmt.Sprintf("tasks/%02d_%s", i+1, step.Role) + spec := StepSpec{ + ID: id, Role: step.Role, Title: step.Title, + SubWorkspace: sub, Mode: "execute", Parallel: step.Parallel, + Accept: []string{rdevDHFAccept}, + } + if step.Delegate { + spec.Accept = append([]string{"可委托检索,未命中须写检索局限"}, spec.Accept...) + } + if i > 0 { + if step.Parallel { + if prev != "" { + spec.Needs = []string{prev} + } + } else if len(parallelOutputs) > 0 { + spec.Needs = append([]string(nil), parallelOutputs...) + parallelOutputs = nil + } else if prev != "" { + spec.Needs = []string{prev} + } + } + out = append(out, spec) + file := sub + "/output.md" + if step.Parallel { + parallelOutputs = append(parallelOutputs, file) + } else { + prev = file + parallelOutputs = nil + } + } + return out +} + +func rdevAttachStageHint(taskKey string, steps []StepSpec) []StepSpec { + meta, ok := LookupRDevTask(taskKey) + if !ok { + return steps + } + for i := range steps { + if steps[i].Role == "writer" && meta.Hint != "" { + steps[i].Accept = append(steps[i].Accept, meta.Hint) + } + if steps[i].Role == "organizer" && meta.SkipHint != "" { + steps[i].Accept = append(steps[i].Accept, meta.SkipHint) + } + } + return steps +} diff --git a/internal/squad/service.go b/internal/squad/service.go index 1a250c05..3a99f37f 100644 --- a/internal/squad/service.go +++ b/internal/squad/service.go @@ -13,6 +13,7 @@ import ( "chat-agent-system/internal/config" "chat-agent-system/internal/quote" "chat-agent-system/internal/stream" + "chat-agent-system/internal/workflowdef" ) // TaskMeta 任务元信息 @@ -125,25 +126,43 @@ func NewService(cfg *config.Config) *Service { } } -var SupportedTasks = []TaskMeta{ - {Key: "intake", Title: "用户初始需求收集", ArtifactTitle: "用户初始需求收集表", Description: "梳理客户原始需求、提炼事实清单与缺口"}, - {Key: "research", Title: "调研", ArtifactTitle: "调研报告", Description: "市场竞品同维度对比、法规要点与技术方案初筛"}, - {Key: "feasibility_dev", Title: "设计开发可行性评估", ArtifactTitle: "设计开发可行性评估报告", Description: "工程化差距、技术路线与系统架构可行性推导"}, - {Key: "feasibility_reg", Title: "注册路径可行性评估", ArtifactTitle: "注册路径可行性评估报告", Description: "分类建议、临床评价策略与主要标准检测规划"}, - {Key: "user_requirements", Title: "用户需求表", ArtifactTitle: "用户需求表", Description: "用户角色画像与可追溯需求条目清单"}, - {Key: "system_reqs", Title: "系统需求规格书", ArtifactTitle: "系统需求规格书", Description: "智能产品经理深度提炼,输出系统需求规格书及工程追溯"}, - {Key: "test_manager", Title: "系统验证方案及计划", ArtifactTitle: "系统验证方案及计划", Description: "智能测试经理基于系统需求生成系统测试方案与测试用例"}, - {Key: "prelim_design", Title: "系统初步设计方案", ArtifactTitle: "系统初步设计方案", Description: "子系统架构边界、数据接口与Mermaid上下文流向"}, - {Key: "cdo_quote", Title: "CDO报价单", ArtifactTitle: "CDO报价单(含定价核算 Excel)", Description: "基于公司定价模型的工时拆解、成本核算与法定说明"}, +var baseSupportedTasks = []TaskMeta{ + {Key: "bd_intake", Title: "用户初始需求收集", ArtifactTitle: "用户初始需求收集表", Description: "梳理客户原始需求、提炼事实清单与缺口"}, + {Key: "bd_research", Title: "调研", ArtifactTitle: "调研报告", Description: "市场竞品同维度对比、法规要点与技术方案初筛"}, + {Key: "bd_feasibility_dev", Title: "设计开发可行性评估", ArtifactTitle: "设计开发可行性评估报告", Description: "工程化差距、技术路线与系统架构可行性推导"}, + {Key: "bd_feasibility_reg", Title: "注册路径可行性评估", ArtifactTitle: "注册路径可行性评估报告", Description: "分类建议、临床评价策略与主要标准检测规划"}, + {Key: "bd_user_requirements", Title: "用户需求表", ArtifactTitle: "用户需求表", Description: "用户角色画像与可追溯需求条目清单"}, + {Key: "pm_system_reqs", Title: "系统需求规格书", ArtifactTitle: "系统需求规格书", Description: "智能产品经理深度提炼,输出系统需求规格书及工程追溯"}, + {Key: "tm_system_verification", Title: "系统验证方案及计划", ArtifactTitle: "系统验证方案及计划", Description: "智能测试经理基于系统需求生成系统测试方案与测试用例"}, + {Key: "bd_prelim_design", Title: "系统初步设计方案", ArtifactTitle: "系统初步设计方案", Description: "子系统架构边界、数据接口与Mermaid上下文流向"}, + {Key: "bd_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 } -func TaskArtifactTitle(taskKey string) string { +func taskKnown(taskKey string) bool { + canon := workflowdef.CanonicalTaskKey(taskKey) for _, t := range SupportedTasks { - if t.Key == taskKey { + if t.Key == canon { + return true + } + } + return false +} + +func TaskArtifactTitle(taskKey string) string { + canon := workflowdef.CanonicalTaskKey(taskKey) + for _, t := range SupportedTasks { + if t.Key == canon { return t.ArtifactTitle } } @@ -224,7 +243,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR // 2. 若为 CDO 报价任务,先执行 Excel 报价核算引擎并回填 var attachments []Attachment - if req.TaskKey == "cdo_quote" { + if workflowdef.SameTaskKey(req.TaskKey, "cdo_quote") { att, err := s.handleCDOQuotePreparation(workDir, req) if err == nil && att != nil { attachments = append(attachments, *att) diff --git a/internal/squad/workspace.go b/internal/squad/workspace.go index fa3a9a60..b1599549 100644 --- a/internal/squad/workspace.go +++ b/internal/squad/workspace.go @@ -9,6 +9,7 @@ import ( "time" "chat-agent-system/internal/template" + "chat-agent-system/internal/workflowdef" ) // WorkspacePrepareRequest 工作区准备请求 @@ -468,13 +469,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") @@ -558,6 +566,15 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq taskBuilder.WriteString("\n") } + if strings.HasPrefix(wf.TaskKey, "rd_") { + overlay := RDevStageOverlay(wf.TaskKey, step.Role, rdevTierFromWorkspace(workDir)) + if overlay != "" { + _ = os.WriteFile(filepath.Join(subDir, "OVERLAY.md"), []byte(overlay+"\n"), 0o644) + taskBuilder.WriteString("## 阶段纪律(overlay)\n") + taskBuilder.WriteString("写作纪律与粒度下限见同目录 `OVERLAY.md`。模板只规定章节结构,不得用 overlay 改写章节骨架。\n\n") + } + } + taskBuilder.WriteString("## 交付契约与写作纪律\n") taskBuilder.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`:\n", deliverableName)) taskBuilder.WriteString("1. 严格四段式展开(结论句 → 表格/列表 → 解读 → 支撑出处)。\n") @@ -589,6 +606,10 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq // DefaultSquadAgents 根据任务类型返回默认小队成员编制 func DefaultSquadAgents(taskKey string) []AgentSpec { + if strings.HasPrefix(taskKey, "rd_") { + return rdevSquadAgents(taskKey, "") + } + taskKey = workflowdef.ImplementationTaskKey(taskKey) switch taskKey { case "intake": return []AgentSpec{ @@ -703,7 +724,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Model: "claude-3-7-sonnet", Tools: []string{"read", "write", "edit"}, BashPermission: "deny", - Responsibilities: []string{"严格按 system_reqs.md 模板成文", "正文结构完整", "填满追溯表"}, + Responsibilities: []string{"严格按 pm_system_reqs.md 模板成文", "正文结构完整", "填满追溯表"}, ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, }, { @@ -751,7 +772,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { Model: "claude-3-7-sonnet", Tools: []string{"read", "write", "edit"}, BashPermission: "deny", - Responsibilities: []string{"严格按 test_manager.md 模板成文", "用例主表翔实完整"}, + Responsibilities: []string{"严格按 tm_system_verification.md 模板成文", "用例主表翔实完整"}, ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"}, }, { @@ -796,6 +817,10 @@ func DefaultSquadAgents(taskKey string) []AgentSpec { // DefaultWorkflowSteps 根据任务类型返回最佳实践编排步骤 func DefaultWorkflowSteps(taskKey string) []StepSpec { + if strings.HasPrefix(taskKey, "rd_") { + return rdevWorkflowSteps(taskKey, "") + } + taskKey = workflowdef.ImplementationTaskKey(taskKey) switch taskKey { case "intake": return []StepSpec{ @@ -1077,7 +1102,7 @@ func DefaultWorkflowSteps(taskKey string) []StepSpec { "tasks/03_regulatory/output.md", }, Accept: []string{ - "严格按 test_manager.md 模板生成正式方案文本", + "严格按 tm_system_verification.md 模板生成正式方案文本", "嵌入测试用例清单与覆盖率统计,严禁遗漏上游需求", }, }, 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 new file mode 100644 index 00000000..aea369ae --- /dev/null +++ b/internal/sysadmin/features.go @@ -0,0 +1,77 @@ +package sysadmin + +import "os" + +// Feature 功能开关视图;value 统一用字符串表示。 +type Feature struct { + Key string `json:"key"` + Label string `json:"label"` + Group string `json:"group"` + Value string `json:"value"` + Source string `json:"source"` + RequiresRestart bool `json:"requiresRestart"` + Description string `json:"description"` +} + +type featureSpec struct { + key string // 环境变量名 + label string + group string + fallback string // 组合根未提供生效值时的兜底 + description string +} + +// featureCatalog 覆盖 config.Config 主要开关与各业务包灰度 flag(宁全勿缺)。 +// 生效值由组合根(app)解析后注入,避免本包反向依赖业务模块。 +var featureCatalog = []featureSpec{ + {key: "AGENT_BACKEND", label: "Agent 执行后端", group: "agent", fallback: "pi-agent", description: "对话与任务执行的 Agent 后端(stub / cursor / pi-agent)"}, + {key: "MAX_CONCURRENT_AGENTS", label: "Agent 全局并发上限", group: "agent", fallback: "8", description: "保护 pi-agent / GPU / API 的并发闸"}, + {key: "PI_AGENT_RUNNER_URL", label: "PI Runner 地址", group: "agent", fallback: "", description: "产品内 PI Agent Runner 服务地址;空表示未接入"}, + {key: "ALLOW_IN_MEMORY", label: "允许内存降级模式", group: "system", fallback: "false", description: "数据库不可用时降级内存模式(仅限本地 Demo)"}, + {key: "AUTH_REGISTRATION_ENABLED", label: "开放注册", group: "auth", fallback: "true", description: "是否允许用户自主注册"}, + {key: "AUTH_REQUIRE_APPROVAL", label: "注册需审核", group: "auth", fallback: "false", description: "新注册用户是否需要管理员审核后激活"}, + {key: "FAV_PUBLISHER_ENABLED", label: "FAV 发布器", group: "artifact-vault", fallback: "false", description: "Final Artifact Vault 后台发布器;默认关闭"}, + {key: "FAV_DEGRADED_READ", label: "FAV 降级读", group: "artifact-vault", fallback: "false", description: "FAV 缺失时允许降级读取;默认关闭"}, + {key: "INTENT_V2_SHADOW", label: "意图 V2 影子模式", group: "intent", fallback: "false", description: "V2 意图识别影子运行,仅观测不接管"}, + {key: "INTENT_V2_DECISION", label: "意图 V2 决策", group: "intent", fallback: "false", description: "V2 意图识别接管路由决策"}, + {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: "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 动作适配器"}, + {key: "MULTI_INTENT_U11_DOCGOV", label: "U11 文档治理适配器", group: "intentflow", fallback: "false", description: "docgov 动作适配器"}, + {key: "MULTI_INTENT_U12_RUN_CONTROL", label: "U12 运行控制适配器", group: "intentflow", fallback: "false", description: "workflow/run-control 动作适配器"}, + {key: "SEMANTIC_SUMMARY_SHADOW", label: "语义摘要影子", group: "ctxmgr", fallback: "false", description: "上下文语义摘要影子生成"}, + {key: "SEMANTIC_SUMMARY_REPLACE", label: "语义摘要替换", group: "ctxmgr", fallback: "false", description: "语义摘要替换确定性叙事(保留位,未接线)"}, + {key: "PI_CHANGE_REPORT_NARRATIVE", label: "PI 变更报告叙事", group: "docrevision", fallback: "false", description: "变更报告 PI 叙事增强;默认仅确定性回退"}, +} + +// Features 返回功能开关快照;source 恒为 env,均需重启生效。 +func (s *Service) Features() []Feature { + values := map[string]string{} + if s.featureValues != nil { + values = s.featureValues() + } + out := make([]Feature, 0, len(featureCatalog)) + for _, spec := range featureCatalog { + value, ok := values[spec.key] + if !ok { + if env, set := os.LookupEnv(spec.key); set { + value = env + } else { + value = spec.fallback + } + } + out = append(out, Feature{ + Key: spec.key, + Label: spec.label, + Group: spec.group, + Value: value, + Source: "env", + RequiresRestart: true, + Description: spec.description, + }) + } + return out +} 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/service.go b/internal/sysadmin/service.go new file mode 100644 index 00000000..da636643 --- /dev/null +++ b/internal/sysadmin/service.go @@ -0,0 +1,142 @@ +// Package sysadmin 系统管理台(一期)薄聚合层:只通过小接口读取其他模块的 +// 公开状态,业务包不得 import 本包。 +package sysadmin + +import ( + "context" + "time" + + "chat-agent-system/internal/config" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// StatusProvider 业务服务的数据库/schema 公开状态(*bizdev.Service 满足)。 +type StatusProvider interface { + DatabaseReady() bool + SchemaVersion() int64 + LiveSchemaVersion(ctx context.Context) int64 +} + +// KBProber 知识库在线探针(*kb.Client 满足)。 +type KBProber interface { + Online() bool +} + +// Options 装配根注入的依赖;全部为只读探针,聚合层不持有业务写路径。 +type Options struct { + Pool *pgxpool.Pool + Store SettingsStore // 测试可直接注入;为空且 Pool 非空时使用 PG store + Status StatusProvider + KB KBProber + TaskStoreReady func(ctx context.Context) bool + FeatureValues func() map[string]string // 组合根解析后的功能开关生效值(key 为环境变量名) +} + +// Service 系统管理台聚合服务。 +type Service struct { + cfg *config.Config + store SettingsStore + status StatusProvider + kb KBProber + taskStoreReady func(ctx context.Context) bool + featureValues func() map[string]string + specs []SettingSpec + startedAt time.Time +} + +// NewService 构建聚合服务;cfg 允许为 nil(测试)。 +func NewService(cfg *config.Config, opts Options) *Service { + store := opts.Store + if store == nil && opts.Pool != nil { + store = newPGSettingsStore(opts.Pool) + } + return &Service{ + cfg: cfg, + store: store, + status: opts.Status, + kb: opts.KB, + taskStoreReady: opts.TaskStoreReady, + featureValues: opts.FeatureValues, + specs: defaultSettingSpecs(), + startedAt: time.Now(), + } +} + +// Overview 系统总览,字段对齐前端 camelCase 契约。 +type Overview struct { + Status string `json:"status"` + Env string `json:"env"` + AgentBackend string `json:"agentBackend"` + DatabaseReady bool `json:"databaseReady"` + SchemaVersion int64 `json:"schemaVersion"` + TaskStoreReady bool `json:"taskStoreReady"` + KBOnline bool `json:"kbOnline"` + UptimeSeconds int64 `json:"uptimeSeconds"` +} + +// Overview 聚合 /health 同源信号。 +func (s *Service) Overview(ctx context.Context) Overview { + dbReady := s.status != nil && s.status.DatabaseReady() + schemaVersion := int64(0) + if s.status != nil { + if dbReady { + schemaVersion = s.status.LiveSchemaVersion(ctx) + } else { + schemaVersion = s.status.SchemaVersion() + } + } + taskStoreReady := false + if s.taskStoreReady != nil { + taskStoreReady = s.taskStoreReady(ctx) + } + + status := "ok" + if s.cfg != nil && s.cfg.ContextDbHost != "" && !s.cfg.AllowInMemory && !dbReady { + status = "degraded" + } + + out := Overview{ + Status: status, + DatabaseReady: dbReady, + SchemaVersion: schemaVersion, + TaskStoreReady: taskStoreReady, + KBOnline: s.kb != nil && s.kb.Online(), + UptimeSeconds: int64(time.Since(s.startedAt).Seconds()), + } + if s.cfg != nil { + out.Env = s.cfg.Env + out.AgentBackend = s.cfg.AgentBackend + } + return out +} + +// KBInfo 知识库连接视图;密码等敏感信息绝不输出。 +type KBInfo struct { + Online bool `json:"online"` + Host string `json:"host"` + Port int `json:"port"` + Database string `json:"database"` + User string `json:"user"` + EmbeddingModel string `json:"embeddingModel"` + RerankerEnabled bool `json:"rerankerEnabled"` + SSLMode string `json:"sslMode"` +} + +// KBInfo 返回 KB 连接与模型配置(脱敏)。 +func (s *Service) KBInfo() KBInfo { + out := KBInfo{ + Online: s.kb != nil && s.kb.Online(), + // KbDSN 固定 sslmode=disable,如实暴露 + SSLMode: "disable", + } + if s.cfg != nil { + out.Host = s.cfg.KbDbHost + out.Port = s.cfg.KbDbPort + out.Database = s.cfg.KbDbName + out.User = s.cfg.KbDbUser + out.EmbeddingModel = s.cfg.EmbeddingModel + out.RerankerEnabled = s.cfg.RerankerEndpointURL != "" + } + return out +} diff --git a/internal/sysadmin/service_test.go b/internal/sysadmin/service_test.go new file mode 100644 index 00000000..7827cf17 --- /dev/null +++ b/internal/sysadmin/service_test.go @@ -0,0 +1,151 @@ +package sysadmin + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "chat-agent-system/internal/config" +) + +type fakeStatus struct { + ready bool + version int64 +} + +func (f fakeStatus) DatabaseReady() bool { return f.ready } +func (f fakeStatus) SchemaVersion() int64 { + return f.version +} +func (f fakeStatus) LiveSchemaVersion(_ context.Context) int64 { return f.version + 1 } + +type fakeKB struct{ online bool } + +func (f fakeKB) Online() bool { return f.online } + +func testConfig() *config.Config { + return &config.Config{ + Env: "test", + AgentBackend: "stub", + AllowInMemory: true, + KbDbHost: "kb.internal", + KbDbPort: 5432, + KbDbUser: "regkb", + KbDbPassword: "super-secret", + KbDbName: "regulations", + } +} + +func TestOverview_AggregatesSources(t *testing.T) { + svc := NewService(testConfig(), Options{ + Status: fakeStatus{ready: true, version: 48}, + KB: fakeKB{online: true}, + TaskStoreReady: func(context.Context) bool { + return true + }, + }) + ov := svc.Overview(context.Background()) + if ov.Status != "ok" { + t.Fatalf("expected ok, got %s", ov.Status) + } + if ov.Env != "test" || ov.AgentBackend != "stub" { + t.Fatalf("unexpected env/backend: %+v", ov) + } + if !ov.DatabaseReady || !ov.TaskStoreReady || !ov.KBOnline { + t.Fatalf("expected all ready: %+v", ov) + } + // dbReady 时使用 LiveSchemaVersion(fake 返回 version+1) + if ov.SchemaVersion != 49 { + t.Fatalf("expected live schema version 49, got %d", ov.SchemaVersion) + } + if ov.UptimeSeconds < 0 { + t.Fatalf("uptime must be >= 0, got %d", ov.UptimeSeconds) + } +} + +func TestOverview_DegradedWhenDBDownAndNotInMemory(t *testing.T) { + cfg := testConfig() + cfg.AllowInMemory = false + cfg.ContextDbHost = "127.0.0.1" + svc := NewService(cfg, Options{Status: fakeStatus{ready: false, version: 48}}) + ov := svc.Overview(context.Background()) + if ov.Status != "degraded" { + t.Fatalf("expected degraded, got %s", ov.Status) + } + // 未就绪时回退启动缓存版本 + if ov.SchemaVersion != 48 { + t.Fatalf("expected cached schema version 48, got %d", ov.SchemaVersion) + } + if ov.TaskStoreReady || ov.KBOnline { + t.Fatalf("expected nil probes to report not-ready: %+v", ov) + } +} + +func TestKBInfo_NeverLeaksSecrets(t *testing.T) { + svc := NewService(testConfig(), Options{KB: fakeKB{online: true}}) + info := svc.KBInfo() + if !info.Online { + t.Fatal("expected online") + } + if info.Host != "kb.internal" || info.Port != 5432 || info.Database != "regulations" || info.User != "regkb" { + t.Fatalf("unexpected connection view: %+v", info) + } + if info.SSLMode != "disable" { + t.Fatalf("expected sslMode=disable (KbDSN fixed), got %s", info.SSLMode) + } + if info.RerankerEnabled { + t.Fatal("reranker endpoint empty must report disabled") + } + // 结构体无密码字段;序列化结果也不得出现密码值 + raw, err := json.Marshal(info) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if s := string(raw); strings.Contains(s, "super-secret") || strings.Contains(s, "assword") { + t.Fatalf("KBInfo leaks secret material: %s", s) + } +} + +func TestFeatures_CoversCatalogAndInjectedValues(t *testing.T) { + svc := NewService(testConfig(), Options{ + FeatureValues: func() map[string]string { + return map[string]string{ + "AGENT_BACKEND": "stub", + "FAV_PUBLISHER_ENABLED": "true", + "MULTI_INTENT_PLAN_TIER": "shadow", + } + }, + }) + features := svc.Features() + if len(features) != len(featureCatalog) { + t.Fatalf("expected %d features, got %d", len(featureCatalog), len(features)) + } + byKey := map[string]Feature{} + for _, f := range features { + byKey[f.Key] = f + if f.Source != "env" || !f.RequiresRestart { + t.Fatalf("feature %s must be env-sourced and restart-bound: %+v", f.Key, f) + } + if f.Label == "" || f.Group == "" { + t.Fatalf("feature %s missing label/group: %+v", f.Key, f) + } + } + if byKey["FAV_PUBLISHER_ENABLED"].Value != "true" { + t.Fatalf("expected injected FAV value, got %+v", byKey["FAV_PUBLISHER_ENABLED"]) + } + if byKey["FAV_PUBLISHER_ENABLED"].Group != "artifact-vault" { + t.Fatalf("unexpected group: %+v", byKey["FAV_PUBLISHER_ENABLED"]) + } + if byKey["MULTI_INTENT_PLAN_TIER"].Value != "shadow" { + t.Fatalf("expected injected plan tier, got %+v", byKey["MULTI_INTENT_PLAN_TIER"]) + } + if byKey["INTENT_V2_SHADOW"].Value != "false" { + t.Fatalf("expected catalog fallback false, got %+v", byKey["INTENT_V2_SHADOW"]) + } + for _, key := range []string{"AGENT_BACKEND", "AUTH_REGISTRATION_ENABLED", "FAV_DEGRADED_READ", "SEMANTIC_SUMMARY_SHADOW", "PI_CHANGE_REPORT_NARRATIVE"} { + if _, ok := byKey[key]; !ok { + t.Fatalf("catalog must cover %s", key) + } + } +} diff --git a/internal/sysadmin/settings.go b/internal/sysadmin/settings.go new file mode 100644 index 00000000..89f6a2c5 --- /dev/null +++ b/internal/sysadmin/settings.go @@ -0,0 +1,252 @@ +package sysadmin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "time" +) + +// 设置读写错误;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") + ErrExpectedRevisionRequired = errors.New("sysadmin: expected_revision required") +) + +// 设置值类型。 +const ( + SettingTypeString = "string" + SettingTypeBool = "bool" + SettingTypeNumber = "number" +) + +// SettingSpec 白名单注册表条目;只有登记在册的 key 才可读写。 +type SettingSpec struct { + Key string + Label string + Group string + Type string + Default any + Writable bool + RequiresRestart bool + EnvVar string // 可选 env 中间层:DB > env > default + Description string +} + +// Setting 设置视图,字段对齐前端 camelCase 契约。 +type Setting struct { + Key string `json:"key"` + Label string `json:"label"` + Group string `json:"group"` + Type string `json:"type"` + Value any `json:"value"` + Default any `json:"default"` + 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: "全站维护公告横幅文案;空字符串表示关闭"}, + {Key: "ui.read_only_mode", Label: "只读模式提示", Group: "ui", Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, Description: "开启后前端进入只读提示模式,阻断写操作入口"}, + {Key: "chat.default_session_role", Label: "新会话默认职责", Group: "chat", Type: SettingTypeString, Default: "engineer", Writable: true, RequiresRestart: false, Description: "未显式指定职责时新会话的默认角色"}, + {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: "授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。", + }, + } +} + +func (s *Service) findSpec(key string) (SettingSpec, bool) { + for _, spec := range s.specs { + if spec.Key == key { + return spec, true + } + } + return SettingSpec{}, false +} + +// ListSettings 返回白名单全部设置;读优先级 DB > env > default。 +func (s *Service) ListSettings(ctx context.Context) ([]Setting, error) { + out := make([]Setting, 0, len(s.specs)) + for _, spec := range s.specs { + setting, err := s.resolve(ctx, spec) + if err != nil { + return nil, err + } + out = append(out, setting) + } + return out, nil +} + +// UpdateSetting 校验并写入白名单设置,返回更新后的视图。 +// 兼容旧调用:非版本化 key 可走此入口;版本化策略 key 必须带 ExpectedRevision,请用 UpdateSettingEx。 +func (s *Service) UpdateSetting(ctx context.Context, key string, raw json.RawMessage, updatedBy int64) (Setting, error) { + 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, in.Value); err != nil { + return Setting{}, err + } + if s.store == nil { + return Setting{}, ErrStoreUnavailable + } + 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) +} + +// resolve 按 DB > env > default 解析单个设置的当前值。 +func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error) { + setting := Setting{ + Key: spec.Key, + Label: spec.Label, + Group: spec.Group, + Type: spec.Type, + Value: spec.Default, + Default: spec.Default, + Writable: spec.Writable, + RequiresRestart: spec.RequiresRestart, + Description: spec.Description, + } + if spec.EnvVar != "" { + if raw, set := os.LookupEnv(spec.EnvVar); set { + if v, err := parseSettingValue(spec.Type, raw); err == nil { + setting.Value = v + } + } + } + if s.store == nil { + return setting, nil + } + raw, updatedBy, updatedAt, revision, err := s.store.Get(ctx, spec.Key) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return setting, nil + } + return Setting{}, err + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + 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 +} + +// validateSettingValue 按声明类型校验 JSON 值。 +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 + if err := json.Unmarshal(raw, &v); err != nil { + return ErrInvalidSettingValue + } + case SettingTypeBool: + var v bool + 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: + var v float64 + if err := json.Unmarshal(raw, &v); err != nil { + return ErrInvalidSettingValue + } + default: + return ErrInvalidSettingValue + } + return nil +} + +// parseSettingValue 将 env 字符串按类型解析。 +func parseSettingValue(valueType, raw string) (any, error) { + switch valueType { + case SettingTypeString: + return raw, nil + case SettingTypeBool: + v, err := strconv.ParseBool(raw) + if err != nil { + return nil, err + } + return v, nil + case SettingTypeNumber: + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, err + } + return v, nil + default: + return nil, ErrInvalidSettingValue + } +} diff --git a/internal/sysadmin/settings_test.go b/internal/sysadmin/settings_test.go new file mode 100644 index 00000000..aa0265ee --- /dev/null +++ b/internal/sysadmin/settings_test.go @@ -0,0 +1,258 @@ +package sysadmin + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" +) + +// memStore 进程内 SettingsStore,用于读优先级与写路径测试。 +type memStore struct { + 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, int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + entry, ok := m.items[key] + if !ok { + return nil, nil, nil, 0, ErrSettingNotFound + } + at := entry.updatedAt + 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, int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + at := time.Now() + 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 { + t.Helper() + for _, s := range settings { + if s.Key == key { + return s + } + } + t.Fatalf("setting %s not found", key) + return Setting{} +} + +func TestListSettings_FallsBackToDefaultWithoutStore(t *testing.T) { + svc := NewService(nil, Options{}) + settings, err := svc.ListSettings(context.Background()) + if err != nil { + t.Fatalf("ListSettings: %v", err) + } + if len(settings) != len(defaultSettingSpecs()) { + t.Fatalf("expected %d settings, got %d", len(defaultSettingSpecs()), len(settings)) + } + notice := findSetting(t, settings, "ui.maintenance_notice") + if notice.Value != "" || notice.Default != "" { + t.Fatalf("expected empty default notice, got value=%v default=%v", notice.Value, notice.Default) + } + if notice.UpdatedAt != nil || notice.UpdatedBy != nil { + t.Fatalf("default-resolved setting must not carry update metadata: %+v", notice) + } + reminder := findSetting(t, settings, "notifications.stage_advance_reminder") + if reminder.Value != true { + t.Fatalf("expected bool default true, got %v", reminder.Value) + } +} + +func TestListSettings_DBOverridesDefault(t *testing.T) { + store := newMemStore() + 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}) + settings, err := svc.ListSettings(context.Background()) + if err != nil { + t.Fatalf("ListSettings: %v", err) + } + notice := findSetting(t, settings, "ui.maintenance_notice") + if notice.Value != "停机维护中" { + t.Fatalf("expected DB value, got %v", notice.Value) + } + if notice.UpdatedBy == nil || *notice.UpdatedBy != 7 { + t.Fatalf("expected updatedBy=7, got %+v", notice.UpdatedBy) + } + if notice.UpdatedAt == nil { + t.Fatal("expected updatedAt from DB row") + } + // 未写入的 key 仍回退 default + limit := findSetting(t, settings, "kb.default_search_limit") + if limit.Value != 8.0 { + t.Fatalf("expected default 8, got %v", limit.Value) + } +} + +func TestListSettings_EnvLayerBetweenDBAndDefault(t *testing.T) { + t.Setenv("SYSADMIN_TEST_FLAG", "true") + specs := []SettingSpec{ + {Key: "test.env_flag", Label: "env 中间层", Group: "test", Type: SettingTypeBool, Default: false, Writable: true, EnvVar: "SYSADMIN_TEST_FLAG"}, + } + svc := &Service{specs: specs} + settings, err := svc.ListSettings(context.Background()) + if err != nil { + t.Fatalf("ListSettings: %v", err) + } + if settings[0].Value != true { + t.Fatalf("expected env override true, got %v", settings[0].Value) + } + + // DB 优先于 env + store := newMemStore() + 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} + settings, err = svc.ListSettings(context.Background()) + if err != nil { + t.Fatalf("ListSettings: %v", err) + } + if settings[0].Value != false { + t.Fatalf("expected DB to win over env, got %v", settings[0].Value) + } +} + +func TestUpdateSetting_UnknownKey(t *testing.T) { + svc := NewService(nil, Options{Store: newMemStore()}) + _, err := svc.UpdateSetting(context.Background(), "no.such.key", json.RawMessage(`"x"`), 1) + if !errors.Is(err, ErrUnknownSetting) { + t.Fatalf("expected ErrUnknownSetting, got %v", err) + } +} + +func TestUpdateSetting_ReadOnlyRejected(t *testing.T) { + specs := []SettingSpec{ + {Key: "test.locked", Label: "只读项", Group: "test", Type: SettingTypeString, Default: "a", Writable: false}, + } + svc := &Service{specs: specs, store: newMemStore()} + _, err := svc.UpdateSetting(context.Background(), "test.locked", json.RawMessage(`"b"`), 1) + if !errors.Is(err, ErrSettingReadOnly) { + t.Fatalf("expected ErrSettingReadOnly, got %v", err) + } +} + +func TestUpdateSetting_TypeValidation(t *testing.T) { + svc := NewService(nil, Options{Store: newMemStore()}) + cases := []struct { + key string + raw string + }{ + {"ui.maintenance_notice", `123`}, // string 声明收到 number + {"ui.read_only_mode", `"true"`}, // bool 声明收到 string + {"kb.default_search_limit", `"8"`}, // number 声明收到 string + {"ui.maintenance_notice", ``}, // 空 body value + {"ui.maintenance_notice", `{"a":1}`}, // string 声明收到 object + } + for _, tc := range cases { + _, err := svc.UpdateSetting(context.Background(), tc.key, json.RawMessage(tc.raw), 1) + if !errors.Is(err, ErrInvalidSettingValue) { + t.Fatalf("%s <- %s: expected ErrInvalidSettingValue, got %v", tc.key, tc.raw, err) + } + } +} + +func TestUpdateSetting_WithoutStore(t *testing.T) { + svc := NewService(nil, Options{}) + _, err := svc.UpdateSetting(context.Background(), "ui.maintenance_notice", json.RawMessage(`"x"`), 1) + if !errors.Is(err, ErrStoreUnavailable) { + t.Fatalf("expected ErrStoreUnavailable, got %v", err) + } +} + +func TestUpdateSetting_PersistsAndReturnsUpdatedView(t *testing.T) { + svc := NewService(nil, Options{Store: newMemStore()}) + setting, err := svc.UpdateSetting(context.Background(), "kb.default_search_limit", json.RawMessage(`12`), 42) + if err != nil { + t.Fatalf("UpdateSetting: %v", err) + } + if setting.Value != 12.0 { + t.Fatalf("expected 12, got %v", setting.Value) + } + if setting.Default != 8.0 { + t.Fatalf("default must stay 8, got %v", setting.Default) + } + if setting.UpdatedBy == nil || *setting.UpdatedBy != 42 { + t.Fatalf("expected updatedBy=42, got %+v", setting.UpdatedBy) + } + if setting.UpdatedAt == nil { + t.Fatal("expected updatedAt after write") + } + + // 覆盖写:值更新、审计字段跟随 + setting, err = svc.UpdateSetting(context.Background(), "kb.default_search_limit", json.RawMessage(`20`), 43) + if err != nil { + t.Fatalf("second UpdateSetting: %v", err) + } + if setting.Value != 20.0 || setting.UpdatedBy == nil || *setting.UpdatedBy != 43 { + t.Fatalf("expected overwrite (20, by 43), got (%v, %+v)", setting.Value, setting.UpdatedBy) + } +} diff --git a/internal/sysadmin/store.go b/internal/sysadmin/store.go new file mode 100644 index 00000000..475a0e33 --- /dev/null +++ b/internal/sysadmin/store.go @@ -0,0 +1,141 @@ +package sysadmin + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// 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, 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 { + pool *pgxpool.Pool +} + +func newPGSettingsStore(pool *pgxpool.Pool) SettingsStore { + return &pgSettingsStore{pool: pool} +} + +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, 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, 0, ErrSettingNotFound + } + return nil, nil, nil, 0, err + } + return json.RawMessage(raw), updatedBy, &updatedAt, revision, nil +} + +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, 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, 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 new file mode 100644 index 00000000..c37b4ae7 --- /dev/null +++ b/internal/sysadmin/store_pg_test.go @@ -0,0 +1,142 @@ +package sysadmin + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "chat-agent-system/internal/db" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestPGSettingsStore_RoundTrip(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) + key := "ui.maintenance_notice" + 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 { + t.Fatalf("upsert: %v", err) + } + raw, updatedBy, updatedAt, revision, err := store.Get(ctx, key) + if err != nil { + t.Fatalf("get: %v", err) + } + if string(raw) != `"维护中"` { + t.Fatalf("unexpected value: %s", raw) + } + if updatedBy == nil || *updatedBy != 1 { + t.Fatalf("expected updatedBy=1, got %+v", updatedBy) + } + if updatedAt == nil { + t.Fatal("expected updatedAt") + } + 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) + if err != nil { + t.Fatalf("get after overwrite: %v", err) + } + if string(raw) != `"恢复"` || updatedBy == nil || *updatedBy != 2 { + 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/docker_context_test.go b/internal/template/docker_context_test.go index 5cdc0476..f7c1255d 100644 --- a/internal/template/docker_context_test.go +++ b/internal/template/docker_context_test.go @@ -71,12 +71,12 @@ func TestGetDocTemplate_AppRootLayout(t *testing.T) { if err := os.MkdirAll(dest, 0o755); err != nil { t.Fatal(err) } - src := filepath.Join(root, "templates", "docs", "prelim_design.md") + src := filepath.Join(root, "templates", "docs", "bd_prelim_design.md") body, err := os.ReadFile(src) if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dest, "prelim_design.md"), body, 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dest, "bd_prelim_design.md"), body, 0o644); err != nil { t.Fatal(err) } agentDir := filepath.Join(app, "configs", "agents") @@ -132,10 +132,10 @@ func TestDockerBuildContext_TemplatesDocsReadable(t *testing.T) { _ = exec.Command("docker", "rmi", "-f", tag).Run() }) - cat := exec.Command("docker", "run", "--rm", "--entrypoint", "cat", tag, "/app/templates/docs/prelim_design.md") + cat := exec.Command("docker", "run", "--rm", "--entrypoint", "cat", tag, "/app/templates/docs/bd_prelim_design.md") body, err := runTimed(cat, 30*time.Second) if err != nil { - t.Fatalf("image missing /app/templates/docs/prelim_design.md: %v\n%s", err, body) + t.Fatalf("image missing /app/templates/docs/bd_prelim_design.md: %v\n%s", err, body) } if !bytes.Contains(body, []byte("系统初步设计方案")) || !bytes.Contains(body, []byte("子系统划分")) { t.Fatalf("image template is not the official prelim_design (%d bytes)", len(body)) diff --git a/internal/template/loader.go b/internal/template/loader.go index 1b93e5d6..c33ef283 100644 --- a/internal/template/loader.go +++ b/internal/template/loader.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "chat-agent-system/internal/workflowdef" "chat-agent-system/templates" ) @@ -42,25 +43,54 @@ func GetDocTemplate(name string) (string, error) { if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { return "", fmt.Errorf("read doc template: invalid name %q", name) } - if b, err := templates.DocTemplates.ReadFile("docs/" + name + ".md"); err == nil && len(strings.TrimSpace(string(b))) > 0 { - return string(b), nil + for _, candidate := range templateNames(name) { + if b, err := templates.DocTemplates.ReadFile("docs/" + candidate + ".md"); err == nil && len(strings.TrimSpace(string(b))) > 0 { + return string(b), nil + } + root := findProjectRoot() + path := filepath.Join(root, "templates", "docs", candidate+".md") + b, err := os.ReadFile(path) + if err == nil && len(strings.TrimSpace(string(b))) > 0 { + return string(b), nil + } } - root := findProjectRoot() - path := filepath.Join(root, "templates", "docs", name+".md") - b, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read doc template %s: %w", name, err) + return "", fmt.Errorf("read doc template %s: file not found", name) +} + +func templateNames(name string) []string { + names := []string{name} + if canonical := workflowdef.CanonicalTaskKey(name); canonical != name { + names = append(names, canonical) } - return string(b), nil + if legacy := workflowdef.ImplementationTaskKey(name); legacy != name { + names = append(names, legacy) + } + return names } 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..52f2a2dc 100644 --- a/internal/workflow/registry.go +++ b/internal/workflow/registry.go @@ -4,24 +4,29 @@ import "fmt" // ExecutorRegistry 执行器注册表(schema v2 发布校验与运行时解析)。 var ExecutorRegistry = map[string]struct{}{ - "bizdev.stage.v1": {}, - "squad.system_reqs.v1": {}, - "squad.test_manager.v1": {}, - "bizdev.intake.v1": {}, - "bizdev.research.v1": {}, - "bizdev.feasibility.v1": {}, - "bizdev.feasibility_dev.v1": {}, - "bizdev.user_requirements.v1": {}, - "bizdev.prelim_design.v1": {}, - "bizdev.cdo_quote.v1": {}, - "software.doc.v1": {}, + "bizdev.stage.v1": {}, + "squad.system_reqs.v1": {}, + "squad.test_manager.v1": {}, + "squad.rdev_doc.v1": {}, + "bizdev.intake.v1": {}, + "bizdev.research.v1": {}, + "bizdev.feasibility.v1": {}, + "bizdev.feasibility_dev.v1": {}, + "bizdev.user_requirements.v1": {}, + "bizdev.prelim_design.v1": {}, + "bizdev.cdo_quote.v1": {}, + "software.doc.v1": {}, } // QualityGateRegistry 确定性质量门禁注册表。 var QualityGateRegistry = map[string]struct{}{ - "quality.system_reqs.v2": {}, - "quality.test_manager.v2": {}, - "quality.generic.v1": {}, + "quality.system_reqs.v2": {}, + "quality.test_manager.v2": {}, + "quality.generic.v1": {}, + "quality.rd_user_needs.v1": {}, + "quality.rd_system_reqs.v1": {}, + "quality.rd_review_minutes.v1": {}, + "quality.rd_upload_index.v1": {}, } // ValidateExecutorKey 校验执行器已注册。 diff --git a/internal/workflow/runtime/orchestrator.go b/internal/workflow/runtime/orchestrator.go index 7431c17a..43ccc717 100644 --- a/internal/workflow/runtime/orchestrator.go +++ b/internal/workflow/runtime/orchestrator.go @@ -66,6 +66,7 @@ func (o *Orchestrator) ResolveDocTask(ctx context.Context, projectID, docTaskIns res.TemplateVersion = def.TemplateVersion } } + matchedDuty := false for _, stPlan := range plan.Stages { if stPlan.StageKey != st.StageKey { continue @@ -77,8 +78,12 @@ func (o *Orchestrator) ResolveDocTask(ctx context.Context, projectID, docTaskIns res.AuthorUserID = dtPlan.Duties.AuthorUserID res.ReviewerUserID = dtPlan.Duties.ReviewerUserID res.ApproverUserID = dtPlan.Duties.ApproverUserID + matchedDuty = true } } + if !matchedDuty { + return nil, fmt.Errorf("orchestrator: frozen plan has no duties for stage %s task %s", st.StageKey, dt.TaskKey) + } return res, nil } } 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/keyspace.go b/internal/workflowdef/keyspace.go new file mode 100644 index 00000000..779a0965 --- /dev/null +++ b/internal/workflowdef/keyspace.go @@ -0,0 +1,98 @@ +package workflowdef + +import ( + "regexp" + "strings" +) + +// legacyToCanonical 是 D12 的旧任务键 → 新任务键。 +// 冻结实例继续持有旧键;新发布的清单使用新键。两边必须指向同一套步骤和模板。 +var legacyToCanonical = map[string]string{ + "intake": "bd_intake", + "research": "bd_research", + "feasibility_dev": "bd_feasibility_dev", + "feasibility_reg": "bd_feasibility_reg", + "user_requirements": "bd_user_requirements", + "prelim_design": "bd_prelim_design", + "cdo_quote": "bd_cdo_quote", + "system_reqs": "pm_system_reqs", + "test_manager": "tm_system_verification", + "software_charter": "sw_software_charter", + "source_inventory": "sw_source_inventory", + "current_state_audit": "sw_current_state_audit", + "architecture_baseline": "sw_architecture_baseline", + "software_risk_register": "sw_software_risk_register", + "product_requirements": "sw_product_requirements", + "scope_backlog": "sw_scope_backlog", + "target_architecture": "sw_target_architecture", + "decision_log": "sw_decision_log", + "bootstrap_roadmap": "sw_bootstrap_roadmap", + "verification_strategy": "sw_verification_strategy", + "operations_baseline": "sw_operations_baseline", + "bootstrap_readiness": "sw_bootstrap_readiness", + "change_brief": "sw_change_brief", + "implementation_plan": "sw_implementation_plan", + "change_manifest": "sw_change_manifest", + "adr_delta": "sw_adr_delta", + "test_report": "sw_test_report", + "security_review": "sw_security_review", + "release_plan": "sw_release_plan", + "release_notes": "sw_release_notes", + "retrospective": "sw_retrospective", + "backlog_delta": "sw_backlog_delta", +} + +var canonicalToLegacy map[string]string + +func init() { + canonicalToLegacy = make(map[string]string, len(legacyToCanonical)) + for legacy, canonical := range legacyToCanonical { + canonicalToLegacy[canonical] = legacy + } +} + +// CanonicalTaskKey 把旧键收成发布用的新键。未知键(含 rd_*)原样返回。 +func CanonicalTaskKey(key string) string { + key = strings.TrimSpace(key) + if canonical, ok := legacyToCanonical[key]; ok { + return canonical + } + return key +} + +// ImplementationTaskKey 把新键收成现有步骤开关仍使用的旧键。 +// 存量执行路径不改写;新键只是别名。 +func ImplementationTaskKey(key string) string { + key = strings.TrimSpace(key) + if legacy, ok := canonicalToLegacy[key]; ok { + return legacy + } + return key +} + +// SameTaskKey 判断两个键是否同一任务(旧键与新键视为相同)。 +func SameTaskKey(a, b string) bool { + ca := CanonicalTaskKey(a) + cb := CanonicalTaskKey(b) + return ca != "" && ca == cb +} + +var namespacedDocID = regexp.MustCompile(`^(doc_proj\d+_)(.+)$`) + +// AlternateDocIDs 给出同一文档的另一种键名形式。 +// 账本不批量迁移:旧 ID 仍可查,新 ID 也要能指回旧行。 +func AlternateDocIDs(docID string) []string { + m := namespacedDocID.FindStringSubmatch(strings.TrimSpace(docID)) + if len(m) != 3 { + return nil + } + prefix, suffix := m[1], m[2] + var out []string + if canonical := CanonicalTaskKey(suffix); canonical != suffix { + out = append(out, prefix+canonical) + } + if legacy := ImplementationTaskKey(suffix); legacy != suffix { + out = append(out, prefix+legacy) + } + return out +} diff --git a/internal/workflowdef/keyspace_test.go b/internal/workflowdef/keyspace_test.go new file mode 100644 index 00000000..3dab1d4f --- /dev/null +++ b/internal/workflowdef/keyspace_test.go @@ -0,0 +1,93 @@ +package workflowdef + +import ( + "strings" + "testing" +) + +func TestSameTaskKey_LegacyAndCanonical(t *testing.T) { + pairs := [][2]string{ + {"intake", "bd_intake"}, + {"system_reqs", "pm_system_reqs"}, + {"test_manager", "tm_system_verification"}, + {"software_charter", "sw_software_charter"}, + {"change_brief", "sw_change_brief"}, + } + for _, pair := range pairs { + if !SameTaskKey(pair[0], pair[1]) { + t.Fatalf("expected %s == %s", pair[0], pair[1]) + } + if CanonicalTaskKey(pair[0]) != pair[1] { + t.Fatalf("canonical %s = %s, want %s", pair[0], CanonicalTaskKey(pair[0]), pair[1]) + } + if ImplementationTaskKey(pair[1]) != pair[0] { + t.Fatalf("implementation %s = %s, want %s", pair[1], ImplementationTaskKey(pair[1]), pair[0]) + } + } + if SameTaskKey("intake", "research") { + t.Fatal("intake and research must stay distinct") + } + if CanonicalTaskKey("rd_proposal") != "rd_proposal" { + t.Fatal("rd_ keys are already namespaced") + } +} + +func TestAlternateDocIDs_RoundTrip(t *testing.T) { + alts := AlternateDocIDs("doc_proj56_intake") + if len(alts) != 1 || alts[0] != "doc_proj56_bd_intake" { + t.Fatalf("old id aliases = %v", alts) + } + back := AlternateDocIDs("doc_proj56_bd_intake") + if len(back) != 1 || back[0] != "doc_proj56_intake" { + t.Fatalf("new id aliases = %v", back) + } + if got := AlternateDocIDs("doc_proj56_rd_proposal"); len(got) != 0 { + t.Fatalf("rd_ doc id should not alias, got %v", got) + } +} + +func TestPublishedTaskKeysAreNamespacedAndDisjoint(t *testing.T) { + files := map[string]string{ + "main.v2.json": "bd_", + "product_manager.v2.json": "pm_", + "test_manager.v2.json": "tm_", + "rdev.v2.json": "rd_", + "software_bootstrap.v2.json": "sw_", + "software_delivery.v2.json": "sw_", + } + seen := map[string]string{} + for file, prefix := range files { + def, err := LoadManifestDefinition(file) + if err != nil { + t.Fatal(err) + } + for _, stage := range def.Stages { + for _, task := range stage.DocTasks { + key := task.Key + if !strings.HasPrefix(key, prefix) { + t.Fatalf("%s task %s missing prefix %s", file, key, prefix) + } + if prev, ok := seen[key]; ok { + t.Fatalf("task key %s intersects %s and %s", key, prev, file) + } + seen[key] = file + if task.OutputDocType != key { + t.Fatalf("%s output_doc_type %s != key %s", file, task.OutputDocType, key) + } + if prefix == "rd_" { + if ImplementationTaskKey(key) != key { + t.Fatalf("rd_ key %s must not alias", key) + } + continue + } + legacy := ImplementationTaskKey(key) + if legacy == key { + t.Fatalf("%s has no legacy alias", key) + } + if CanonicalTaskKey(legacy) != key { + t.Fatalf("legacy %s does not round-trip to %s", legacy, key) + } + } + } + } +} 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/main.v2.json b/internal/workflowdef/manifests/main.v2.json index d6b78a76..17d9c9c6 100644 --- a/internal/workflowdef/manifests/main.v2.json +++ b/internal/workflowdef/manifests/main.v2.json @@ -1,24 +1,29 @@ { "schema_version": 2, "workflow_key": "main", - "product_domains": ["medical_device", "non_medical_software"], + "product_domains": [ + "medical_device", + "non_medical_software" + ], "stages": [ { "key": "V0.1_triage", "name": "V0.1 需求摸底", "position": 10, "optional": false, - "doc_tasks": [{ - "key": "intake", - "title": "用户初始需求收集表", - "default_enabled": true, - "creator_configurable": true, - "blocking_when_enabled": true, - "executor_key": "bizdev.intake.v1", - "quality_gate_key": "quality.generic.v1", - "output_doc_type": "intake", - "output_doc_title": "用户初始需求收集表" - }] + "doc_tasks": [ + { + "key": "bd_intake", + "title": "用户初始需求收集表", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.intake.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "bd_intake", + "output_doc_title": "用户初始需求收集表" + } + ] }, { "key": "V0.5_feasibility", @@ -26,11 +31,61 @@ "position": 20, "optional": false, "doc_tasks": [ - {"key": "research", "title": "调研报告", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.research.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "research", "output_doc_title": "调研报告"}, - {"key": "feasibility_dev", "title": "设计开发可行性评估报告", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.feasibility_dev.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "feasibility_dev", "output_doc_title": "设计开发可行性评估报告"}, - {"key": "feasibility_reg", "title": "注册路径可行性评估报告", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.feasibility.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "feasibility_reg", "output_doc_title": "注册路径可行性评估报告"}, - {"key": "user_requirements", "title": "用户需求表", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.user_requirements.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "user_requirements", "output_doc_title": "用户需求表"}, - {"key": "prelim_design", "title": "系统初步设计方案", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.prelim_design.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "prelim_design", "output_doc_title": "系统初步设计方案"} + { + "key": "bd_research", + "title": "调研报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.research.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "bd_research", + "output_doc_title": "调研报告" + }, + { + "key": "bd_feasibility_dev", + "title": "设计开发可行性评估报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.feasibility_dev.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "bd_feasibility_dev", + "output_doc_title": "设计开发可行性评估报告" + }, + { + "key": "bd_feasibility_reg", + "title": "注册路径可行性评估报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.feasibility.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "bd_feasibility_reg", + "output_doc_title": "注册路径可行性评估报告" + }, + { + "key": "bd_user_requirements", + "title": "用户需求表", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.user_requirements.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "bd_user_requirements", + "output_doc_title": "用户需求表" + }, + { + "key": "bd_prelim_design", + "title": "系统初步设计方案", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.prelim_design.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "bd_prelim_design", + "output_doc_title": "系统初步设计方案" + } ] }, { @@ -38,17 +93,19 @@ "name": "V1.0 商务报价", "position": 30, "optional": false, - "doc_tasks": [{ - "key": "cdo_quote", - "title": "CDO 报价单", - "default_enabled": true, - "creator_configurable": true, - "blocking_when_enabled": true, - "executor_key": "bizdev.cdo_quote.v1", - "quality_gate_key": "quality.generic.v1", - "output_doc_type": "cdo_quote", - "output_doc_title": "CDO 报价单" - }] + "doc_tasks": [ + { + "key": "bd_cdo_quote", + "title": "CDO 报价单", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.cdo_quote.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "bd_cdo_quote", + "output_doc_title": "CDO 报价单" + } + ] } ] } diff --git a/internal/workflowdef/manifests/product_manager.v2.json b/internal/workflowdef/manifests/product_manager.v2.json index ad87d2d4..cf60ce91 100644 --- a/internal/workflowdef/manifests/product_manager.v2.json +++ b/internal/workflowdef/manifests/product_manager.v2.json @@ -1,22 +1,29 @@ { "schema_version": 2, "workflow_key": "product_manager", - "product_domains": ["medical_device", "non_medical_software"], - "stages": [{ - "key": "requirements_baseline", - "name": "系统需求基线", - "position": 10, - "optional": false, - "doc_tasks": [{ - "key": "system_reqs", - "title": "系统需求规格书", - "default_enabled": true, - "creator_configurable": true, - "blocking_when_enabled": true, - "executor_key": "squad.system_reqs.v1", - "quality_gate_key": "quality.system_reqs.v2", - "output_doc_type": "system_reqs", - "output_doc_title": "系统需求规格书" - }] - }] + "product_domains": [ + "medical_device", + "non_medical_software" + ], + "stages": [ + { + "key": "requirements_baseline", + "name": "系统需求基线", + "position": 10, + "optional": false, + "doc_tasks": [ + { + "key": "pm_system_reqs", + "title": "系统需求规格书", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.system_reqs.v1", + "quality_gate_key": "quality.system_reqs.v2", + "output_doc_type": "pm_system_reqs", + "output_doc_title": "系统需求规格书" + } + ] + } + ] } 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/manifests/software_bootstrap.v2.json b/internal/workflowdef/manifests/software_bootstrap.v2.json index 3ccf584b..fb5196c0 100644 --- a/internal/workflowdef/manifests/software_bootstrap.v2.json +++ b/internal/workflowdef/manifests/software_bootstrap.v2.json @@ -1,7 +1,9 @@ { "schema_version": 2, "workflow_key": "software_bootstrap", - "product_domains": ["non_medical_software"], + "product_domains": [ + "non_medical_software" + ], "stages": [ { "key": "S0_boundary", @@ -11,25 +13,25 @@ "manual_gate": true, "doc_tasks": [ { - "key": "software_charter", + "key": "sw_software_charter", "title": "项目章程", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "software_charter", + "output_doc_type": "sw_software_charter", "output_doc_title": "项目章程" }, { - "key": "source_inventory", + "key": "sw_source_inventory", "title": "资料与仓库清单", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "source_inventory", + "output_doc_type": "sw_source_inventory", "output_doc_title": "资料与仓库清单" } ] @@ -42,36 +44,36 @@ "manual_gate": true, "doc_tasks": [ { - "key": "current_state_audit", + "key": "sw_current_state_audit", "title": "现状审计", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "current_state_audit", + "output_doc_type": "sw_current_state_audit", "output_doc_title": "现状审计" }, { - "key": "architecture_baseline", + "key": "sw_architecture_baseline", "title": "当前架构与依赖图", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "architecture_baseline", + "output_doc_type": "sw_architecture_baseline", "output_doc_title": "当前架构与依赖图" }, { - "key": "software_risk_register", + "key": "sw_software_risk_register", "title": "风险登记册", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "software_risk_register", + "output_doc_type": "sw_software_risk_register", "output_doc_title": "风险登记册" } ] @@ -84,25 +86,25 @@ "manual_gate": true, "doc_tasks": [ { - "key": "product_requirements", + "key": "sw_product_requirements", "title": "产品与系统需求基线", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "product_requirements", + "output_doc_type": "sw_product_requirements", "output_doc_title": "产品与系统需求基线" }, { - "key": "scope_backlog", + "key": "sw_scope_backlog", "title": "范围化 backlog", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "scope_backlog", + "output_doc_type": "sw_scope_backlog", "output_doc_title": "范围化 backlog" } ] @@ -115,25 +117,25 @@ "manual_gate": true, "doc_tasks": [ { - "key": "target_architecture", + "key": "sw_target_architecture", "title": "目标架构方案", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "target_architecture", + "output_doc_type": "sw_target_architecture", "output_doc_title": "目标架构方案" }, { - "key": "decision_log", + "key": "sw_decision_log", "title": "架构决策记录", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "decision_log", + "output_doc_type": "sw_decision_log", "output_doc_title": "架构决策记录" } ] @@ -146,47 +148,47 @@ "manual_gate": true, "doc_tasks": [ { - "key": "bootstrap_roadmap", + "key": "sw_bootstrap_roadmap", "title": "分阶段建设路线", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "bootstrap_roadmap", + "output_doc_type": "sw_bootstrap_roadmap", "output_doc_title": "分阶段建设路线" }, { - "key": "verification_strategy", + "key": "sw_verification_strategy", "title": "验证策略与测试矩阵", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "verification_strategy", + "output_doc_type": "sw_verification_strategy", "output_doc_title": "验证策略与测试矩阵" }, { - "key": "operations_baseline", + "key": "sw_operations_baseline", "title": "部署运维与恢复基线", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "operations_baseline", + "output_doc_type": "sw_operations_baseline", "output_doc_title": "部署运维与恢复基线" }, { - "key": "bootstrap_readiness", + "key": "sw_bootstrap_readiness", "title": "自举就绪评审", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "bootstrap_readiness", + "output_doc_type": "sw_bootstrap_readiness", "output_doc_title": "自举就绪评审" } ] diff --git a/internal/workflowdef/manifests/software_delivery.v2.json b/internal/workflowdef/manifests/software_delivery.v2.json index 947d8b7d..3812a356 100644 --- a/internal/workflowdef/manifests/software_delivery.v2.json +++ b/internal/workflowdef/manifests/software_delivery.v2.json @@ -1,7 +1,9 @@ { "schema_version": 2, "workflow_key": "software_delivery", - "product_domains": ["non_medical_software"], + "product_domains": [ + "non_medical_software" + ], "required_upstream_workflow": "", "stages": [ { @@ -12,25 +14,25 @@ "manual_gate": true, "doc_tasks": [ { - "key": "change_brief", + "key": "sw_change_brief", "title": "变更简报", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "change_brief", + "output_doc_type": "sw_change_brief", "output_doc_title": "变更简报" }, { - "key": "implementation_plan", + "key": "sw_implementation_plan", "title": "实施计划", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "implementation_plan", + "output_doc_type": "sw_implementation_plan", "output_doc_title": "实施计划" } ] @@ -43,25 +45,25 @@ "manual_gate": true, "doc_tasks": [ { - "key": "change_manifest", + "key": "sw_change_manifest", "title": "变更清单", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "change_manifest", + "output_doc_type": "sw_change_manifest", "output_doc_title": "变更清单" }, { - "key": "adr_delta", + "key": "sw_adr_delta", "title": "架构决策增量", "default_enabled": false, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "adr_delta", + "output_doc_type": "sw_adr_delta", "output_doc_title": "架构决策增量" } ] @@ -74,25 +76,25 @@ "manual_gate": true, "doc_tasks": [ { - "key": "test_report", + "key": "sw_test_report", "title": "测试报告", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "test_report", + "output_doc_type": "sw_test_report", "output_doc_title": "测试报告" }, { - "key": "security_review", + "key": "sw_security_review", "title": "安全审查", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "security_review", + "output_doc_type": "sw_security_review", "output_doc_title": "安全审查" } ] @@ -105,25 +107,25 @@ "manual_gate": true, "doc_tasks": [ { - "key": "release_plan", + "key": "sw_release_plan", "title": "发布计划", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "release_plan", + "output_doc_type": "sw_release_plan", "output_doc_title": "发布计划" }, { - "key": "release_notes", + "key": "sw_release_notes", "title": "发布说明", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "release_notes", + "output_doc_type": "sw_release_notes", "output_doc_title": "发布说明" } ] @@ -136,25 +138,25 @@ "manual_gate": true, "doc_tasks": [ { - "key": "retrospective", + "key": "sw_retrospective", "title": "复盘", "default_enabled": true, "creator_configurable": false, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "retrospective", + "output_doc_type": "sw_retrospective", "output_doc_title": "复盘" }, { - "key": "backlog_delta", + "key": "sw_backlog_delta", "title": "Backlog 增量", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "software.doc.v1", "quality_gate_key": "quality.generic.v1", - "output_doc_type": "backlog_delta", + "output_doc_type": "sw_backlog_delta", "output_doc_title": "Backlog 增量" } ] diff --git a/internal/workflowdef/manifests/test_manager.v2.json b/internal/workflowdef/manifests/test_manager.v2.json index a55e49f1..96bfde23 100644 --- a/internal/workflowdef/manifests/test_manager.v2.json +++ b/internal/workflowdef/manifests/test_manager.v2.json @@ -1,22 +1,29 @@ { "schema_version": 2, "workflow_key": "test_manager", - "product_domains": ["medical_device", "non_medical_software"], - "stages": [{ - "key": "system_verification_planning", - "name": "系统验证策划", - "position": 10, - "optional": false, - "doc_tasks": [{ - "key": "test_manager", - "title": "系统验证方案及计划", - "default_enabled": true, - "creator_configurable": true, - "blocking_when_enabled": true, - "executor_key": "squad.test_manager.v1", - "quality_gate_key": "quality.test_manager.v2", - "output_doc_type": "test_manager", - "output_doc_title": "系统验证方案及计划" - }] - }] + "product_domains": [ + "medical_device", + "non_medical_software" + ], + "stages": [ + { + "key": "system_verification_planning", + "name": "系统验证策划", + "position": 10, + "optional": false, + "doc_tasks": [ + { + "key": "tm_system_verification", + "title": "系统验证方案及计划", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.test_manager.v1", + "quality_gate_key": "quality.test_manager.v2", + "output_doc_type": "tm_system_verification", + "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/cdo_quote.md b/templates/docs/bd_cdo_quote.md similarity index 100% rename from templates/docs/cdo_quote.md rename to templates/docs/bd_cdo_quote.md diff --git a/templates/docs/feasibility_dev.md b/templates/docs/bd_feasibility_dev.md similarity index 100% rename from templates/docs/feasibility_dev.md rename to templates/docs/bd_feasibility_dev.md diff --git a/templates/docs/feasibility_reg.md b/templates/docs/bd_feasibility_reg.md similarity index 100% rename from templates/docs/feasibility_reg.md rename to templates/docs/bd_feasibility_reg.md diff --git a/templates/docs/intake.md b/templates/docs/bd_intake.md similarity index 100% rename from templates/docs/intake.md rename to templates/docs/bd_intake.md diff --git a/templates/docs/prelim_design.md b/templates/docs/bd_prelim_design.md similarity index 100% rename from templates/docs/prelim_design.md rename to templates/docs/bd_prelim_design.md diff --git a/templates/docs/research.md b/templates/docs/bd_research.md similarity index 100% rename from templates/docs/research.md rename to templates/docs/bd_research.md diff --git a/templates/docs/user_requirements.md b/templates/docs/bd_user_requirements.md similarity index 100% rename from templates/docs/user_requirements.md rename to templates/docs/bd_user_requirements.md diff --git a/templates/docs/system_reqs.md b/templates/docs/pm_system_reqs.md similarity index 100% rename from templates/docs/system_reqs.md rename to templates/docs/pm_system_reqs.md 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/templates/docs/test_manager.md b/templates/docs/tm_system_verification.md similarity index 100% rename from templates/docs/test_manager.md rename to templates/docs/tm_system_verification.md 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/admin-console.spec.ts b/web/e2e/admin-console.spec.ts new file mode 100644 index 00000000..8f72376f --- /dev/null +++ b/web/e2e/admin-console.spec.ts @@ -0,0 +1,40 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * 系统管理台最小冒烟:admin 登录 → 进入管理台 → 分组导航渲染 → 返回工作台。 + * 与其他 e2e 一致:仅 RUN_UI_ACCEPTANCE=1 且对接真实 dev 栈时运行(无 mock), + * 需要 ADMIN_USERNAME / ADMIN_PASSWORD 环境变量。 + */ +const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; +const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || ''; +const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || ''; + +test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the real dev stack'); + +async function login(page: Page, username: string, password: string) { + await page.goto('/'); + await page.fill('input[placeholder*="系统用户名"]', username); + await page.fill('input[placeholder*="登录密码"]', password); + await page.click('button:has-text("进入医疗协同工作台")'); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); +} + +test('admin console smoke: enter, nav renders, exit back to workbench', async ({ page }) => { + if (!ADMIN_USER || !ADMIN_PASS) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + await login(page, ADMIN_USER, ADMIN_PASS); + + // 进入系统管理台(LeftRail 管理盾牌按钮,仅管理员可见) + await page.click('button[title*="系统管理与准入治理控制台"]'); + await expect(page.getByText('系统管理', { exact: true })).toBeVisible({ timeout: 15_000 }); + + // 左侧分组导航渲染 + for (const label of ['系统运行总览', '知识库连接', '特性功能开关', '系统设置白名单', '工作流定义', 'RBAC 角色权限', '用户与准入治理', '文档与模板库']) { + await expect(page.getByRole('button', { name: label })).toBeVisible(); + } + + // 返回工作台 + await page.getByRole('button', { name: '返回工作台' }).first().click(); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 15_000 }); +}); 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/e2e/initial-load-performance.spec.ts b/web/e2e/initial-load-performance.spec.ts new file mode 100644 index 00000000..111cdd40 --- /dev/null +++ b/web/e2e/initial-load-performance.spec.ts @@ -0,0 +1,193 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test, type Page, type Request } from '@playwright/test'; +import { + countInitRequests, + redactResourceUrl, + summarizeResources, + type RequestSample, + type ResourceSample, +} from '../src/lib/initialLoadMetrics.ts'; + +/** + * U1 登录态基线。只测导航、资源瀑布和初始化请求次数。 + * 不发送聊天消息,不把查询串、令牌或正文写入证据。 + */ +const enabled = process.env.RUN_INITIAL_LOAD_BASELINE === '1'; +const username = process.env.ADMIN_USERNAME?.trim() || ''; +const password = process.env.ADMIN_PASSWORD?.trim() || ''; +const samples = Math.max(1, Number(process.env.INITIAL_LOAD_SAMPLES || '1')); +const evidenceName = process.env.INITIAL_LOAD_EVIDENCE || 'login-waterfall.json'; +const evidenceDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../.doc/test-reports/assets/2026-09-19-initial-load-u1', +); + +test.skip(!enabled, 'set RUN_INITIAL_LOAD_BASELINE=1 against the real dev stack'); + +interface Capture { + requests: RequestSample[]; +} + +function watchRequests(page: Page): Capture { + const capture: Capture = { requests: [] }; + const onRequest = (request: Request) => { + capture.requests.push({ + method: request.method(), + path: redactResourceUrl(request.url()), + }); + }; + page.on('request', onRequest); + return capture; +} + +async function readPerformance(page: Page) { + return page.evaluate(() => { + const navigation = performance.getEntriesByType('navigation').map((entry) => { + const nav = entry as PerformanceNavigationTiming; + return { + ttfb: nav.responseStart, + dcl: nav.domContentLoadedEventEnd, + load: nav.loadEventEnd, + }; + }); + const paint = performance.getEntriesByType('paint').map((entry) => ({ + name: entry.name, + ms: entry.startTime, + })); + const resources = performance.getEntriesByType('resource').map((entry) => { + const resource = entry as PerformanceResourceTiming; + const pathOnly = resource.name.split('?')[0] ?? resource.name; + let pathname = pathOnly; + try { + pathname = new URL(pathOnly).pathname; + } catch { + pathname = pathOnly.replace(/^https?:\/\/[^/]+/i, ''); + } + return { + path: pathname || '/', + initiator: resource.initiatorType, + durationMs: resource.duration, + transferBytes: resource.transferSize, + decodedBytes: resource.decodedBodySize, + }; + }); + const longTasks = ((window as unknown as { __longTasks?: { duration: number }[] }).__longTasks || []) + .map((entry) => entry.duration); + return { navigation, paint, resources, longTasks }; + }); +} + +async function openAnonymous(page: Page) { + await page.addInitScript(() => { + const target = window as unknown as { __longTasks: { duration: number }[] }; + target.__longTasks = []; + try { + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + target.__longTasks.push({ duration: entry.duration }); + } + }).observe({ type: 'longtask', buffered: true }); + } catch { + /* 部分无头环境没有 longtask */ + } + }); + const started = Date.now(); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.locator('input[placeholder*="系统用户名"]').waitFor({ state: 'visible' }); + return Date.now() - started; +} + +async function login(page: Page) { + await page.fill('input[placeholder*="系统用户名"]', username); + await page.fill('input[placeholder*="登录密码"]', password); + const started = Date.now(); + await page.click('button:has-text("进入医疗协同工作台")'); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('textarea[placeholder*="向项目助手提问"]')).toBeVisible({ timeout: 30_000 }); + return Date.now() - started; +} + +test('logged-in workbench records waterfall and init request counts', async ({ browser }) => { + if (!username || !password) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + mkdirSync(evidenceDir, { recursive: true }); + const rows = []; + for (let sample = 1; sample <= samples; sample += 1) { + const context = await browser.newContext(); + const page = await context.newPage(); + const capture = watchRequests(page); + const anonymousVisibleMs = await openAnonymous(page); + const anonymousPerf = await readPerformance(page); + const workbenchReadyMs = await login(page); + await page.waitForTimeout(1500); + const loggedInPerf = await readPerformance(page); + const counts = countInitRequests(capture.requests); + const anonymousResources = anonymousPerf.resources as ResourceSample[]; + const loggedInResources = loggedInPerf.resources as ResourceSample[]; + const row = { + sample, + anonymousVisibleMs, + workbenchReadyMs, + anonymous: { + navigation: anonymousPerf.navigation, + paint: anonymousPerf.paint, + summary: summarizeResources(anonymousResources), + longTaskCount: anonymousPerf.longTasks.length, + }, + loggedIn: { + navigation: loggedInPerf.navigation, + paint: loggedInPerf.paint, + summary: summarizeResources(loggedInResources), + longTaskCount: loggedInPerf.longTasks.length, + }, + initRequests: counts, + }; + rows.push(row); + writeFileSync(path.join(evidenceDir, evidenceName), JSON.stringify(rows, null, 2)); + await context.close(); + } + const last = rows[rows.length - 1]; + expect(last?.anonymous.summary.requestCount).toBeGreaterThan(0); + expect(last?.workbenchReadyMs).toBeGreaterThan(0); + const memberCap = process.env.EXPECT_INIT_MEMBER_PROJECTS; + if (memberCap) { + expect(last?.initRequests.distinctMemberProjects).toBeLessThanOrEqual(Number(memberCap)); + } +}); + +test('throttled login still reaches the workbench', async ({ browser }) => { + test.skip(process.env.INITIAL_LOAD_THROTTLE !== '1', 'set INITIAL_LOAD_THROTTLE=1 to measure slow-network budgets'); + if (!username || !password) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + mkdirSync(evidenceDir, { recursive: true }); + const profiles = [ + { name: '10mbps-cpu4', download: 1_250_000, upload: 625_000 }, + { name: '2mbps-cpu4', download: 250_000, upload: 125_000 }, + ]; + const rows = []; + for (const profile of profiles) { + for (let sample = 1; sample <= samples; sample += 1) { + const context = await browser.newContext(); + const page = await context.newPage(); + const client = await context.newCDPSession(page); + await client.send('Network.emulateNetworkConditions', { + offline: false, + latency: 80, + downloadThroughput: profile.download, + uploadThroughput: profile.upload, + }); + await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + await page.route(/fonts\.(googleapis|gstatic)\.com/, (route) => route.abort()); + const anonymousVisibleMs = await openAnonymous(page); + const workbenchReadyMs = await login(page); + rows.push({ profile: profile.name, sample, anonymousVisibleMs, workbenchReadyMs }); + writeFileSync(path.join(evidenceDir, process.env.INITIAL_LOAD_THROTTLE_EVIDENCE || 'throttled.json'), JSON.stringify(rows, null, 2)); + await context.close(); + } + } + expect(rows.every((row) => row.workbenchReadyMs > 0)).toBe(true); +}); diff --git a/web/index.html b/web/index.html index 7b072245..b28ef6c9 100644 --- a/web/index.html +++ b/web/index.html @@ -6,11 +6,12 @@ 水木东方 · 医疗器械现代 AI 协同工作台 - - diff --git a/web/package.json b/web/package.json index efc5150d..bed6f6c1 100644 --- a/web/package.json +++ b/web/package.json @@ -5,10 +5,19 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "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", + "test:e2e:initial-load": "RUN_INITIAL_LOAD_BASELINE=1 playwright test e2e/initial-load-performance.spec.ts --project=chromium" }, "dependencies": { "@tanstack/react-virtual": "^3.14.11", diff --git a/web/public/fonts/OFL.txt b/web/public/fonts/OFL.txt new file mode 100644 index 00000000..c0d34c0c --- /dev/null +++ b/web/public/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/public/fonts/inter-v20-latin-ext.woff2 b/web/public/fonts/inter-v20-latin-ext.woff2 new file mode 100644 index 00000000..479d010d Binary files /dev/null and b/web/public/fonts/inter-v20-latin-ext.woff2 differ diff --git a/web/public/fonts/inter-v20-latin.woff2 b/web/public/fonts/inter-v20-latin.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/web/public/fonts/inter-v20-latin.woff2 differ diff --git a/web/public/fonts/jetbrains-mono-v24-latin-ext.woff2 b/web/public/fonts/jetbrains-mono-v24-latin-ext.woff2 new file mode 100644 index 00000000..82f96681 Binary files /dev/null and b/web/public/fonts/jetbrains-mono-v24-latin-ext.woff2 differ diff --git a/web/public/fonts/jetbrains-mono-v24-latin.woff2 b/web/public/fonts/jetbrains-mono-v24-latin.woff2 new file mode 100644 index 00000000..4d09cda4 Binary files /dev/null and b/web/public/fonts/jetbrains-mono-v24-latin.woff2 differ diff --git a/web/scripts/precompress-dist.mjs b/web/scripts/precompress-dist.mjs new file mode 100644 index 00000000..f5133abf --- /dev/null +++ b/web/scripts/precompress-dist.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * Build-time precompress for web/dist: write sibling .br / .gz variants. + * Zero npm deps — uses Node zlib (available in node:22-bookworm web-builder). + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.resolve(__dirname, '..', 'dist'); +const MIN_BYTES = 1024; +const COMPRESS_EXT = new Set(['.js', '.css', '.html', '.svg', '.json', '.txt', '.xml', '.map']); +const SKIP_EXT = new Set(['.woff', '.woff2', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.br', '.gz']); + +function walk(dir, out = []) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, ent.name); + if (ent.isDirectory()) walk(full, out); + else out.push(full); + } + return out; +} + +function shouldCompress(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (SKIP_EXT.has(ext)) return false; + if (!COMPRESS_EXT.has(ext)) return false; + const st = fs.statSync(filePath); + return st.size >= MIN_BYTES; +} + +function writeIfSmaller(sourcePath, variantPath, buf, label, originalSize) { + if (buf.length >= originalSize) { + if (fs.existsSync(variantPath)) fs.unlinkSync(variantPath); + return null; + } + fs.writeFileSync(variantPath, buf); + const pct = ((1 - buf.length / originalSize) * 100).toFixed(1); + console.log( + ` ${label.padEnd(4)} ${path.relative(distDir, sourcePath)} ${originalSize} → ${buf.length} (−${pct}%)`, + ); + return buf.length; +} + +if (!fs.existsSync(distDir)) { + console.error(`precompress: missing ${distDir}; run vite build first`); + process.exit(1); +} + +const files = walk(distDir).filter(shouldCompress); +console.log(`precompress: ${files.length} candidate(s) under ${distDir}`); + +let brCount = 0; +let gzCount = 0; +for (const file of files) { + const raw = fs.readFileSync(file); + const br = brotliCompressSync(raw, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 11, + [zlibConstants.BROTLI_PARAM_SIZE_HINT]: raw.length, + }, + }); + const gz = gzipSync(raw, { level: 9 }); + if (writeIfSmaller(file, `${file}.br`, br, 'br', raw.length) != null) brCount += 1; + if (writeIfSmaller(file, `${file}.gz`, gz, 'gzip', raw.length) != null) gzCount += 1; +} + +console.log(`precompress: wrote ${brCount} .br and ${gzCount} .gz`); diff --git a/web/src/App.tsx b/web/src/App.tsx index bec94c67..40ff42b3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,138 +1,54 @@ import { useState, useMemo, useEffect, useRef, useCallback } from 'react'; -import { AppShell } from './components/layout/AppShell'; -import { MessageStream } from './components/chat/MessageStream'; -import { InputArea } from './components/chat/InputArea'; -import { SlimTabBar } from './components/doc/SlimTabBar'; -import { DeckDrawer } from './components/doc/DeckDrawer'; -import { BlockRenderer } from './components/doc/BlockRenderer'; -import { BlockEditorModal } from './components/doc/BlockEditorModal'; -import { DocumentRevisionToolbar } from './components/doc/DocumentRevisionToolbar'; -import { CommentSidePanel } from './components/doc/CommentSidePanel'; -import { DocGovActionBar } from './components/doc/DocGovActionBar'; -import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar'; -import { - createFreeformDoc, - saveMessageToPrivate, - listFreeformDocs, - fetchFreeformDoc, - shareFreeformDoc, - unshareFreeformDoc, - deleteFreeformDoc, -} from './api/freeformDoc'; -import { registerUploadAttachments } from './lib/uploadFreeformRegister'; -import { fetchChatSessions, upsertChatSession } from './api/chatSessions'; -import { listProjectWorkflowDocuments } from './api/projectDocuments'; +import { WorkbenchView } from './components/layout/WorkbenchView'; +import { DocumentPane } from './components/doc/DocumentPane'; +import { ChatColumn } from './components/chat/ChatColumn'; +import { fetchFreeformDoc } from './api/freeformDoc'; import type { DiffResult, OverlayMode } from './features/documentRevision/types'; +import { fetchProjectLineage, getProject } from './api/projectWorkflow'; +import { listProjectMembers } from './api/projectMembers'; import { - documentRevisionInactiveHint, - revisionToolbarMountMode, -} from './features/documentRevision/revisionToolbarMount'; -import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow'; -import { - addProjectMember, - listProjectMembers, - removeProjectMember, - updateProjectMember, -} from './api/projectMembers'; -import { - buildManagedMemberUpsertPayload, - ensureCreatedProjectWithMembers, -} from './lib/createProjectMembersPersist'; -import { afterProjectMemberPersisted } from './lib/afterProjectMemberPersisted'; -import { - beginCreateProjectSubmit, - endCreateProjectSubmit, - resolveCreateProjectSubmitPhase, - shouldCloneMedicalTemplates, type CreateProjectSubmitPhase, } from './lib/createProjectSubmitGuard'; import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter'; -import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync'; import { resolveCurrentDocRole } from './lib/currentDocRole'; -import { canAddBlockComment } from './lib/commentPolicy'; import { - pollUntilPostTaskServerDoc, - shouldSwitchTemplateSlotToServer, -} from './lib/postTaskDeliverableSync'; + decideModificationProposal, +} from './api/docgov'; import { - isRemoteTaskTerminal, - nextAttemptOpts, - resolveReconnectRunId, - shouldSubscribeExistingRun, -} from './lib/taskRunReconnect'; -import { - GOVERNANCE_BLOCKED_HINT, - isBlockedGovernanceStatus, -} from './lib/blockedGovernanceCopy'; -import { - buildControlledLoadErrorSkeleton, buildControlledLoadingSkeleton, - canonicalDocIdForTask, controlledHydrateFingerprint, hydrateControlledDocumentContent, - resolveCanonicalDocId, shouldLoadControlledContent, } from './lib/controlledDocHydrate'; import { - controlledHydrateCandidateIds, dropRecordKeys, - planControlledContentInvalidation, type ControlledInvalidationPlan, } from './lib/controlledContentInvalidation'; -import { applyLeaseUpdates, sameLease } from './lib/deliverableLeaseSync'; -import { ControlledContentRequestError } from './api/projectDocuments'; import { createEditorHydrationGate, - type HydrationToken, } from './lib/editorHydrationGate'; import { decideActiveDocSwitch, type BlockEditorSession, } from './lib/blockSavePolicy'; -import { saveControlledBlockEdit } from './lib/saveControlledBlockEdit'; import { DocumentLineage } from './types/projectWorkflow'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; -import { deriveReplyTitle } from './features/replyExport/title'; -import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard'; -import { downloadMarkdownReply, downloadDocxReply } from './features/replyExport/download'; -import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard'; -import { UserProjectRightsModal } from './components/user/UserProjectRightsModal'; -import { UserSettingsModal } from './components/user/UserSettingsModal'; -import { AdminSettingsModal } from './components/admin/AdminSettingsModal'; -import { DocVisibilityModal } from './components/doc/DocVisibilityModal'; -import { CreateProjectModal, type CreateProjectConfirmData } from './components/project/CreateProjectModal'; -import { ProjectMembersModal } from './components/project/ProjectMembersModal'; -import { ProjectSettingsModal } from './components/project/ProjectSettingsModal'; +import { OnDemand } from './components/lazy/OnDemand'; import { fetchProjectSettings } from './api/projectSettings'; -import { FilePreviewPanel } from './components/doc/preview/FilePreviewPanel'; -import { ShieldAlert } from 'lucide-react'; import { LoginView } from './components/auth/LoginView'; -import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal'; import { authClient } from './api/auth'; -import { streamChat, mapErrorInfo } from './api/chatStream'; -import { streamTask, buildTaskRequest, buildResumeRequest } from './api/taskStream'; -import type { StreamEvent } from './api/chatStream'; -import { shouldAutoRetryChat, shouldAutoRetryTask, autoRetryDelayMs, fallbackErrorClass } from './api/chatRetry'; -import { applyPhaseEvent } from './engine/phaseEventMapper'; import { LoginResult, SystemAuthConfig } from './types/auth'; import { markdownToBlockAST } from './engine/markdownToBlock'; import { blockASTToMarkdown } from './engine/blockToMarkdown'; -import { recomputeNumbering } from './engine/autoNumber'; -import { DocumentAST, DocumentBlock, BlockComment } from './types/block'; +import { DocumentAST, DocumentBlock } from './types/block'; import { ProjectSession, ChatMessage, - ChatErrorInfo, DeliverableItem, DeliverableCategory, - InteractiveCardData, - PhaseEvent, - TaskRunContext, - TaskRunMaterial, ProjectInfo, ProjectMember, - ProjectMemberRole, } from './types/workbench'; import { UserRole, UserProfile, DocPermissionRole } from './types/docgov'; import type { DocLifecycleState } from './types/docgov'; @@ -142,30 +58,12 @@ import { hasPermission, filterVisibleDeliverablesWithRBAC, } from './engine/rbac'; -import { exportDocumentToDocx } from './engine/docxRenderer'; -import { exportDocumentToXlsx } from './engine/xlsxRenderer'; -import { - findMatchingBlockForReview, - createRejectConfirmationCard, - isAffirmativeResponse, - findLastActiveCard, - decrementActiveCardsTtl, - applyConfirmRejectToDoc, - applyUndoRejectToDoc, - resolveCardInMessages, - cancelCardInMessages, -} from './engine/cardActionEngine'; import { applyPlanViewToCard, cardFromPlanView, - isPlanConfirmCard, - markPlanCard, - reconcileConflictCard, } from './engine/planCard'; import { fetchActiveIntentPlan, - fetchIntentPlan, - submitIntentPlanDecision, } from './api/intentPlans'; import { ALL_INITIAL_DELIVERABLES, @@ -173,99 +71,46 @@ import { MOCK_USERS, } from '@demo-seeds'; import { - createProjectTemplateDeliverables, getTemplateDefinitionByTaskKey, buildTemplateASTForProject, } from './engine/templateFactory'; -import { resolveTaskTemplateForMessage } from './engine/templateMatch'; import { loadSessions, - saveSessions, loadActiveSessionId, - saveActiveSessionId, loadDeliverables, - saveDeliverables, loadUsers, - saveUsers, - migrateAndClearLegacyMessages, purgeDemoSeedsFromLocalStorage, resolveActiveSessionForUser, loadProjects, - saveProjects, filterProjectSessionsForUser, getProjectMembership, - buildPrivateSessionId, } from './data/storage'; import { useChatWindow } from './hooks/useChatWindow'; +import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync'; +import { useBlockEditing } from './hooks/useBlockEditing'; +import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration'; +import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions'; +import { useFreeformDocActions } from './hooks/useFreeformDocActions'; +import { useCreateProjectConfirm } from './hooks/useCreateProjectConfirm'; +import { useProjectMemberActions } from './hooks/useProjectMemberActions'; +import { useCardActions } from './hooks/useCardActions'; +import { useTaskRunAttempts } from './hooks/useTaskRunAttempts'; +import { useSendMessage } from './hooks/useSendMessage'; +import { useServerDocumentSync } from './hooks/useServerDocumentSync'; +import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence'; import { useTurnRuntime } from './hooks/useTurnRuntime'; import { useTaskRunStream } from './hooks/useTaskRunStream'; -import { fetchChatTurnStatus } from './api/chatTurn'; -import { createClientTurnId } from './lib/createClientTurnId'; import { mergeSessionUser } from './lib/mergeSessionUser'; -import { mapServerProjectToProjectInfo, mergeProjectLists } from './lib/projectAdapter'; +import { RequestEpoch, shareInflight } from './data/initFanout'; import { - mapServerSessionToProjectSession, - mergeSessionLists, resolveSessionProjectId, } from './lib/sessionAdapter'; -import { SendMessageResult } from './types/sendMessage'; -import { CategoryFilterBar } from './components/history/CategoryFilterBar'; -import { HistoryCalendar } from './components/history/HistoryCalendar'; -import { ChatSearchModal } from './components/history/ChatSearchModal'; -import { DigestTimelineModal } from './components/history/DigestTimelineModal'; -import { navigateToAnchor, parseAnchorHash, resolveTurnAnchorId } from './engine/anchorNavigator'; -import { ChatExportModal } from './components/history/ChatExportModal'; +import { navigateToAnchor, parseAnchorHash } from './engine/anchorNavigator'; 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 { uploadProjectAsset } from './api/projectAssets'; -import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns'; - -/** - * 将附件转为任务材料:二进制先上传 CAS 登记 asset_id;小文本可 inline Base64。 - */ -async function readAttachmentsAsMaterials( - attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[], - projectId?: number, -): Promise { - const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']); - const results: TaskRunMaterial[] = []; - for (const att of attachments) { - if (att.assetId && att.assetId > 0) { - results.push({ filename: att.name, asset_id: att.assetId }); - continue; - } - if (!att.fileRaw) continue; - const extMatch = att.name.match(/\.([^.]+)$/); - const ext = extMatch ? extMatch[1].toLowerCase() : ''; - if (!TEXT_EXTS.has(ext)) { - if (!projectId || projectId <= 0) { - throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`); - } - const uploaded = await uploadProjectAsset(projectId, att.fileRaw as Blob, att.name); - results.push({ filename: uploaded.filename || att.name, asset_id: uploaded.asset_id }); - continue; - } - try { - const content = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const text = reader.result as string; - const b64 = btoa(unescape(encodeURIComponent(text))); - resolve(b64); - }; - reader.onerror = () => reject(reader.error); - reader.readAsText(att.fileRaw as Blob, 'utf-8'); - }); - results.push({ filename: att.name, content }); - } catch { - throw new Error(`读取文本附件 ${att.name} 失败`); - } - } - return results; -} +import { fetchChatCategories, fetchChatDates } from './api/chatWindow'; +import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench'; +import { saveFile } from './data/fileStore'; +import { cancelTaskRun, getTaskRun } from './api/taskRuns'; export { ALL_INITIAL_DELIVERABLES, @@ -273,6 +118,8 @@ export { MOCK_USERS, } from '@demo-seeds'; +const loadAdminConsoleView = () => + import('./components/admin/console/AdminConsoleView').then((m) => ({ default: m.AdminConsoleView })); export default function App() { // Wave 2:启动时幂等剔除 localStorage 中的 demo seed 残留(须先于 load*) purgeDemoSeedsFromLocalStorage(); @@ -281,23 +128,10 @@ export default function App() { const [sessions, setSessions] = useState(() => loadSessions()); const [activeSessionId, setActiveSessionId] = useState(() => loadActiveSessionId('sess-001')); - // 自动持久化 sessions 与 activeSessionId - useEffect(() => { - saveSessions(sessions); - }, [sessions]); - - useEffect(() => { - saveActiveSessionId(activeSessionId); - }, [activeSessionId]); - // 全局角色库与人员名单状态 (支持动态增删改与持久化) const [roles, setRoles] = useState(() => DEFAULT_ROLES); const [allUsers, setAllUsers] = useState(() => loadUsers(MOCK_USERS)); - useEffect(() => { - saveUsers(allUsers); - }, [allUsers]); - // 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责) const [currentUser, setCurrentUser] = useState(() => allUsers[0] || MOCK_USERS[0] || { id: 0, @@ -316,32 +150,23 @@ export default function App() { const deliverablesRef = useRef(deliverables); deliverablesRef.current = deliverables; - // 自动持久化 deliverables(序列化时剔除不可持久化的 File 对象,见 storage.ts) - useEffect(() => { - saveDeliverables(deliverables); - }, [deliverables]); - - // ✅ 启动时从 IndexedDB 恢复上传文件的 fileRaw(解决页面刷新后 File 对象丢失问题) - // 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch。 - useEffect(() => { - let cancelled = false; - restoreFileRawToDeliverables(deliverables).then((restored) => { - if (!cancelled && restored !== deliverables) { - // 只有真正有内容被恢复时才更新 state(避免无意义的 re-render) - setDeliverables(restored); - } - }); - return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // 只在 mount 时运行一次 - const [activeCategory, setActiveCategory] = useState('workflow'); // 项目与成员体系状态 (支持持久化) const [projects, setProjects] = useState(() => loadProjects()); - useEffect(() => { - saveProjects(projects); - }, [projects]); + const projectsRef = useRef(projects); + projectsRef.current = projects; + const memberInflightRef = useRef(new Map>()); + const sessionEpochRef = useRef(new RequestEpoch()); + useWorkbenchPersistence({ + sessions, + activeSessionId, + allUsers, + deliverables, + deliverablesRef, + setDeliverables, + projects, + }); const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); const [createProjectError, setCreateProjectError] = useState(null); @@ -387,19 +212,21 @@ export default function App() { const syncProjectMembersFromServer = useCallback( async (projectId: number, creatorId: number) => { - try { - const records = await listProjectMembers(projectId); - const members = mapApiMembersToProjectMembers(records, creatorId); - if (members.length > 0) { - applyMembersToProject(projectId, members); + return shareInflight(memberInflightRef.current, projectId, async () => { + try { + const records = await listProjectMembers(projectId); + const members = mapApiMembersToProjectMembers(records, creatorId); + if (members.length > 0) { + applyMembersToProject(projectId, members); + } + setProjectMembersError(null); + return members; + } catch (err) { + const msg = err instanceof Error ? err.message : '项目成员同步失败'; + setProjectMembersError(msg); + return null; } - setProjectMembersError(null); - return members; - } catch (err) { - const msg = err instanceof Error ? err.message : '项目成员同步失败'; - setProjectMembersError(msg); - return null; - } + }); }, [applyMembersToProject] ); @@ -478,126 +305,36 @@ export default function App() { // 模态浮层控制 const [rightsModalOpen, setRightsModalOpen] = useState(false); const [userSettingsOpen, setUserSettingsOpen] = useState(false); - const [adminSettingsOpen, setAdminSettingsOpen] = useState(false); + // 顶层视图切换:单屏工作台 / 全屏系统管理台(仅管理员可进入) + const [view, setView] = useState<'workbench' | 'admin'>('workbench'); const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false); const [targetDocForVisibility, setTargetDocForVisibility] = useState(null); const [capabilities, setCapabilities] = useState(() => authClient.getCapabilities()); - useEffect(() => { - authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {}); - const saved = authClient.loadSavedSession(); - if (saved) { - setSession(saved); - if (saved.env) setSystemEnv(saved.env); - setCapabilities(saved.capabilities ?? []); - const match = allUsers.find((u) => u.id === saved.user.id); - setCurrentUser(mergeSessionUser(saved.user, match)); - void authClient - .fetchMe() - .then((me) => { - setCapabilities(me.capabilities ?? []); - setCurrentUser(mergeSessionUser(me.user, match)); - setSessionValidated(true); - }) - .catch(() => { - authClient.clearLocalSession(); - setSession(null); - setCapabilities([]); - setSessionValidated(false); - }); - } else { - setSessionValidated(false); - } - }, []); + useAuthenticatedWorkspaceSync({ + allUsers, + setCurrentUser, + setSession, + setSessionValidated, + setSystemEnv, + setSystemAuthConfig, + setCapabilities, + session, + sessionValidated, + currentUser, + currentProjectId, + syncProjectMembersFromServer, + setProjects, + setProjectSyncNotice, + projectMembersModalOpen, + projectsRef, + setSessions, + setSessionSyncNotice, + sessionEpochRef, + }); - useEffect(() => { - if (!session || sessionValidated !== true) { - setProjectSyncNotice(null); - return; - } - - let cancelled = false; - void (async () => { - try { - const serverProjects = await fetchProjects(); - if (cancelled) return; - setProjects((prev) => { - const mapped = (serverProjects ?? []).map((project) => - mapServerProjectToProjectInfo( - project, - allUsers, - prev.find((item) => item.id === project.id) - ) - ); - return mergeProjectLists(prev, mapped); - }); - await Promise.all( - serverProjects.map((project) => - syncProjectMembersFromServer(project.id, project.creator_user_id) - ) - ); - setProjectSyncNotice(null); - } catch (err) { - if (cancelled) return; - console.warn('[projects] 服务端项目列表同步失败,继续使用本地缓存:', err); - setProjectSyncNotice('项目列表暂用本地缓存:无法连接服务端,新建项目可能无法在其他设备同步。'); - } - })(); - - return () => { - cancelled = true; - }; - }, [session, sessionValidated, allUsers, syncProjectMembersFromServer]); - - useEffect(() => { - if (!projectMembersModalOpen || !currentProject || !session || sessionValidated !== true) return; - void syncProjectMembersFromServer(currentProject.id, currentProject.creatorId); - }, [projectMembersModalOpen, currentProject, session, syncProjectMembersFromServer]); - - useEffect(() => { - if (!session || sessionValidated !== true || !currentUser.id) { - setSessionSyncNotice(null); - return; - } - - let cancelled = false; - void (async () => { - try { - const serverSessions = await fetchChatSessions(currentUser.id); - if (cancelled) return; - setSessions((prev) => { - const mapped = (serverSessions ?? []).map((item) => - mapServerSessionToProjectSession( - item, - projects, - prev.find((sessionItem) => sessionItem.sessionId === item.session_id) - ) - ); - return mergeSessionLists(prev, mapped); - }); - setSessionSyncNotice(null); - } catch (err) { - if (cancelled) return; - console.warn('[sessions] 服务端会话列表同步失败,继续使用本地缓存:', err); - setSessionSyncNotice( - '会话列表暂用本地缓存:无法连接服务端,新建会话可能无法在其他设备同步。' - ); - } - })(); - - return () => { - cancelled = true; - }; - }, [session, sessionValidated, currentUser.id, projects]); - - const handleLogout = () => { - void authClient.logout().finally(() => { - setSession(null); - setCapabilities([]); - setSessionValidated(false); - }); - }; + // handleLogout 定义在 turnRuntime/taskRunStream hook 之后,便于 abort 全会话在途流。 // 核心功能权限计算 const canCreateProject = useMemo(() => { @@ -906,139 +643,23 @@ export default function App() { }); }, [docCache, activeDocId, deliverables, projects, visibleDeliverables, controlledContentReady, currentUser]); - // legacy doc-p{projectId}-{taskKey} → 服务端 doc_id 映射 - useEffect(() => { - const canonicalId = resolveCanonicalDocId(activeDocId, deliverables); - if (canonicalId === activeDocId) return; - setActiveDocId(canonicalId); - setDocCache((prev) => { - if (!prev[activeDocId]) return prev; - const next = { ...prev }; - delete next[activeDocId]; - return next; - }); - }, [deliverables, activeDocId]); - - const applyHydratedDocument = useCallback( - ( - token: HydrationToken, - ast: DocumentAST | null | undefined, - docId: string, - opts?: { refreshToolbar?: boolean }, - ) => { - if (!ast) return false; - if (!editorHydrationGateRef.current.decide(token).apply) return false; - setDocCache((prev) => ({ ...prev, [docId]: ast })); - setControlledContentReady((prev) => ({ ...prev, [docId]: true })); - setDocLoadErrors((prev) => ({ ...prev, [docId]: false })); - setDocLoadErrorInfo((prev) => { - if (!prev[docId]) return prev; - const next = { ...prev }; - delete next[docId]; - return next; - }); - if (opts?.refreshToolbar !== false) { - setDocRevRefreshKey((k) => k + 1); - } - return true; - }, - [], - ); - - // 受控非模板文档:挂载/切换/同步后自动拉取服务端正文。 - // hydrate 会按 head_block_map 对齐块 ID;编辑弹窗打开期间仍不得发起新 GET, - // 已在途响应也不得覆盖编辑基线或 PATCH 成功后的新正文。 - useEffect(() => { - if (!session || sessionValidated !== true) return; - const target = deliverables.find((d) => d.docId === activeDocId); - if (!target || !shouldLoadControlledContent(target)) return; - const token = editorHydrationGateRef.current.beginHydration(activeDocId); - if (!token) return; - - let cancelled = false; - setDocLoadErrors((prev) => { - const next = { ...prev }; - delete next[activeDocId]; - return next; - }); - void hydrateControlledDocumentContent(activeDocId, target) - .then((ast) => { - if (cancelled) return; - applyHydratedDocument(token, ast, activeDocId); - }) - .catch((err) => { - console.warn('自动拉取受控文档正文失败:', err); - if (cancelled) return; - if (!editorHydrationGateRef.current.decide(token).apply) return; - setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); - setDocLoadErrorInfo((prev) => ({ - ...prev, - [activeDocId]: { - code: err instanceof ControlledContentRequestError ? err.code : undefined, - message: err instanceof Error ? err.message : String(err), - correlationId: - err instanceof ControlledContentRequestError ? err.correlationId : undefined, - }, - })); - setDocCache((prev) => ({ - ...prev, - [activeDocId]: buildControlledLoadErrorSkeleton(target), - })); - }); - - return () => { - cancelled = true; - }; - }, [session, sessionValidated, activeDocId, activeHydrateFingerprint, applyHydratedDocument, hydrateResumeKey]); - - const retryControlledDocLoad = useCallback(() => { - const target = deliverables.find((d) => d.docId === activeDocId); - if (!target || !shouldLoadControlledContent(target)) return; - const token = editorHydrationGateRef.current.beginHydration(activeDocId); - if (!token) return; - - setDocLoadErrors((prev) => { - const next = { ...prev }; - delete next[activeDocId]; - return next; - }); - setControlledContentReady((prev) => ({ ...prev, [activeDocId]: false })); - setDocCache((prev) => ({ - ...prev, - [activeDocId]: buildControlledLoadingSkeleton(target), - })); - - void hydrateControlledDocumentContent(activeDocId, target) - .then((ast) => { - applyHydratedDocument(token, ast, activeDocId); - }) - .catch((err) => { - console.warn('重试拉取受控文档正文失败:', err); - if (!editorHydrationGateRef.current.decide(token).apply) return; - setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); - setDocLoadErrorInfo((prev) => ({ - ...prev, - [activeDocId]: { - code: err instanceof ControlledContentRequestError ? err.code : undefined, - message: err instanceof Error ? err.message : String(err), - correlationId: - err instanceof ControlledContentRequestError ? err.correlationId : undefined, - }, - })); - setDocCache((prev) => ({ - ...prev, - [activeDocId]: buildControlledLoadErrorSkeleton(target), - })); - }); - }, [activeDocId, deliverables, applyHydratedDocument]); - - // 自愈后自动回写入缓存,保障后续操作直接命中 - useEffect(() => { - if (!docAST || docCache[docAST.docId]) return; - const target = deliverables.find((d) => d.docId === docAST.docId); - if (target && shouldLoadControlledContent(target)) return; - setDocCache((prev) => ({ ...prev, [docAST.docId]: docAST })); - }, [docAST, docCache, deliverables]); + const { applyHydratedDocument, retryControlledDocLoad } = useControlledDocumentHydration({ + session, + sessionValidated, + activeDocId, + setActiveDocId, + deliverables, + docCache, + setDocCache, + setControlledContentReady, + setDocLoadErrors, + setDocLoadErrorInfo, + setDocRevRefreshKey, + editorHydrationGateRef, + activeHydrateFingerprint, + hydrateResumeKey, + docAST, + }); // 根据权威 user id 计算当前文档职责;未知身份 fail-closed 为 viewer const currentDocRole: UserRole = resolveCurrentDocRole({ @@ -1234,331 +855,53 @@ export default function App() { } }; - // 一键将个人私密文档共享至项目协作参考库 - const handleShareToProject = async (docId: string) => { - try { - if (docId.startsWith('doc-free-')) { - await shareFreeformDoc(docId); - } - } catch (e) { - console.error('Share doc error:', e); - } - const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16); - setDeliverables((prev) => - prev.map((d) => (d.docId === docId ? { ...d, category: 'shared', space: 'project_shared', sharedAt: nowStr } : d)) - ); - setDocCache((prev) => { - if (!prev[docId]) return prev; - return { - ...prev, - [docId]: { ...prev[docId], category: 'shared', space: 'project_shared', sharedAt: nowStr }, - }; - }); - setActiveCategory('shared'); - const targetDoc = deliverables.find((d) => d.docId === docId); - setMessages((prev) => [ - ...prev, - { - id: 'ast-share-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '🌐 一键共享至项目参考库', - content: `已为您将私密推演文档《${targetDoc?.title || '文档'}》一键共享至【项目协作参考库】!项目全员均可作为方案参考进行查阅与批注。您作为创建者,可随时点击「取消共享」恢复为个人私密状态。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - }; - - // 取消共享 (恢复为个人私有草稿) - const handleRevokeShare = async (docId: string) => { - try { - if (docId.startsWith('doc-free-')) { - await unshareFreeformDoc(docId); - } - } catch (e) { - console.error('Unshare doc error:', e); - } - setDeliverables((prev) => - prev.map((d) => (d.docId === docId ? { ...d, category: 'private', space: 'my_private', sharedAt: undefined } : d)) - ); - setDocCache((prev) => { - if (!prev[docId]) return prev; - return { - ...prev, - [docId]: { ...prev[docId], category: 'private', space: 'my_private', sharedAt: undefined }, - }; - }); - setActiveCategory('private'); - const targetDoc = deliverables.find((d) => d.docId === docId); - setMessages((prev) => [ - ...prev, - { - id: 'ast-revoke-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '🔒 撤回共享至个人私密', - content: `已撤回共享!《${targetDoc?.title || '文档'}》已从项目协作库移除并恢复为您的【个人私密文档】,其他团队成员在界面与大盘中已不可见。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - }; - - // 删除私密文档 - const handleDeleteDoc = async (docId: string) => { - try { - if (docId.startsWith('doc-free-')) { - await deleteFreeformDoc(docId); - } - } catch (e) { - console.error('Delete doc error:', e); - } - setDeliverables((prev) => prev.filter((d) => d.docId !== docId)); - if (activeDocId === docId) { - const remaining = deliverables.filter((d) => d.docId !== docId); - const fallback = remaining.find((d) => d.category === 'workflow') || remaining[0]; - if (fallback) { - setActiveDocId(fallback.docId); - setActiveCategory(fallback.category); - } - } - }; - - // 单条智能体回复保存到“我的私密”文档 - const handleSaveMessageToPrivate = async (message: ChatMessage) => { - try { - const serverId = message.serverId || 0; - let resData; - if (serverId > 0) { - try { - const resp = await saveMessageToPrivate( - serverId, - currentProjectId, - undefined, - message.contentSha256, - ); - resData = resp.data; - } catch (serverErr: any) { - console.warn('服务端自由文档落库失败,降级为本地草稿保存:', serverErr); - } - } - - const docId = resData?.document.doc_id || `doc-free-${Date.now()}`; - const title = resData?.document.title || deriveReplyTitle(message.content, message.timestamp); - const markdown = resData?.content_markdown || message.content; - const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16); - - const newDocItem: DeliverableItem = { - docId, - projectId: currentProjectId, - taskKey: 'freeform_private', - title, - stage: 'V0.1', - category: 'private', - creatorId: currentUser.id, - creatorName: currentUser.name, - isFixedWorkflow: false, - isTemplateOnly: false, - lifecycleState: 'drafting', - isClaimed: true, - author: currentUser.name, - reviewer: '', - approver: '', - updatedAt: nowStr, - summary: message.content.slice(0, 120), - governanceMode: 'freeform', - space: 'my_private', - }; - - const newAST = markdownToBlockAST(markdown, { - docId, - title, - taskKey: 'freeform_private', - version: `${resData?.document.current_version_id || 1}`, - category: 'private', - creatorId: currentUser.id, - creatorName: currentUser.name, - lifecycleState: 'drafting', - isClaimed: true, - author: currentUser.name, - reviewer: '', - approver: '', - }); - newAST.governanceMode = 'freeform'; - newAST.space = 'my_private'; - - setDeliverables((prev) => { - const exists = prev.some((d) => d.docId === docId); - if (exists) return prev; - return [newDocItem, ...prev]; - }); - - setDocCache((prev) => ({ - ...prev, - [docId]: newAST, - })); - - setActiveDocId(docId); - setActiveCategory('private'); - setDeckDrawerOpen(false); - } catch (err: any) { - console.error('保存到我的私密失败:', err); - throw err; - } - }; - - // 挂载或项目切换时,拉取服务端的自由文档(我的私密与项目共享)并同步至 deliverables - useEffect(() => { - if (!session || sessionValidated !== true || !currentProjectId) return; - let active = true; - const fetchDocs = async () => { - try { - const [privateSummaries, sharedSummaries] = await Promise.all([ - listFreeformDocs('my_private', currentProjectId).catch(() => []), - listFreeformDocs('project_shared', currentProjectId).catch(() => []), - ]); - if (!active) return; - const allSummaries = [...privateSummaries, ...sharedSummaries]; - if (allSummaries.length === 0) return; - - setDeliverables((prev) => { - let updated = [...prev]; - for (const s of allSummaries) { - const exists = updated.find((d) => d.docId === s.doc_id); - const category = s.space === 'my_private' ? 'private' : 'shared'; - if (!exists) { - updated.unshift({ - docId: s.doc_id, - projectId: s.project_id || currentProjectId, - taskKey: 'freeform_doc', - title: s.title, - stage: 'V0.1', - category, - creatorId: s.owner_user_id || currentUser.id, - creatorName: currentUser.name, - isFixedWorkflow: false, - isTemplateOnly: false, - lifecycleState: 'drafting', - isClaimed: true, - author: currentUser.name, - reviewer: '', - approver: '', - updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16), - governanceMode: 'freeform', - space: s.space, - }); - } - } - return updated; - }); - } catch (e) { - console.warn('获取服务端自由文档列表异常:', e); - } - }; - fetchDocs(); - return () => { - active = false; - }; - }, [session, sessionValidated, currentProjectId, currentUser.id]); - - // 挂载或项目切换时,拉取服务端工作流受控文档并覆盖本地模板状态 - useEffect(() => { - if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return; - let active = true; - const syncWorkflowDocs = async () => { - try { - // 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。 - const workflowDocs = await listProjectWorkflowDocuments(currentProjectId); - if (!active) return; - setDeliverables((prev) => - mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser), - ); - applyControlledInvalidationPlan( - planControlledContentInvalidation({ - reason: 'list_sync', - candidateIds: controlledHydrateCandidateIds( - deliverablesRef.current, - currentProjectId, - ), - activeDocId: activeDocIdRef.current, - editorOpen: editorHydrationGateRef.current.isEditorOpen(), - }), - ); - } catch (e) { - console.warn('[deliverables] 工作流受控文档同步失败:', e); - } - }; - void syncWorkflowDocs(); - return () => { - active = false; - }; - }, [ + useServerDocumentSync({ session, sessionValidated, currentProjectId, - currentUser.id, - currentUser.name, + currentUser, workflowDocsEpoch, + setDeliverables, + setDocCache, + deliverablesRef, + activeDocIdRef, + editorHydrationGateRef, applyControlledInvalidationPlan, - ]); - - // 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。 - useEffect(() => { - if (!session || sessionValidated !== true || currentProjectId <= 0) return; - let cancelled = false; - const syncLeases = async () => { - const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []); - if (cancelled || docs.length === 0) return; - const leases = new Map( - docs.map((doc) => [ - doc.doc_id, - doc.lease_holder_type - ? { - holderType: doc.lease_holder_type, - holderId: doc.lease_holder_id, - expiresAt: doc.lease_expires_at, - heartbeatAt: doc.lease_heartbeat_at, - } - : undefined, - ]), - ); - setDeliverables((prev) => applyLeaseUpdates(prev, leases, currentProjectId)); - setDocCache((prev) => { - let changed = false; - const next = { ...prev }; - leases.forEach((editLease, docId) => { - if (!next[docId]) return; - if (sameLease(next[docId].editLease, editLease)) return; - next[docId] = { ...next[docId], editLease }; - changed = true; - }); - return changed ? next : prev; - }); - }; - const timer = window.setInterval(() => void syncLeases(), 5000); - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, [session, sessionValidated, currentProjectId]); + }); // 模态弹窗与侧栏 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([]); - useEffect(() => { - void migrateAndClearLegacyMessages(); - }, []); + const { + handleShareToProject, + handleRevokeShare, + handleDeleteDoc, + handleSaveMessageToPrivate, + } = useFreeformDocActions({ + activeDocId, + activeSessionId, + currentProjectId, + currentUser, + deliverables, + setDeliverables, + setDocCache, + setActiveDocId, + setActiveCategory, + setMessages, + setDeckDrawerOpen, + }); // 基于当前激活的项目会话严格隔离对话记录 const currentSessionMessages = useMemo(() => { @@ -1777,11 +1120,6 @@ export default function App() { setActiveQuotes((prev) => prev.filter((_, i) => i !== index)); }; - // 挂载时检测并迁移旧版 localStorage 巨大 MESSAGES 键 - useEffect(() => { - migrateAndClearLegacyMessages(); - }, []); - // 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态) const isStreamingActive = useMemo(() => { return currentSessionMessages.some( @@ -1792,1010 +1130,46 @@ export default function App() { }, [currentSessionMessages]); // 处理交互卡片动作流转 (驳回门禁确认、撤回、取消与终审联动) - const handleCardAction = (cardId: string, actionId: string, payload?: any) => { - // 查找对应的卡片 - let targetCard: InteractiveCardData | undefined; - for (const m of messages) { - if (m.interactiveCard && m.interactiveCard.cardId === cardId) { - targetCard = m.interactiveCard; - break; - } - } - - if (isPlanConfirmCard(targetCard, payload)) { - const planId = String(payload?.plan_id || targetCard?.planId || ''); - const expectedRevision = Number(payload?.expected_revision || targetCard?.planRevision || 0); - const kind = (payload?.kind || (actionId === 'plan_cancel' ? 'cancel' : 'confirm')) as - | 'confirm' - | 'cancel'; - const projectId = currentProjectId; - const sessionId = activeSessionId; - setMessages((prev) => - prev.map((m) => - m.interactiveCard?.cardId === cardId - ? { ...m, interactiveCard: markPlanCard(m.interactiveCard, 'executing') } - : m - ) - ); - void (async () => { - const decisionNonce = - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${cardId}:${actionId}:${expectedRevision}:${Date.now()}:${Math.random()}`; - const result = await submitIntentPlanDecision({ - planId, - projectId, - sessionId, - expectedRevision, - decisionNonce, - decisionDigest: `${kind}:${expectedRevision}`, - kind, - }); - setMessages((prev) => - prev.map((m) => { - if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m; - if (result.httpStatus === 409) { - return { ...m, interactiveCard: reconcileConflictCard(m.interactiveCard, result.plan) }; - } - if (result.plan) { - return { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, result.plan) }; - } - return m; - }) - ); - if (!result.plan && result.httpStatus >= 400) { - const latest = await fetchIntentPlan(planId, projectId, sessionId).catch(() => null); - if (latest) { - setMessages((prev) => - prev.map((m) => - m.interactiveCard?.cardId === cardId && m.interactiveCard - ? { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, latest) } - : m - ) - ); - } - } - })(); - return; - } - - const targetDocId = - payload?.targetDocId || payload?.docId || targetCard?.targetDocId || activeDocId; - const reason = - payload?.reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。'; - - if (actionId === 'confirm_reject') { - // 1. 调用既有文档驳回逻辑 (将目标文档生命周期由 reviewing 回退为 drafting) - setDeliverables((prev) => - prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'drafting' } : d)) - ); - - // 2. 扫描目标文档 AST,在匹配块上追加 type: 'issue' 批注 - let targetBlockId: string | null = null; - let targetSectionName = '第3节'; - setDocCache((prev) => { - let currentDoc = prev[targetDocId]; - if (!currentDoc && ALL_MOCK_DOCUMENTS[targetDocId]) { - const meta = ALL_MOCK_DOCUMENTS[targetDocId]; - currentDoc = markdownToBlockAST(meta.markdown || '', { - docId: meta.docId || targetDocId, - title: meta.title, - taskKey: meta.taskKey, - version: meta.version, - category: meta.category as DeliverableCategory | undefined, - creatorId: meta.creatorId, - creatorName: meta.creatorName, - sharedAt: meta.sharedAt, - lifecycleState: meta.lifecycleState as DocLifecycleState | undefined, - isClaimed: meta.isClaimed, - author: meta.author, - reviewer: meta.reviewer, - approver: meta.approver, - }); - } - if (!currentDoc) return prev; - - const { updatedDoc, matchingBlock } = applyConfirmRejectToDoc( - currentDoc, - cardId, - currentUser.name, - reason, - targetCard?.sectionTitle || payload?.targetSection - ); - targetBlockId = matchingBlock.id; - if (matchingBlock.numbering) { - targetSectionName = `第${matchingBlock.numbering.replace('.', '')}节`; - } - - return { - ...prev, - [targetDocId]: updatedDoc, - }; - }); - - // 3. 更新卡片状态为 resolved - const sectionLabel = - targetCard?.sectionTitle?.match(/第[0-9一二三四五六七八九]+节/)?.[0] || targetSectionName || '第3节'; - const resolutionSummary = `已正式退回编制阶段,批注已挂载至${sectionLabel}`; - - setMessages((prev) => { - const updatedMessages = resolveCardInMessages( - prev, - cardId, - resolutionSummary, - currentUser.name - ); - return [ - ...updatedMessages, - { - id: 'sys-rej-' + Date.now(), - sessionId: activeSessionId, - role: 'system', - content: `⚠️ ${currentUser.name}(审核负责人)驳回了《${targetCard?.targetDocTitle || targetDocId}》,已退回编制阶段。驳回原因:${reason}`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]; - }); - - // 4. 切换当前右侧激活文档为目标文档并高亮匹配块 - setActiveDocId(targetDocId); - const targetDeliverable = deliverables.find((d) => d.docId === targetDocId); - if (targetDeliverable) { - setActiveCategory(targetDeliverable.category); - } - if (targetBlockId) { - setHighlightedBlockId(targetBlockId); - setTimeout(() => { - setHighlightedBlockId(null); - }, 2500); - } - } else if (actionId === 'cancel') { - // 标记卡片为 cancelled 并追加说明 - setMessages((prev) => [ - ...cancelCardInMessages(prev, cardId), - { - id: 'ast-cancel-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '🚫 取消操作', - content: '已取消驳回操作,文档保持待审核状态。', - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - } else if (actionId === 'undo') { - // 撤销驳回:恢复为待审核状态 (reviewing) - setDeliverables((prev) => - prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d)) - ); - - // 移除注入的批注并恢复 reviewing 状态 - setDocCache((prev) => { - const currentDoc = prev[targetDocId]; - if (!currentDoc) return prev; - const updatedDoc = applyUndoRejectToDoc(currentDoc, cardId); - return { - ...prev, - [targetDocId]: updatedDoc, - }; - }); - - // 标记卡片为 cancelled 并追加说明 - setMessages((prev) => [ - ...cancelCardInMessages(prev, cardId), - { - id: 'ast-undo-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '↩️ 撤回驳回', - content: '已在合法窗口期撤回驳回,文档已恢复为待审核状态。', - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - } else if (actionId === 'confirm_final_approve') { - // 终审批准 - setDeliverables((prev) => - prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approved' } : d)) - ); - setDocCache((prev) => { - const currentDoc = prev[targetDocId]; - if (!currentDoc) return prev; - return { - ...prev, - [targetDocId]: { ...currentDoc, lifecycleState: 'approved' as const }, - }; - }); - - setMessages((prev) => [ - ...resolveCardInMessages( - prev, - cardId, - '已终审批准生效', - currentUser.name - ), - { - id: 'appr-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '🏆 终审批准生效', - content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${targetCard?.targetDocTitle || targetDocId}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`, - timestamp: new Date().toTimeString().substring(0, 5), - recommendedCases: [ - { - docId: targetDocId, - taskKey: targetCard?.targetDocId || 'research', - artifactTitle: targetCard?.targetDocTitle || '受控交付物', - sectionTitle: '终审已生效标杆案例', - similarityScore: 1.0, - keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'], - summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。', - }, - ], - }, - ]); - } else if (actionId === 'confirm_review_pass' || actionId === 'confirm_review') { - // 审核通过:流转至待终审状态 (approving) - setDeliverables((prev) => - prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approving' } : d)) - ); - setDocCache((prev) => { - const currentDoc = prev[targetDocId]; - if (!currentDoc) return prev; - return { - ...prev, - [targetDocId]: { ...currentDoc, lifecycleState: 'approving' as const }, - }; - }); - - setMessages((prev) => [ - ...resolveCardInMessages( - prev, - cardId, - '审核已通过,流转至终审阶段', - currentUser.name - ), - { - id: 'rev-pass-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '✅ 审核通过确认', - content: `✅ ${currentUser.name}(审核人)已审核通过《${targetCard?.targetDocTitle || targetDocId}》!正文继续保持锁定,已流转至终审批准人进行最终批准。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - } else if (actionId === 'confirm_submit_review' || actionId === 'submit_review') { - // 提交审核:流转至待审核状态 (reviewing) - setDeliverables((prev) => - prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d)) - ); - setDocCache((prev) => { - const currentDoc = prev[targetDocId]; - if (!currentDoc) return prev; - return { - ...prev, - [targetDocId]: { ...currentDoc, lifecycleState: 'reviewing' as const }, - }; - }); - - setMessages((prev) => [ - ...resolveCardInMessages( - prev, - cardId, - '已正式提交审核,正文锁定', - currentUser.name - ), - { - id: 'sub-rev-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '📤 提交审核确认', - content: `📤 ${currentUser.name}(编制人)已正式提交《${targetCard?.targetDocTitle || targetDocId}》进入审核流程!正文已锁定,李主管(审核人)可在工作台或会话流中进行合规复核。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - } - }; + const handleCardAction = useCardActions({ + messages, + currentProjectId, + activeSessionId, + activeDocId, + currentUser, + deliverables, + setMessages, + setDeliverables, + setDocCache, + setActiveDocId, + setActiveCategory, + setHighlightedBlockId, + }); // 合成网络级异常的结构化错误信息 (fetch/stream 抛出而非后端 error 事件时使用) - const buildNetworkErrorInfo = ( - httpStatus?: number, - opts?: { errorCode?: string; detail?: string }, - ): ChatErrorInfo => { - const code = opts?.errorCode || ''; - const detail = opts?.detail || ''; - if (code === 'DOC_BUSY' || code === 'document_busy') { - return { - userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务', - code: 'DOC_BUSY', - class: 'fatal', - actions: [], - }; - } - if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') { - return { - userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试', - code, - class: 'transient', - actions: ['retry'], - }; - } - if (code === 'DOC_DUTY_FORBIDDEN') { - return { - userMessage: detail || '当前账号不是该文档任务的职责人,无法执行', - code, - class: 'fatal', - actions: ['retry'], - }; - } - if (httpStatus === 401) { - return { - userMessage: '登录已失效,请重新登录后再试', - code: 'ERR_UNAUTHENTICATED', - class: 'fatal', - actions: ['retry'], - }; - } - if (httpStatus === 403) { - if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') { - return { - userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份', - code: code || 'ERR_FORBIDDEN', - class: 'fatal', - actions: ['retry'], - }; - } - return { - userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段', - code: code || 'ERR_FORBIDDEN', - class: 'fatal', - actions: ['retry'], - }; - } - return { - userMessage: '网络连接异常,请检查网络后重试', - code: 'NETWORK_ERROR', - class: fallbackErrorClass({ network: true }), - actions: ['retry'], - }; - }; - - // 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled。 - // 闲聊路径在无 client_turn_id 幂等前禁止自动重放 POST;失败仅展示手动「重试」。 - type StreamAttemptOpts = { - userMsgId?: string; - clientTurnId?: string; - quote?: QuotedMessage; - }; - - const runStreamAttempt = ( - userText: string, - asstId: string, - attemptIndex: number, - opts?: StreamAttemptOpts - ) => { - const quote = opts?.quote; - const controller = new AbortController(); - const streamSessionId = activeSessionId; - if (opts?.clientTurnId) { - turnRuntime.registerTurn({ - clientTurnId: opts.clientTurnId, - sessionId: streamSessionId, - userMsgId: opts.userMsgId || '', - asstId, - status: 'pending', - controller, - startedAt: Date.now(), - }); - } - - const currentProj = sessions.find((s) => s.sessionId === streamSessionId); - let accumulated = ''; - let settled = false; - - const updateAsst = (patch: Partial) => { - setMessages((prev) => prev.map((m) => (m.id === asstId ? { ...m, ...patch } : m))); - }; - - const scheduleRetry = (delayMs: number) => { - setTimeout(() => { - if (controller.signal.aborted) { - updateAsst({ status: 'cancelled', livePhaseLabel: undefined }); - if (opts?.clientTurnId) { - turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'cancelled'); - } - return; - } - runStreamAttempt(userText, asstId, attemptIndex + 1, opts); - }, delayMs); - }; - - const finishTurn = (status: 'completed' | 'failed' | 'cancelled') => { - if (opts?.clientTurnId) { - turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, status); - } - }; - - const failOrRetry = (info: ChatErrorInfo) => { - settled = true; - if (shouldAutoRetryChat(info, attemptIndex)) { - const delayMs = autoRetryDelayMs(attemptIndex); - updateAsst({ - status: 'auto_retrying', - livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`, - }); - scheduleRetry(delayMs); - return; - } - updateAsst({ - status: 'failed', - errorInfo: { - ...info, - userMessage: - attemptIndex > 0 - ? `${info.userMessage}(请确认上一请求是否已在处理后再手动重试)` - : info.userMessage, - }, - livePhaseLabel: undefined, - }); - finishTurn('failed'); - }; - - updateAsst({ - status: 'pending', - livePhaseLabel: attemptIndex > 0 ? '正在手动重试…' : '助手正在处理…', - content: '', - errorInfo: undefined, - }); - if (opts?.clientTurnId) { - turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'pending'); - } - - const bindUserServerId = (serverId: number) => { - if (!opts?.userMsgId) return; - chatWindow.replaceMessageServerId(opts.userMsgId, serverId); - setMessages((prev) => - prev.map((m) => (m.id === opts.userMsgId ? { ...m, serverId } : m)) - ); - }; - - streamChat( - { - session_id: streamSessionId, - user_id: currentUser.id, - project_id: currentProj?.projectId || currentProjectId || 1, - project_name: currentProj?.title || '肌骨超声AI辅助诊断系统', - device_type: currentProj?.deviceType || '肌骨超声', - stage: currentProj?.stage || 'V0.5', - user_name: currentUser.name, - user_role: currentDocRole, - message: userText, - client_turn_id: opts?.clientTurnId, - quote: quote, - }, - { - onEvent: (ev) => { - if (controller.signal.aborted || settled) return; - switch (ev.type) { - case 'turn_ack': { - const userServerId = ev.turn?.user_message_id ?? ev.user_message_id; - if (typeof userServerId === 'number' && userServerId > 0) { - bindUserServerId(userServerId); - } - break; - } - case 'phase': - case 'status': { - const label = ev.phase?.message || ev.status?.message; - updateAsst({ status: 'streaming_status', livePhaseLabel: label || '助手正在处理…' }); - if (opts?.clientTurnId) { - turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming'); - } - break; - } - case 'delta': { - accumulated += ev.delta || ''; - updateAsst({ status: 'streaming_status', content: accumulated }); - if (opts?.clientTurnId) { - turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming'); - } - break; - } - case 'error': { - failOrRetry(mapErrorInfo(ev)); - break; - } - case 'done': { - settled = true; - const userServerId = ev.done?.user_message_id ?? ev.user_message_id; - const serverMsgId = ev.done?.message_id ?? ev.message_id ?? (ev as { messageId?: number }).messageId; - if (typeof userServerId === 'number' && userServerId > 0) { - bindUserServerId(userServerId); - } - if (typeof serverMsgId === 'number' && serverMsgId > 0) { - chatWindow.replaceMessageServerId(asstId, serverMsgId); - } - const exportCap = ev.export_capability || ev.done?.export_capability; - updateAsst({ - status: 'completed', - livePhaseLabel: undefined, - serverId: typeof serverMsgId === 'number' ? serverMsgId : undefined, - exportCapability: exportCap, - }); - finishTurn('completed'); - break; - } - default: - break; - } - }, - }, - controller.signal, - opts?.clientTurnId ? { idempotencyKey: opts.clientTurnId } : undefined - ).catch(async (_err) => { - if (settled) return; - if (controller.signal.aborted) { - updateAsst({ status: 'cancelled', livePhaseLabel: undefined }); - finishTurn('cancelled'); - return; - } - if (opts?.clientTurnId) { - try { - const turnStatus = await fetchChatTurnStatus(streamSessionId, opts.clientTurnId); - if (turnStatus?.status === 'completed' && turnStatus.assistantMessageId) { - settled = true; - if (turnStatus.userMessageId) bindUserServerId(turnStatus.userMessageId); - chatWindow.replaceMessageServerId(asstId, turnStatus.assistantMessageId); - updateAsst({ - status: 'completed', - livePhaseLabel: undefined, - serverId: turnStatus.assistantMessageId, - }); - finishTurn('completed'); - return; - } - if (turnStatus?.status === 'processing') { - updateAsst({ - status: 'streaming_status', - livePhaseLabel: '连接中断,服务端仍在处理…', - }); - if (opts?.clientTurnId) { - turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming'); - } - return; - } - } catch { - // fall through to structured failure - } - } - const httpStatus = - typeof _err === 'object' && _err !== null && 'httpStatus' in _err - ? Number((_err as { httpStatus?: number }).httpStatus) - : undefined; - const errMeta = _err as { errorCode?: string; detail?: string; message?: string }; - failOrRetry( - buildNetworkErrorInfo(httpStatus, { - errorCode: errMeta.errorCode, - detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '), - }), - ); - }); - }; - - // P2:长任务尝试模式。'run' 新任务 | 'resume' 续跑失败工作区 | 'restart' 按原意图新工作区 - const runTaskAttempt = ( - task: TaskRunContext, - asstId: string, - mode: 'run' | 'resume' | 'restart', - attemptIndex: number, - opts?: { phaseEvents?: PhaseEvent[]; workDir?: string; reconnectRunId?: string } - ) => { - const sessionId = task.sessionId || activeSessionId; - let retainedRunId = resolveReconnectRunId({ - attemptIndex, - optsReconnectRunId: opts?.reconnectRunId, - }); - taskRunStream.abortSession(sessionId); - // 重连保留显式 runId;禁止无条件 setRunId(null) 导致自动重试丢失 - if (retainedRunId) { - taskRunStream.setRunId(sessionId, retainedRunId); - } else { - taskRunStream.setRunId(sessionId, null); - } - const controller = new AbortController(); - taskRunStream.setController(sessionId, controller); - - let accumulated = ''; - let settled = false; - - const patchAsst = (patch: Partial | ((m: ChatMessage) => Partial)) => { - setMessages((prev) => - prev.map((m) => (m.id === asstId ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m)) - ); - }; - - const scheduleRetry = (delayMs: number) => { - setTimeout(() => { - if (controller.signal.aborted) { - patchAsst({ status: 'cancelled', livePhaseLabel: undefined }); - taskRunStream.setThinking(sessionId, false); - return; - } - runTaskAttempt(task, asstId, mode, attemptIndex + 1, nextAttemptOpts(opts, retainedRunId)); - }, delayMs); - }; - - const failOrRetry = (info: ChatErrorInfo) => { - settled = true; - if (shouldAutoRetryTask(info, attemptIndex)) { - const delayMs = autoRetryDelayMs(attemptIndex); - patchAsst({ - status: 'auto_retrying', - livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`, - }); - scheduleRetry(delayMs); - return; - } - patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined }); - taskRunStream.setThinking(sessionId, false); - }; - - const syncControlledDocsAfterTask = async (taskKey: string, preferredDocId?: string | null) => { - if (!task.projectId || task.projectId <= 0 || !taskKey) return; - const { merged, serverDocId } = await pollUntilPostTaskServerDoc({ - listDocs: () => listProjectWorkflowDocuments(task.projectId!).catch(() => []), - getDeliverables: () => deliverablesRef.current, - merge: (prev, docs) => - mergeWorkflowDocumentsFromServer(prev, docs, task.projectId!, currentUser), - projectId: task.projectId, - taskKey, - preferredDocId, - maxAttempts: 10, - delayMs: 800, - onMerged: (next) => { - deliverablesRef.current = next; - setDeliverables(next); - applyControlledInvalidationPlan( - planControlledContentInvalidation({ - reason: 'post_task', - candidateIds: controlledHydrateCandidateIds(next, task.projectId), - activeDocId: activeDocIdRef.current, - editorOpen: editorHydrationGateRef.current.isEditorOpen(), - }), - ); - }, - }); - if ( - serverDocId && - shouldSwitchTemplateSlotToServer({ - activeDocId, - deliverables: merged, - projectId: task.projectId, - taskKey, - serverDocId, - }) - ) { - setActiveDocId(serverDocId); - setActiveCategory('workflow'); - } - }; - - // 等待态:resume 保留已有 phaseEvents 续接;run/restart 重置正文与步骤条 - patchAsst({ - status: 'pending', - livePhaseLabel: - mode === 'resume' ? '正在继续任务…' : mode === 'restart' ? '正在重新开始任务…' : '任务执行中…', - errorInfo: undefined, - ...(mode === 'resume' || attemptIndex > 0 - ? {} - : { content: '', phaseEvents: [], taskWorkspaceId: undefined }), - }); - taskRunStream.setThinking(sessionId, true); - - const onEvent = (ev: StreamEvent) => { - if (controller.signal.aborted || settled) return; - switch (ev.type) { - case 'phase': { - patchAsst((m) => ({ - status: 'streaming_status', - livePhaseLabel: ev.phase?.message || m.livePhaseLabel, - phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase), - })); - break; - } - case 'status': { - if (ev.status?.message) { - patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message }); - } - break; - } - case 'delta': { - accumulated += ev.delta || ''; - patchAsst({ status: 'streaming_status', content: accumulated }); - break; - } - case 'deliverable': { - const title = ev.deliverable?.title; - if (title) { - accumulated += (accumulated ? '\n\n' : '') + `📄 已生成文档:**${title}**`; - patchAsst({ status: 'streaming_status', content: accumulated }); - } - break; - } - case 'governance': { - const governance = ev.governance; - const docId = governance?.doc_id; - if (docId) { - const leaseReleased = governance.action === 'lease_released'; - const editLease = leaseReleased - ? undefined - : { holderType: 'squad' as const, holderId: taskRunStream.activeTaskRunId || undefined }; - setDeliverables((prev) => - prev.map((doc) => (doc.docId === docId ? { ...doc, editLease } : doc)), - ); - setDocCache((prev) => { - const ast = prev[docId]; - return ast ? { ...prev, [docId]: { ...ast, editLease } } : prev; - }); - } - if (governance?.notification_msg) { - patchAsst({ - status: 'streaming_status', - livePhaseLabel: governance.notification_msg, - }); - } - break; - } - case 'error': { - failOrRetry(mapErrorInfo(ev)); - break; - } - case 'done': { - settled = true; - const finishedRunId = retainedRunId || taskRunStream.activeTaskRunId; - taskRunStream.setRunId(sessionId, null); - taskRunStream.setThinking(sessionId, false); - void (async () => { - let nextStatus: AssistantMessageStatus = 'completed'; - let preferredDocId: string | null = null; - if (finishedRunId) { - const remote = await getTaskRun(finishedRunId).catch(() => null); - if (remote?.status) { - const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant'); - if (mapped) nextStatus = mapped; - } - } - patchAsst({ - status: nextStatus, - livePhaseLabel: undefined, - taskWorkspaceId: ev.done?.workspace_id, - ...(finishedRunId ? { taskRunId: finishedRunId } : {}), - }); - // quality_failed:未过门禁稿不得当作正式受控版本激活/同步切换 - if (nextStatus === 'completed' || nextStatus === 'pending_author_confirmation') { - await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId); - } - await chatWindow.refresh().catch(() => undefined); - })(); - break; - } - default: - break; - } - }; - - const startObserver = async () => { - const body = - mode === 'resume' && opts?.workDir - ? buildResumeRequest(task, opts.workDir) - : buildTaskRequest(task); - - if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') { - // 断线重试:显式 reconnectRunId 保留;禁止 setMessages updater 取 runId - const existingRunId = resolveReconnectRunId({ - attemptIndex, - optsReconnectRunId: retainedRunId || opts?.reconnectRunId, - }); - if (attemptIndex > 0 && existingRunId) { - const remote = await getTaskRun(existingRunId).catch(() => null); - if (remote && shouldSubscribeExistingRun(remote.status)) { - retainedRunId = existingRunId; - taskRunStream.setRunId(sessionId, existingRunId); - patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' }); - await subscribeTaskRunEvents(existingRunId, { onEvent }, controller.signal); - return; - } - if (remote && isRemoteTaskTerminal(remote.status)) { - settled = true; - const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed'; - patchAsst({ - status: mapped, - livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, - taskRunId: existingRunId, - }); - taskRunStream.setThinking(sessionId, false); - if (mapped === 'completed' || mapped === 'pending_author_confirmation') { - await syncControlledDocsAfterTask(task.taskKey || 'intake'); - } - return; - } - } - - const created = await createTaskRun({ - ...body, - session_id: task.sessionId, - project_id: task.projectId, - user_id: task.userId, - client_turn_id: task.clientTurnId, - user_message: task.userMessage, - }); - retainedRunId = created.run_id; - taskRunStream.setRunId(sessionId, created.run_id); - patchAsst({ taskRunId: created.run_id }); - if (isBlockedGovernanceStatus(created.status)) { - settled = true; - patchAsst({ - status: 'blocked_pending_governance', - content: GOVERNANCE_BLOCKED_HINT, - livePhaseLabel: undefined, - taskRunId: created.run_id, - }); - taskRunStream.setRunId(sessionId, null); - taskRunStream.setThinking(sessionId, false); - await chatWindow.refresh().catch(() => undefined); - return; - } - await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal); - return; - } - - await streamTask(body, { onEvent }, controller.signal); - }; - - void startObserver().catch(async (_err) => { - if (settled) return; - if (controller.signal.aborted) { - patchAsst({ status: 'cancelled', livePhaseLabel: undefined }); - taskRunStream.setThinking(sessionId, false); - return; - } - const runId = retainedRunId || taskRunStream.activeTaskRunId || undefined; - if (runId) { - try { - const remote = await getTaskRun(runId); - if (remote.status === 'quality_failed') { - settled = true; - patchAsst({ - status: 'quality_failed', - livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, - taskRunId: runId, - }); - taskRunStream.setThinking(sessionId, false); - return; - } - if (isBlockedGovernanceStatus(remote.status)) { - settled = true; - patchAsst({ - status: 'blocked_pending_governance', - content: GOVERNANCE_BLOCKED_HINT, - livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, - taskRunId: runId, - }); - taskRunStream.setThinking(sessionId, false); - return; - } - if (remote.status === 'completed') { - settled = true; - patchAsst({ - status: 'completed', - livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, - taskRunId: runId, - }); - taskRunStream.setThinking(sessionId, false); - await syncControlledDocsAfterTask(task.taskKey || 'intake'); - return; - } - if (remote.status === 'pending_author_confirmation') { - settled = true; - patchAsst({ - status: 'pending_author_confirmation', - livePhaseLabel: undefined, - taskWorkspaceId: remote.workspace_id, - taskRunId: runId, - }); - taskRunStream.setThinking(sessionId, false); - await syncControlledDocsAfterTask(task.taskKey || 'intake'); - await chatWindow.refresh().catch(() => undefined); - return; - } - if (shouldSubscribeExistingRun(remote.status)) { - // 原 run 仍在跑:重订阅,不 POST 新 run - settled = false; - retainedRunId = runId; - patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId }); - await subscribeTaskRunEvents(runId, { onEvent }, controller.signal); - return; - } - if (remote.status === 'failed' || remote.status === 'cancelled') { - failOrRetry(buildNetworkErrorInfo()); - return; - } - } catch { - // fall through to network retry path - } - } - const httpStatus = - typeof _err === 'object' && _err !== null && 'httpStatus' in _err - ? Number((_err as { httpStatus?: number }).httpStatus) - : undefined; - const errMeta = _err as { errorCode?: string; detail?: string; message?: string }; - failOrRetry( - buildNetworkErrorInfo(httpStatus, { - errorCode: errMeta.errorCode, - detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '), - }), - ); - }); - }; - - // 结构化错误 / 已停止会话上的用户手动恢复动作 (P2:retry / continue / restart 分型恢复) - const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => { - const target = messages.find((m) => m.id === messageId); - if (!target) return; - - // 长任务路径:按 taskRequest 重发 / 续跑工作区 / 按原意图重启 - if (target.taskRequest) { - if (action === 'retry') { - const userText = target.taskRequest.userMessage || ''; - const retryActive = - deliverables.find((d) => d.docId === activeDocId) || - visibleDeliverables.find((d) => d.docId === activeDocId); - const reResolved = resolveTaskTemplateForMessage( - userText, - currentProjectDeliverables, - retryActive, - ); - const retryTask = reResolved - ? { - ...target.taskRequest, - taskKey: reResolved.taskKey, - docId: reResolved.docId || target.taskRequest.docId, - } - : target.taskRequest; - runTaskAttempt(retryTask, messageId, 'run', 0); - } else if (action === 'continue') { - const wsId = target.errorInfo?.workspaceId || target.taskWorkspaceId; - if (!wsId) return; - runTaskAttempt(target.taskRequest, messageId, 'resume', 0, { - phaseEvents: target.phaseEvents, - workDir: wsId, - }); - } else { - if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return; - runTaskAttempt(target.taskRequest, messageId, 'restart', 0); - } - return; - } - - // 闲聊路径(P1 行为不变:仅 retry) - if (action !== 'retry') return; - const sourceMsg = target.sourceUserMessageId - ? messages.find((m) => m.id === target.sourceUserMessageId) - : undefined; - const originalText = sourceMsg?.content?.trim(); - if (!originalText) return; - runStreamAttempt(originalText, messageId, 0, { - userMsgId: sourceMsg?.id, - clientTurnId: target.clientTurnId || sourceMsg?.clientTurnId, - }); - }; + const { runStreamAttempt, runTaskAttempt, handleErrorAction } = useTaskRunAttempts({ + activeSessionId, + activeDocId, + currentProjectId, + currentUser, + currentDocRole, + sessions, + messages, + deliverables, + visibleDeliverables, + currentProjectDeliverables, + deliverablesRef, + activeDocIdRef, + editorHydrationGateRef, + turnRuntime, + taskRunStream, + chatWindow, + setMessages, + setDeliverables, + setDocCache, + setActiveDocId, + setActiveCategory, + applyControlledInvalidationPlan, + }); // 用户主动停止:仅中止当前 session 的活跃 turn const handleStopStreaming = () => { @@ -2808,613 +1182,202 @@ export default function App() { } }; + const handleLogout = () => { + turnRuntime.abortAllSessions(); + taskRunStream.abortAllSessions(); + void authClient.logout().finally(() => { + setSession(null); + setCapabilities([]); + setSessionValidated(false); + }); + }; + // 发送消息与对话驱动双轨逻辑 - const handleSendMessage = async ( - text: string, - attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[], - quotes?: QuotedMessage[] - ): Promise => { - const trimmed = text.trim(); - let clientTurnId: string; - try { - clientTurnId = createClientTurnId(); - } catch (err) { - return { - accepted: false, - error: err instanceof Error ? err.message : '无法生成发送标识,请重试', - }; - } - - // 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退 - let processedAttachments = attachments; - if (attachments && attachments.length > 0) { - let registered; + // 编制人接受变更提议(经 HTTP decide) + const handleAcceptProposal = () => { + if (!pendingProposal?.proposalId) return; + const proposalId = pendingProposal.proposalId; + void (async () => { try { - registered = await registerUploadAttachments({ - attachments, - projectId: currentProjectId, - userId: currentUser.id, - userName: currentUser.name, - createFreeformDoc, + setProposalError(null); + await decideModificationProposal({ + proposal_id: proposalId, + approve: true, + reason: '编制人确认采纳变更提案', }); - } catch (err) { - return { - accepted: false, - error: err instanceof Error ? err.message : '附件服务端注册失败,请重试', - }; - } - - const newDeliverablesList = registered.map((r) => r.deliverable); - const newDocCacheEntries: Record = {}; - for (const r of registered) { - const baseTitle = r.deliverable.title; - const initialMd = r.detail.content_markdown; - const newAST = markdownToBlockAST(initialMd, { - docId: r.serverDocId, - title: baseTitle, - taskKey: 'user_requirements', - version: 'V1.0', - category: 'private', - creatorId: currentUser.id, - creatorName: currentUser.name, - lifecycleState: 'drafting', - isClaimed: true, - author: currentUser.name, - reviewer: '', - approver: '', - }); - newAST.governanceMode = 'freeform'; - newAST.space = 'my_private'; - newDocCacheEntries[r.serverDocId] = newAST; - if (r.attachment.fileRaw) { - saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) => - console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e), - ); + 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 }); } - } - - processedAttachments = registered.map((r) => r.attachment); - const newDocIds = new Set(newDeliverablesList.map((d) => d.docId)); - setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]); - setDocCache((prev) => ({ - ...prev, - ...newDocCacheEntries, - })); - - if (processedAttachments.length > 0 && processedAttachments[0].docId) { - setActiveDocId(processedAttachments[0].docId); - setActiveCategory('private'); - } - } - - const userMsg: ChatMessage = { - id: 'msg-' + Date.now(), - clientTurnId, - clientMessageId: `${clientTurnId}:user`, - sessionId: activeSessionId, - role: 'user', - authorName: currentUser.name, - content: text, - timestamp: new Date().toTimeString().substring(0, 5), - attachments: processedAttachments, - quotes: quotes && quotes.length > 0 ? quotes : undefined, - }; - setActiveQuotes([]); - - // 检查是否为肯定回复(双轨同效判定:打字回复等同于点击卡片默认动作) - const isAffirmative = isAffirmativeResponse(trimmed); - const lastActive = findLastActiveCard(messages, activeSessionId); - - if (isAffirmative && lastActive) { - const { card } = lastActive; - const defaultAction = card.actions.find((a) => a.isAffirmativeDefault) || card.actions[0]; - if (defaultAction) { - setMessages((prev) => [...prev, userMsg]); - handleCardAction(card.cardId, defaultAction.actionId, defaultAction.payload); - return { accepted: true }; - } - } - - // 递减消息列表中尚未完结的 active 卡片 ttlTurns,若 <= 0 则标记为 expired - setMessages((prev) => [...decrementActiveCardsTtl(prev, activeSessionId), userMsg]); - - // 命中以下任一演示用关键词捷径时才走本地模拟分支 (与真实后端 SSE 路径互斥,判定条件与下方分支体一一对应) - const isGenDocShortcut = - text.includes('生成文档') || - text.includes('起草文档') || - text.includes('测算') || - text.includes('推演') || - text.includes('备忘') || - text.includes('生成报告'); - const isRejectShortcut = text.includes('驳回') || text.includes('退回'); - const isModifyShortcut = - (text.includes('修改') || text.includes('更新') || text.includes('调整')) && - docAST.isClaimed && - currentDocRole === 'author'; - const isReviewPassShortcut = - (text.includes('通过') || text.includes('同意')) && - ((docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') || - (docAST.lifecycleState === 'approving' && currentDocRole === 'approver')); - const sendActiveDoc = - deliverables.find((d) => d.docId === activeDocId) || - visibleDeliverables.find((d) => d.docId === activeDocId); - const resolvedTask = resolveTaskTemplateForMessage( - text, - currentProjectDeliverables, - sendActiveDoc, - ); - const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut; - - // P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复) - if (resolvedTask) { - const asstId = 'ast-' + Date.now(); - const currentProj = sessions.find((s) => s.sessionId === activeSessionId); - // 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续 - const clientTurnId = `turn_${Date.now()}`; - const projId = resolveSessionProjectId(currentProj, currentProjectId); - if (projId <= 0) { - console.error('[task] 无法解析会话所属项目', activeSessionId); - return { accepted: false }; - } - const taskKey = resolvedTask.taskKey; - const matchedDeliverable = deliverables.find((d) => { - if (d.projectId !== projId) return false; - if (resolvedTask.docId) return d.docId === resolvedTask.docId; - return d.taskKey === taskKey; - }); - const activeLease = matchedDeliverable?.editLease; - const leaseIsActive = - !!activeLease?.holderType && - (!activeLease.expiresAt || new Date(activeLease.expiresAt).getTime() > Date.now()); - if (leaseIsActive) { - setMessages((prev) => [ - ...prev, - { - id: 'ast-busy-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '项目助手', - content: `《${matchedDeliverable?.title || '该文档'}》正在由${activeLease?.holderType === 'squad' ? '项目助手' : '其他用户'}编辑/生成中。当前请求未启动新任务;完成后状态会自动解除。`, - timestamp: new Date().toTimeString().substring(0, 5), - status: 'completed', - }, - ]); - return { accepted: true }; - } - const baseTask: TaskRunContext = { - taskKey, - projectName: currentProj?.title || '医疗器械研发演示项目', - customerName: '北京水木东方', - deviceType: currentProj?.deviceType || '医疗器械', - sessionId: activeSessionId, - userId: currentUser.id, - projectId: projId, - clientTurnId, - userMessage: text, - docId: - resolvedTask.docId || - canonicalDocIdForTask( - projId, - taskKey, - deliverables, - matchedDeliverable?.docId, - ), - }; - const placeholder: ChatMessage = { - id: asstId, - sessionId: activeSessionId, - role: 'assistant', - authorName: '项目助手', - content: '', - timestamp: new Date().toTimeString().substring(0, 5), - status: 'pending', - livePhaseLabel: '任务执行中…', - taskRequest: baseTask, - }; - setMessages((prev) => [...prev, placeholder]); - // 异步读取附件内容后再触发任务,避免文档材料丢失 - void (async () => { - const materials = await readAttachmentsAsMaterials( - processedAttachments || [], - baseTask.projectId, - ); - const task: TaskRunContext = materials.length > 0 ? { ...baseTask, materials } : baseTask; - runTaskAttempt(task, asstId, 'run', 0); - })(); - return { accepted: true }; - } - - if (!isMockShortcut) { - // 真实后端 SSE 路径:立即挂起「等待中」占位气泡,无人为延迟,随后由 runStreamAttempt 驱动状态机 - // (pending → streaming_status → completed/failed/auto_retrying/cancelled),严禁静默兜底话术 - const asstId = 'ast-' + Date.now(); - const placeholder: ChatMessage = { - id: asstId, - clientTurnId, - clientMessageId: `${clientTurnId}:assistant`, - sessionId: activeSessionId, - role: 'assistant', - authorName: '项目助手', - content: '', - timestamp: new Date().toTimeString().substring(0, 5), - status: 'pending', - livePhaseLabel: '助手正在处理…', - sourceUserMessageId: userMsg.id, - }; - setMessages((prev) => [...prev, placeholder]); - runStreamAttempt(text, asstId, 0, { - userMsgId: userMsg.id, - clientTurnId, - quote: quotes && quotes.length > 0 ? quotes[0] : undefined, - }); - return { accepted: true }; - } - - taskRunStream.setThinking(activeSessionId, true); - - setTimeout(() => { - taskRunStream.setThinking(activeSessionId, false); - - // 1. 用户要求助手生成新文档/测算/推演/备忘 (默认自动归档至【我的私密生成】,保护隐私) - if (isGenDocShortcut) { - const newDocId = `doc-private-${Date.now()}`; - const docTitle = text.includes('备忘') - ? `${currentUser.name}专属:临床与商业推演备忘录` - : text.includes('测算') - ? `${currentUser.name}专属:BOM器件成本与毛利灵敏度测算` - : `${currentUser.name}专属:AI协同专项推演草案`; - - const newMeta: DeliverableItem = { - docId: newDocId, - projectId: resolveSessionProjectId( - sessions.find((s) => s.sessionId === activeSessionId), - currentProjectId, - ) || 1, - taskKey: 'custom_draft', - title: docTitle, - stage: 'V0.5', - category: 'private', - creatorId: currentUser.id, - creatorName: currentUser.name, - isFixedWorkflow: false, - isTemplateOnly: false, - lifecycleState: 'drafting', - isClaimed: true, - author: currentUser.name, - reviewer: '', - approver: '', - updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), - summary: `根据对话中您的需求“${text.slice(0, 60)}...”即时生成的个人私密草稿。`, - }; - - const sampleMd = `# ${docTitle}\n\n## 1. 产生背景与对话诉求\n根据用户【${currentUser.name}】于 ${new Date().toLocaleTimeString()} 提出的协同要求:“${text}”,参谋小队即时推演生成本专属文档。\n\n> 🔒 [隐私受控标记] 本文档为用户个人私密生成物,仅 ${currentUser.name} 本人可见,不进入项目固定受控审签体系。若推演成熟,可随时点击下方「一键共享给项目组」转为协作参考资料。\n\n## 2. 核心分析与推演结论\n依据系统知识库与工程模型,对关键指标与风险进行分解:\n- 技术可行性评级:A 级(具备清晰工程落地路径)\n- 建议跟进措施:优化元器件批量采购配额,规避长周期交货风险。\n`; - - const parsedAst = markdownToBlockAST(sampleMd, { - docId: newDocId, - title: docTitle, - taskKey: 'custom_draft', - version: 'V0.1', - category: 'private', - creatorId: currentUser.id, - creatorName: currentUser.name, - lifecycleState: 'drafting', - isClaimed: true, - author: currentUser.name, - }); - - setDeliverables((prev) => [newMeta, ...prev]); - setDocCache((prev) => ({ ...prev, [newDocId]: parsedAst })); - setActiveCategory('private'); - setActiveDocId(newDocId); setMessages((prev) => [ ...prev, { - id: 'ast-' + Date.now(), + id: 'ast-p-' + Date.now(), sessionId: activeSessionId, role: 'assistant', authorName: '水木东方协同参谋', - intentTag: '🔒 个人私密文档已生成', - content: `收到您的要求,已为您即时生成专属推演文档《${docTitle}》!\n\n根据系统隐私保护机制,该文档已自动存入【我的私密】库,**仅您本人可见**。右侧工作台已为您自动切至该文档,您可以自由查看与编辑。推演成熟后,可随时点击底部「一键共享给项目组」作为团队协作参考资料。`, + intentTag: '✅ 协同变更已生效', + content: `变更提议 ${proposalId} 已批准并落盘至《${docAST.title}》,受控文档已同步更新。`, timestamp: new Date().toTimeString().substring(0, 5), }, ]); - return; - } - - // 2. 用户发送驳回意图 (且存在处于 reviewing 的目标交付物) - if (isRejectShortcut) { - const targetReviewDoc = - docAST.lifecycleState === 'reviewing' - ? docAST - : deliverables.find((d) => d.lifecycleState === 'reviewing') - ? (docCache[deliverables.find((d) => d.lifecycleState === 'reviewing')!.docId] || docAST) - : null; - - if (targetReviewDoc && targetReviewDoc.lifecycleState === 'reviewing') { - const matchingBlock = findMatchingBlockForReview(targetReviewDoc); - const card = createRejectConfirmationCard(targetReviewDoc, matchingBlock); - - setMessages((prev) => [ - ...prev, - { - id: 'ast-reject-gate-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '🛡️ 审核驳回门禁', - content: `检测到您对《${targetReviewDoc.title}》提出驳回审核意图。驳回将退回编制阶段并解除锁定,同时向编制人下达整改批注。请确认操作:`, - timestamp: new Date().toTimeString().substring(0, 5), - interactiveCard: card, - }, - ]); - return; - } else { - setMessages((prev) => [ - ...prev, - { - id: 'ast-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '⚠️ 状态校验提示', - content: `当前受控文档《${docAST.title}》处于【${docAST.lifecycleState}】阶段,非待审核状态,无需执行驳回。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - return; - } - } - - 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), - }, - ]); - return; - } - } - - if (text.includes('通过') || text.includes('同意')) { - if (docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') { - handlePassReview(); - setMessages((prev) => [ - ...prev, - { - id: 'ast-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '📋 审核通过流转', - content: `已确认审核通过!《${docAST.title}》状态已流转至【待批准 (approving)】,已呈报王总监终审。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - return; - } - if (docAST.lifecycleState === 'approving' && currentDocRole === 'approver') { - handleApprove(); - return; - } - } - - // P2:模板填报演示已由真实 task stream 链路替代(见上方 matchedTemplate 分支) - }, 1000); - - return { accepted: true }; - }; - - // 编制人接受变更提议 - 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 }); - } - - setMessages((prev) => [ - ...prev, - { - id: 'ast-p-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '✅ 协同变更已生效', - content: `变更提议已成功执行并落盘至《${docAST.title}》,受控文档已同步更新。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - }; - - // 块操作:编排层先 PATCH,成功才提交 AST;失败保留编辑器并明确提示 - const handleSaveBlockEdit = async (updated: DocumentBlock) => { - const target = deliverables.find((d) => d.docId === activeDocId); - if ( - target && - (target.sourceType === 'uploaded_file' || Boolean(target.fileMeta)) && - target.governanceMode === 'freeform' && - !docAST.rootBlock?.children?.length - ) { - closeBlockEditor(); - return; - } - const result = await saveControlledBlockEdit({ - editorDocId: editorSession?.docId, - activeDocId, - ast: docAST, - updated, - actorUserId: currentUser.id, - }); - if (!result.ok) { - setDocRevConflict(result.message); - return; - } - editorHydrationGateRef.current.markSaved(); - setDocAST(result.ast); - setDocCache((prev) => ({ ...prev, [activeDocId]: result.ast })); - closeBlockEditor(); - setDocRevConflict(null); - setDocRevOverlay(result.overlay); - setDocRevRefreshKey((k) => k + 1); - }; - - const handleInsertBlock = (index: number, position: 'above' | 'below') => { - const newBlock: DocumentBlock = { - id: 'blk_' + Math.random().toString(36).substring(2, 9), - type: 'paragraph', - content: '【新插入技术要求】:在此填写具体受控技术规范或临床评定指标...', - metadata: { - lastModifiedBy: `${currentUser.name} (${currentUser.position})`, - lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), - }, - }; - const children = docAST.rootBlock.children || []; - const insertAt = position === 'above' ? index : index + 1; - children.splice(insertAt, 0, newBlock); - recomputeNumbering(docAST.rootBlock); - setDocAST({ ...docAST }); - }; - - const handleMoveBlock = (index: number, direction: 'up' | 'down') => { - const children = docAST.rootBlock.children || []; - const targetIdx = direction === 'up' ? index - 1 : index + 1; - if (targetIdx >= 0 && targetIdx < children.length) { - const temp = children[index]; - children[index] = children[targetIdx]; - children[targetIdx] = temp; - recomputeNumbering(docAST.rootBlock); - setDocAST({ ...docAST }); - } - }; - - const handleDeleteBlock = (index: number) => { - const children = docAST.rootBlock.children || []; - children.splice(index, 1); - recomputeNumbering(docAST.rootBlock); - setDocAST({ ...docAST }); - }; - - // 批注操作 - const handleAddComment = (comment: Omit) => { - if (!commentingBlock || !canAddBlockComment(currentDocRole)) return; - const newCmt: BlockComment = { - ...comment, - id: 'cmt_' + Math.random().toString(36).substring(2, 8), - createdAt: new Date().toTimeString().substring(0, 5), - }; - commentingBlock.comments = [...(commentingBlock.comments || []), newCmt]; - setDocAST({ ...docAST }); - }; - - // 法定生命周期流转 - const handleClaim = () => { - docAST.isClaimed = true; - setDocAST({ ...docAST }); - setDeliverables((prev) => - prev.map((d) => (d.docId === activeDocId ? { ...d, isClaimed: true } : 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)) - ); - - setMessages((prev) => [ - ...prev, - { - id: 'rej-' + Date.now(), - sessionId: activeSessionId, - role: 'system', - content: `⚠️ 李主管(审核负责人)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`, - 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)) - ); - - setMessages((prev) => [ - ...prev, - { - id: 'appr-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '🏆 终审批准生效', - content: `🎉 祝贺!王总监(批准人)已终审批准《${docAST.title}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`, - timestamp: new Date().toTimeString().substring(0, 5), - recommendedCases: [ + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setProposalError(message); + setMessages((prev) => [ + ...prev, { - 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: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。', + 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), + }, + ]); + } + })(); + }; + + const { + handleSaveBlockEdit, + handleInsertBlock, + handleMoveBlock, + handleDeleteBlock, + handleAddComment, + } = useBlockEditing({ + docAST, + setDocAST, + activeDocId, + deliverables, + currentUser, + currentDocRole, + editorSession, + commentingBlock, + editorHydrationGateRef, + setDocCache, + setDocRevConflict, + setDocRevOverlay, + setDocRevRefreshKey, + closeBlockEditor, + }); + + const { + handleClaim, + handleSubmitReview, + handlePassReview, + handleRejectReview, + handleApprove, + handleRejectApprove, + } = useDocumentLifecycleActions({ + docAST, + setDocAST, + activeDocId, + currentUser, + activeSessionId, + setDeliverables, + setMessages, + }); + + const handleSendMessage = useSendMessage({ + currentProjectId, + currentUser, + activeSessionId, + activeDocId, + activeDocIdRef, + messages, + docAST, + docCache, + currentDocRole, + deliverables, + visibleDeliverables, + currentProjectDeliverables, + sessions, + setDeliverables, + setDocCache, + setActiveDocId, + setActiveCategory, + setActiveQuotes, + setMessages, + setProposalError, + setPendingProposal, + taskRunStream, + handleCardAction, + runTaskAttempt, + runStreamAttempt, + handlePassReview, + handleApprove, + }); + // 导出 DOCX (Word 渲染器) const handleExportDocx = async () => { try { + const { exportDocumentToDocx } = await import('./engine/docxRenderer'); await exportDocumentToDocx(docAST); } catch (err) { console.error('导出 DOCX 失败:', err); } }; - // 导出单条智能体回复 DOCX (轻量专属 Word 导出) const handleExportReplyDocx = async (message: ChatMessage) => { try { + const { downloadDocxReply } = await import('./features/replyExport/downloadDocx'); await downloadDocxReply(message.content || '', undefined, { createdAt: message.createdAt || message.timestamp, }); @@ -3423,10 +1386,9 @@ export default function App() { } }; - - // 导出 XLSX (Excel 渲染器) const handleExportXlsx = async () => { try { + const { exportDocumentToXlsx } = await import('./engine/xlsxRenderer'); await exportDocumentToXlsx(docAST); } catch (err) { console.error('导出 XLSX 失败:', err); @@ -3457,6 +1419,38 @@ export default function App() { }; // 未登录拦截门禁:展示现代化医疗合规登录与注册页面 + const handleCreateProjectConfirm = useCreateProjectConfirm({ + currentUser, + createProjectInFlightRef, + createProjectBootstrapIdRef, + setCreateProjectError, + setCreateProjectSubmitting, + setCreateProjectSubmitPhase, + setCreateProjectModalOpen, + setProjects, + setSessions, + setMessages, + setDeliverables, + setDocCache, + setActiveSessionId, + setActiveDocId, + setActiveCategory, + }); + + const { + handleAddProjectMember, + handleUpdateProjectMemberRole, + handleRemoveProjectMember, + handleRestoreProjectMember, + } = useProjectMemberActions({ + currentProject, + syncProjectMembersFromServer, + setWorkflowDocsEpoch, + setSessions, + setMessages, + setProjectMembersError, + }); + if (!session) { return ( { - if (!beginCreateProjectSubmit(createProjectInFlightRef)) { - return; - } - setCreateProjectError(null); - setCreateProjectSubmitting(true); - setCreateProjectSubmitPhase( - resolveCreateProjectSubmitPhase(createProjectBootstrapIdRef.current), + // 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell + if (view === 'admin') { + return ( + setView('workbench'), + onUsersChanged: async () => { + const users = await authClient.getAdminUsers(); + setAllUsers(users); + }, + onRolesChanged: (newRoles: RoleDefinition[]) => setRoles(newRoles), + }} + /> ); - - let newProjId: number; - try { - try { - const ensured = await ensureCreatedProjectWithMembers({ - bootstrapProjectId: createProjectBootstrapIdRef.current, - createProject: async () => { - const created = await createProject({ - name: data.title, - device_type: data.deviceType || undefined, - intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description, - product_domain: data.productDomain, - project_type_key: data.projectTypeKey, - team_mode: data.teamMode, - execution_mode: data.executionMode, - problem_statement: data.problemStatement, - target_users: data.targetUsers, - target_outcome: data.targetOutcome, - initial_workflow_key: - data.productDomain === 'non_medical_software' ? 'software_bootstrap' : undefined, - }); - // create 刚成功即写入 bootstrap:成员失败窗口内串行重试不会再 create - createProjectBootstrapIdRef.current = created.id; - return created; - }, - creatorUserId: currentUser.id, - initialMembers: data.initialMembers, - addMember: addProjectMember, - onPhase: setCreateProjectSubmitPhase, - }); - newProjId = ensured.projectId; - createProjectBootstrapIdRef.current = ensured.projectId; - } catch (err) { - const withId = err as Error & { projectId?: number }; - const bootstrapId = - typeof withId.projectId === 'number' && withId.projectId > 0 - ? withId.projectId - : createProjectBootstrapIdRef.current; - if (bootstrapId) { - createProjectBootstrapIdRef.current = bootstrapId; - } - const message = - err instanceof Error - ? err.message - : '创建项目或同步初始成员失败,请稍后重试(不会因本地状态假成功)'; - setCreateProjectError( - bootstrapId - ? `项目已创建(#${bootstrapId}),但初始成员未全部落库:${message}。请再次确认以幂等补齐,勿关闭后当作成功。` - : message, - ); - return; - } - - const nowStr = new Date().toLocaleString(); - - // 1. 构建项目与成员元数据(服务端成员已持久化) - const members: ProjectMember[] = data.initialMembers.map((m) => ({ - userId: m.userId, - userName: m.userName, - department: m.department, - position: m.position, - role: m.role, - status: 'active', - joinedAt: nowStr, - })); - - const newProject: ProjectInfo = { - id: newProjId, - title: data.title, - deviceType: data.deviceType, - description: data.description, - creatorId: currentUser.id, - creatorName: currentUser.name, - createdAt: nowStr, - members, - }; - - // 2. 为每个选中的项目成员生成独立私聊会话 - const newSessionsToAppend: ProjectSession[] = []; - const newMessagesToAppend: ChatMessage[] = []; - let creatorSessionId = ''; - - members.forEach((m) => { - const sessId = buildPrivateSessionId(`p${newProjId}`, m.userId); - if (m.userId === currentUser.id) { - creatorSessionId = sessId; - } - newSessionsToAppend.push({ - sessionId: sessId, - userId: m.userId, - projectId: newProjId, - title: data.title, - deviceType: data.deviceType, - stage: 'V0.1', - stageLabel: 'V0.1 需求摸底', - messageCount: 1, - updatedAt: '刚刚', - }); - - newMessagesToAppend.push({ - id: `m-init-${m.userId}-${Date.now()}`, - sessionId: sessId, - role: 'system', - content: - m.userId === currentUser.id - ? `【立项成功】项目《${data.title}》已成功建立!全套研发工作流与阶段文档模板已克隆就绪,协同参谋小队已就位,进入【V0.1 需求摸底】阶段。您可以点击附件图标上传临床需求 URS,或直接输入您的产品构想。` - : `【项目协同通知】您已被项目创建人 ${currentUser.name} 邀请加入《${data.title}》,并分配法定职责为【${m.role}】。`, - timestamp: new Date().toTimeString().substring(0, 5), - }); - }); - - // 3. 仅医疗项目克隆 7 份受控模板;软件项目走服务端 catalog / docs_only - const clonedDocs = shouldCloneMedicalTemplates(data.productDomain) - ? createProjectTemplateDeliverables({ - projectId: newProjId, - projectName: data.title, - deviceType: data.deviceType, - creatorId: currentUser.id, - creatorName: currentUser.name, - }) - : { deliverables: [], docASTMap: {} }; - const clonedAsts = clonedDocs.docASTMap; - - setProjects((prev) => [newProject, ...prev]); - setSessions((prev) => [...newSessionsToAppend, ...prev]); - setMessages((prev) => [...prev, ...newMessagesToAppend]); - - void Promise.all( - newSessionsToAppend.map((sessionItem) => - upsertChatSession({ - session_id: sessionItem.sessionId, - title: sessionItem.title, - user_id: sessionItem.userId ?? currentUser.id, - project_id: sessionItem.projectId, - }) - ) - ).catch((err) => { - console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err); - }); - setDeliverables((prev) => [...prev, ...clonedDocs.deliverables]); - setDocCache((prev) => ({ ...prev, ...clonedAsts })); - - if (creatorSessionId) { - setActiveSessionId(creatorSessionId); - } - if (clonedDocs.deliverables[0]?.docId) { - setActiveDocId(clonedDocs.deliverables[0].docId); - setActiveCategory('workflow'); - } - createProjectBootstrapIdRef.current = null; - setCreateProjectModalOpen(false); - setCreateProjectError(null); - } finally { - endCreateProjectSubmit(createProjectInFlightRef); - setCreateProjectSubmitting(false); - setCreateProjectSubmitPhase(null); - } - }; - - // 添加成员(持久化至服务端 project_members) - const handleAddProjectMember = async (newMember: Omit) => { - if (!currentProject) return; - try { - await addProjectMember( - currentProject.id, - buildManagedMemberUpsertPayload( - { userId: newMember.userId, role: newMember.role }, - currentProject.creatorId, - ), - ); - await afterProjectMemberPersisted({ - syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), - refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), - }); - - const newSessId = buildPrivateSessionId(`p${currentProject.id}`, newMember.userId); - setSessions((prev) => { - const exists = prev.some( - (s) => s.projectId === currentProject.id && s.userId === newMember.userId - ); - if (exists) return prev; - return [ - { - sessionId: newSessId, - userId: newMember.userId, - projectId: currentProject.id, - title: currentProject.title, - deviceType: currentProject.deviceType, - stage: 'V0.1', - stageLabel: 'V0.1 需求摸底', - messageCount: 1, - updatedAt: '刚刚', - }, - ...prev, - ]; - }); - - setMessages((prev) => [ - ...prev, - { - id: `m-join-${newMember.userId}-${Date.now()}`, - sessionId: newSessId, - role: 'system', - content: `【项目协同通知】您已被项目创建人 ${currentProject.creatorName} 加入项目《${currentProject.title}》,并分配项目角色为【${newMember.role}】。`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - } catch (err) { - setProjectMembersError(err instanceof Error ? err.message : '添加成员失败'); - throw err; - } - }; - - const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => { - if (!currentProject) return; - try { - await updateProjectMember( - currentProject.id, - buildManagedMemberUpsertPayload({ userId, role: newRole }, currentProject.creatorId), - ); - await afterProjectMemberPersisted({ - syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), - refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), - }); - } catch (err) { - setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败'); - } - }; - - const handleRemoveProjectMember = async (userId: number) => { - if (!currentProject) return; - try { - await removeProjectMember(currentProject.id, userId); - await afterProjectMemberPersisted({ - syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), - refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), - }); - } catch (err) { - setProjectMembersError(err instanceof Error ? err.message : '移出成员失败'); - } - }; - - const handleRestoreProjectMember = async (userId: number) => { - if (!currentProject) return; - try { - const member = currentProject.members.find((m) => m.userId === userId); - await updateProjectMember( - currentProject.id, - buildManagedMemberUpsertPayload( - { userId, role: member?.role ?? 'member', status: 'active' }, - currentProject.creatorId, - ), - ); - await afterProjectMemberPersisted({ - syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), - refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), - }); - } catch (err) { - setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败'); - } - }; + } return ( - <> - { - if (window.location.hash) { - history.replaceState(null, '', window.location.pathname + window.location.search); - } - setActiveSessionId(sessId); - const targetSess = sessions.find((s) => s.sessionId === sessId); - if (targetSess && targetSess.projectId !== currentProjectId) { - const targetProjDocs = deliverables.filter((d) => d.projectId === targetSess.projectId); - if (targetProjDocs.length > 0) { - setActiveDocId(targetProjDocs[0].docId); - setActiveCategory(targetProjDocs[0].category); - } - } - }} - onNewSession={() => setCreateProjectModalOpen(true)} - canCreateProject={canCreateProject} - currentUser={currentUser} - currentRole={currentDocRole} - currentUserName={currentUser.name} - onChangeRole={(_, __) => {}} - onOpenRightsModal={() => setRightsModalOpen(true)} - onOpenUserSettings={() => setUserSettingsOpen(true)} - onOpenAdminSettings={() => setAdminSettingsOpen(true)} - onOpenProjectMembers={() => setProjectMembersModalOpen(true)} - activeWorkflowKey={currentWfState.activeKey} - workflowVersion={currentWfState.version} - lifecycleStatus={currentWfState.lifecycleStatus} - isCreator={currentWfState.creatorId === currentUser.id} - governanceEnabled={workflowGovernanceEnabled} - onOpenProjectSettings={() => setProjectSettingsOpen(true)} - onWorkflowChanged={(newKey, newVer) => { - setProjectWorkflowsState((prev) => ({ - ...prev, - [currentProjectId]: { - ...currentWfState, - activeKey: newKey, - version: newVer, - lifecycleStatus: 'active', - }, - })); - }} - workflowBanner={workflowBanner} - chatContent={ -
- {/* P1, P2, P3: 顶部分类统计过滤栏、历史日历、三模搜索、智能摘要、导出与未读角标 */} - { - if (lastMessageId && activeSessionId) { - const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages); - navigateToAnchor( - { sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 }, - { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } - ); - } - }} - onToggleCalendar={() => setIsCalendarOpen((prev) => !prev)} - isCalendarOpen={isCalendarOpen} - onOpenSearch={() => setIsSearchOpen(true)} - onOpenDigest={() => setIsDigestOpen(true)} - onOpenExport={() => setIsExportOpen(true)} - unreadTotal={unreadTotal} - /> - {isCalendarOpen && ( - { - chatWindow.jumpToDate(date); - setIsCalendarOpen(false); - }} - onClose={() => setIsCalendarOpen(false)} - /> - )} - {isSearchOpen && ( - { - navigateToAnchor( - { sessionId: activeSessionId, messageId: hit.message_id }, - { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } - ); - setIsSearchOpen(false); - }} - onClose={() => setIsSearchOpen(false)} - /> - )} - {isDigestOpen && ( - { - navigateToAnchor( - { sessionId: activeSessionId, messageId }, - { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } - ); - setIsDigestOpen(false); - }} - onClose={() => setIsDigestOpen(false)} - /> - )} - {isExportOpen && ( - setIsExportOpen(false)} - sessionId={activeSessionId} - dayStack={chatWindow.displayDayStack} - /> - )} -
- { - navigateToAnchor( - { sessionId: activeSessionId, messageId: targetAnchor }, - { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } - ); - }} - selectedCategory={chatWindow.selectedCategory} - unreadMessageIds={unreadMessageIds} - onSaveToPrivate={handleSaveMessageToPrivate} - onExportDocx={handleExportReplyDocx} - onOpenDoc={handleSelectDoc} - /> - { - const targetId = await consumeNextUnread(); - if (targetId && activeSessionId) { - navigateToAnchor( - { sessionId: activeSessionId, messageId: targetId, highlightMs: 2000 }, - { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } - ); - } - }} - onNavigateToCategory={(_cat, lastMessageId) => { - if (lastMessageId && activeSessionId) { - const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages); - navigateToAnchor( - { sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 }, - { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } - ); - } - }} - onSelectCategory={chatWindow.setSelectedCategory} - /> -
- {pendingProposal && ( -
- setPendingProposal(null)} - /> -
- )} - -
- } - docContent={ - isCurrentProjectExited ? ( -
-
- -
-

- 受控文档已执行合规脱密隔离 -

-

- 您已退出该研发项目。依据医疗器械质量管理体系 (GMP/ISO 13485) 及商业保密规程,本项目技术规格书、设计输入草案与验证交付物已对您执行访问切断。历史聊天记录仅供审计留痕查阅。 -

-
- ) : ( -
d.docId === activeDocId)?.title || - visibleDeliverables.find((d) => d.docId === activeDocId)?.title || - '' - } - > - {/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */} - setDeckDrawerOpen(!deckDrawerOpen)} - onExportDocx={handleExportDocx} - onExportXlsx={handleExportXlsx} - onExportMarkdown={handleExportMarkdown} - /> - {(() => { - const revDoc = - deliverables.find((d) => d.docId === activeDocId) || - visibleDeliverables.find((d) => d.docId === activeDocId); - const mode = revisionToolbarMountMode( - revDoc ?? { docId: activeDocId, isTemplateOnly: true }, - ); - if (mode === 'toolbar' && revDoc) { - return ( - - ); - } - const hint = documentRevisionInactiveHint( - revDoc ?? { docId: activeDocId, isTemplateOnly: true }, - ); - if (!hint) return null; - return ( -
- {hint} -
- ); - })()} - {docRevConflict && ( -
- {docRevConflict} - -
- )} - {docRevOverlayMode === 'manual' && docRevOverlay && docRevOverlay.hunks?.length > 0 && ( -
- 人工差异 {docRevOverlay.summary.modifies + docRevOverlay.summary.inserts + docRevOverlay.summary.deletes} 处 -
- )} - - {/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */} - setDeckDrawerOpen(false)} - deliverables={visibleDeliverables} - activeDocId={activeDocId} - currentUserId={currentUser.id} - onSelectDoc={handleSelectDoc} - onShareToProject={handleShareToProject} - onRevokeShare={handleRevokeShare} - onDeleteDoc={handleDeleteDoc} - onOpenDocVisibility={(doc) => { - setTargetDocForVisibility(doc); - setDocVisibilityModalOpen(true); - }} - /> - - {/* 核心文档内容渲染区:用户上传文档使用高保真文件预览器,工作流及对话入库文档使用结构化 BlockRenderer */} - {(() => { - const activeDeliverable = deliverables.find((d) => d.docId === activeDocId); - const isUploadedFile = Boolean( - activeDeliverable && - (activeDeliverable.sourceType === 'uploaded_file' || - Boolean(activeDeliverable.fileMeta) || - activeDeliverable.docId.startsWith('doc-upload-') || - Boolean(activeDeliverable.title?.match(/\.(docx?|pdf|xlsx?|csv|pptx?|png|jpe?g|svg|webp|txt|md|json)$/i)) || - (activeDeliverable.taskKey === 'user_requirements' && - !activeDeliverable.isFixedWorkflow && - (activeDeliverable.category === 'private' || activeDeliverable.category === 'shared'))) - ); - if (isUploadedFile && activeDeliverable) { - return ( - { - setTargetDocForVisibility(activeDeliverable); - setDocVisibilityModalOpen(true); - }} + } + docContent={ + - ); - } - return ( - <> - {docLoadErrors[activeDocId] ? ( -
- - {docLoadErrorInfo[activeDocId] - ? [ - docLoadErrorInfo[activeDocId].code, - docLoadErrorInfo[activeDocId].message, - docLoadErrorInfo[activeDocId].correlationId - ? `corr=${docLoadErrorInfo[activeDocId].correlationId}` - : '', - ] - .filter(Boolean) - .join(' ') - : '受控文档正文加载失败,请检查网络后重试。'} - - -
- ) : null} - { - handleSendMessage( - `针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。` - ); - }} - highlightedBlockId={highlightedBlockId} - /> - - ); - })()} - - {/* 底部流转动作条 (受控流程文档使用 DocGovActionBar,自由私密/共享文档使用 FreeformDocumentActionBar) */} - {docAST.governanceMode === 'freeform' || docAST.space === 'my_private' || docAST.space === 'project_shared' || docAST.category === 'private' || docAST.category === 'shared' ? ( - copyRichReplyToClipboard(blockASTToMarkdown(docAST))} - onCopyMarkdown={() => copyMarkdownReply(blockASTToMarkdown(docAST))} - onDownloadMarkdown={() => downloadMarkdownReply(blockASTToMarkdown(docAST), docAST.title)} - onDownloadDocx={handleExportDocx} - /> - ) : ( - { - const currentDoc = deliverables.find((d) => d.docId === activeDocId) || null; - setTargetDocForVisibility(currentDoc); - setDocVisibilityModalOpen(true); - }} - onClaim={handleClaim} - onSubmitReview={handleSubmitReview} - onPassReview={handlePassReview} - onRejectReview={handleRejectReview} - onApprove={handleApprove} - onRejectApprove={handleRejectReview} - onShareToProject={handleShareToProject} - onRevokeShare={handleRevokeShare} - /> - )} - - {/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */} - {editorSession && ( - - )} - - {/* 审核批注侧栏 */} - {commentingBlock && ( - setCommentingBlock(null)} - /> - )} -
- ) - } - /> - - {/* 用户在当前激活项目中的法定职责与权限矩阵弹窗 */} - setRightsModalOpen(false)} - currentUser={currentUser} - currentProjectTitle={sessions.find((s) => s.sessionId === activeSessionId)?.title || '当前项目'} - deliverables={visibleDeliverables} - activeDocId={activeDocId} - canAssignLead={canAssignLead} - onSelectDoc={handleSelectDoc} - onRoleChangeForDoc={handleRoleChangeForDoc} - /> - - {/* 首次登录强制改密阻断弹窗 */} - { - const updated = { ...session, mustChangePassword: false }; - setSession(updated); - authClient.saveSession(updated); - }} - onLogout={handleLogout} - /> - - {/* 用户个人设置与偏好中心 (全员可用) */} - setUserSettingsOpen(false)} - currentUser={currentUser} - roles={roles} - onUpdateProfile={(updatedProfile) => { - setAllUsers((prev) => - prev.map((u) => (u.id === currentUser.id ? { ...u, ...updatedProfile } : u)) - ); - setCurrentUser((prev) => ({ ...prev, ...updatedProfile })); - }} - onLogout={handleLogout} - /> - - {/* 系统管理员独占治理控制台 (仅 role-admin 可入) */} - setAdminSettingsOpen(false)} - currentUser={currentUser} - roles={roles} - onSaveRoles={(newRoles) => setRoles(newRoles)} - allUsers={allUsers} - onUpdateUserRole={(userId, roleId) => { - const targetRole = roles.find((r) => r.id === roleId); - setAllUsers((prev) => - prev.map((u) => (u.id === userId ? { ...u, roleId, position: targetRole?.name || u.position } : u)) - ); - if (currentUser.id === userId) { - setCurrentUser((prev) => ({ ...prev, roleId, position: targetRole?.name || prev.position })); - } - }} - systemConfig={systemAuthConfig} - onUpdateSystemConfig={(cfg) => setSystemAuthConfig((prev) => ({ ...prev, ...cfg }))} - onRefreshUsers={async () => { - const users = await authClient.getAdminUsers(); - setAllUsers(users); - }} - env={systemEnv} - /> - - {/* 单文档可见性覆盖弹窗 */} - setDocVisibilityModalOpen(false)} - doc={targetDocForVisibility} - roles={roles} - currentUserName={currentUser.name} - onSaveRule={handleSaveDocVisibilityRule} - /> - - {/* 发起医疗器械新项目立项弹窗 */} - { - if (createProjectSubmitting) return; - setCreateProjectModalOpen(false); - setCreateProjectError(null); - }} - onSubmit={handleCreateProjectConfirm} - submitError={createProjectError} - submitting={createProjectSubmitting} - submitPhase={createProjectSubmitPhase} - currentUser={currentUser} - availableUsers={allUsers} - /> - - {/* 项目成员与职责分配管理弹窗 */} - setProjectMembersModalOpen(false)} - project={currentProject} - currentUser={currentUser} - availableUsers={allUsers} - canManage={canManageProjectMembers} - membersError={projectMembersError} - onAddMember={handleAddProjectMember} - onUpdateMemberRole={handleUpdateProjectMemberRole} - onRemoveMember={handleRemoveProjectMember} - onRestoreMember={handleRestoreProjectMember} - /> - - m.status === 'active') - .map((m) => ({ - userId: m.userId, - name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`, - }))} - onClose={() => setProjectSettingsOpen(false)} - onActivated={(activeKey) => { - setProjectWorkflowsState((prev) => ({ - ...prev, - [currentProjectId]: { - ...currentWfState, - activeKey, - lifecycleStatus: 'active', - }, - })); - }} - /> - + } + rightsModalOpen={rightsModalOpen} + visibleDeliverables={visibleDeliverables} + activeDocId={activeDocId} + canAssignLead={canAssignLead} + handleSelectDoc={handleSelectDoc} + handleRoleChangeForDoc={handleRoleChangeForDoc} + session={session} + setSession={setSession} + handleLogout={handleLogout} + userSettingsOpen={userSettingsOpen} + roles={roles} + setAllUsers={setAllUsers} + setCurrentUser={setCurrentUser} + docVisibilityModalOpen={docVisibilityModalOpen} + setDocVisibilityModalOpen={setDocVisibilityModalOpen} + targetDocForVisibility={targetDocForVisibility} + handleSaveDocVisibilityRule={handleSaveDocVisibilityRule} + createProjectModalOpen={createProjectModalOpen} + createProjectSubmitting={createProjectSubmitting} + setCreateProjectError={setCreateProjectError} + handleCreateProjectConfirm={handleCreateProjectConfirm} + createProjectError={createProjectError} + createProjectSubmitPhase={createProjectSubmitPhase} + allUsers={allUsers} + projectMembersModalOpen={projectMembersModalOpen} + currentProject={currentProject} + canManageProjectMembers={canManageProjectMembers} + projectMembersError={projectMembersError} + handleAddProjectMember={handleAddProjectMember} + handleUpdateProjectMemberRole={handleUpdateProjectMemberRole} + handleRemoveProjectMember={handleRemoveProjectMember} + handleRestoreProjectMember={handleRestoreProjectMember} + projectSettingsOpen={projectSettingsOpen} + currentActiveSession={currentActiveSession} + /> ); + } 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..70a19c40 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; @@ -23,7 +24,8 @@ const API_BASE = ''; export async function fetchChatSessions( userId: number, projectId?: number, - limit = 100 + limit = 100, + signal?: AbortSignal ): Promise { const params = new URLSearchParams({ user_id: String(userId), @@ -34,6 +36,7 @@ export async function fetchChatSessions( } const res = await fetch(`${API_BASE}/api/v1/chat/sessions?${params.toString()}`, { headers: getBearerAuthHeaders(), + signal, }); if (!res.ok) { throw new Error(`获取会话列表失败: ${res.status}`); @@ -50,7 +53,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/systemAdmin.ts b/web/src/api/systemAdmin.ts new file mode 100644 index 00000000..c8591ccc --- /dev/null +++ b/web/src/api/systemAdmin.ts @@ -0,0 +1,60 @@ +import { authorizedFetch } from './authHeaders'; +import type { + SystemConfigValue, + SystemFeature, + SystemKbInfo, + SystemOverview, + SystemSetting, +} from '../types/systemAdmin'; + +const BASE = '/api/v1/admin/system'; + +async function readError(resp: Response, fallback: string): Promise { + let err: Error; + try { + const text = await resp.text(); + err = text ? new Error(text.slice(0, 200)) : new Error(`${fallback} (HTTP ${resp.status})`); + } catch { + err = new Error(`${fallback} (HTTP ${resp.status})`); + } + (err as Error & { status?: number }).status = resp.status; + return err; +} + +async function getJson(path: string, fallback: string): Promise { + const resp = await authorizedFetch(path); + if (!resp.ok) throw await readError(resp, fallback); + return resp.json() as Promise; +} + +export async function fetchSystemOverview(): Promise { + return getJson(`${BASE}/overview`, '获取系统总览失败'); +} + +export async function fetchSystemKb(): Promise { + return getJson(`${BASE}/kb`, '获取知识库连接信息失败'); +} + +export async function fetchSystemFeatures(): Promise { + const data = await getJson<{ features?: SystemFeature[] }>(`${BASE}/features`, '获取特性开关失败'); + return data.features ?? []; +} + +export async function fetchSystemSettings(): Promise { + const data = await getJson<{ settings?: SystemSetting[] }>(`${BASE}/settings`, '获取系统设置失败'); + return data.settings ?? []; +} + +export async function updateSystemSetting( + key: string, + value: SystemConfigValue, +): Promise { + const resp = await authorizedFetch(`${BASE}/settings/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value }), + }); + if (!resp.ok) throw await readError(resp, '保存设置失败'); + const data = (await resp.json()) as { setting?: SystemSetting } & SystemSetting; + return data.setting ?? data; +} 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/admin/AdminSettingsModal.tsx b/web/src/components/admin/AdminSettingsModal.tsx deleted file mode 100644 index 5699d793..00000000 --- a/web/src/components/admin/AdminSettingsModal.tsx +++ /dev/null @@ -1,1677 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - ShieldAlert, - Users, - UserCheck, - Plus, - Trash2, - Check, - X, - AlertCircle, - CheckCircle2, - Sliders, - Activity, - Shield, - Search, - UserPlus, - GitBranch, -} from 'lucide-react'; -import { UserProfile } from '../../types/docgov'; -import { RoleDefinition, PermissionKey } from '../../types/rbac'; -import { SystemAuthConfig, UserAccount } from '../../types/auth'; -import { authClient } from '../../api/auth'; -import { - listAdminWorkflows, - getAdminWorkflow, - validateWorkflowVersion, - publishWorkflowVersion, - listAdminTemplates, - WorkflowDefinitionSummary, - WorkflowVersionSummary, -} from '../../api/workflowAdmin'; -import { syncAdminUserDirectories } from '../../lib/adminUserDirectorySync'; - -export interface AdminSettingsModalProps { - isOpen: boolean; - onClose: () => void; - currentUser: UserProfile; - roles: RoleDefinition[]; - onSaveRoles: (roles: RoleDefinition[]) => void; - allUsers: UserProfile[]; - onUpdateUserRole: (userId: number, roleId: string) => void; - onRefreshUsers: () => void | Promise; - systemConfig: SystemAuthConfig; - onUpdateSystemConfig: (config: Partial) => void; - env?: string; -} - -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 AdminSettingsModal: React.FC = ({ - isOpen, - onClose, - currentUser, - roles, - onSaveRoles, - allUsers: _allUsers, - onUpdateUserRole, - onRefreshUsers, - systemConfig, - onUpdateSystemConfig, - env = 'dev', -}) => { - const [activeTab, setActiveTab] = useState< - 'users' | 'approvals' | 'policy' | 'roles' | 'workflows' | 'status' - >('users'); - - // 全量账号列表(含待审核及禁用状态) - const [adminUsers, setAdminUsers] = useState([]); - const [userSearchText, setUserSearchText] = useState(''); - const [actionTip, setActionTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); - - // 角色库与编辑态 - const [editRoles, setEditRoles] = useState(roles); - const [selectedRoleId, setSelectedRoleId] = useState(roles[0]?.id || 'role-admin'); - const [isCreatingRole, setIsCreatingRole] = useState(false); - const [newRoleName, setNewRoleName] = useState(''); - const [newRoleCode, setNewRoleCode] = useState(''); - const [newRoleDesc, setNewRoleDesc] = useState(''); - - // 管理员新建用户表单 - const [showCreateUserModal, setShowCreateUserModal] = useState(false); - const [newUname, setNewUname] = useState(''); - const [newRealName, setNewRealName] = useState(''); - const [newInitPass, setNewInitPass] = useState('InitPass123'); - const [newRoleId, setNewRoleId] = useState('role-engineer'); - const [newPosition, setNewPosition] = useState('方案工程师'); - const [newDept, setNewDept] = useState('研发中心'); - - // 准入策略本地状态 - const [allowReg, setAllowReg] = useState(systemConfig.allowRegistration); - const [requireAppr, setRequireAppr] = useState(systemConfig.requireApproval); - - const [adminWorkflows, setAdminWorkflows] = useState([]); - const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); - const [workflowVersions, setWorkflowVersions] = useState([]); - const [adminTemplates, setAdminTemplates] = useState>([]); - const [validationMsg, setValidationMsg] = useState(''); - - // 刷新全量用户列表,并同步父级 App allUsers(立项成员候选依赖此目录) - const refreshUsers = async () => { - try { - await syncAdminUserDirectories({ - reloadLocalUsers: async () => { - const list = await authClient.listAllUsers(); - setAdminUsers(list); - }, - onRefreshUsers, - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : '刷新/同步用户目录失败'; - setActionTip({ type: 'error', msg }); - throw err; - } - }; - - const refreshWorkflows = async () => { - try { - const list = await listAdminWorkflows(); - setAdminWorkflows(list); - const templates = await listAdminTemplates(); - setAdminTemplates(templates.map((t) => ({ asset_key: t.asset_key, name: t.name }))); - } catch { - setAdminWorkflows([]); - } - }; - - const loadWorkflowDetail = async (id: number) => { - setSelectedWorkflowId(id); - try { - const detail = await getAdminWorkflow(id); - setWorkflowVersions(detail.versions ?? []); - } catch { - setWorkflowVersions([]); - } - }; - - const handleValidateVersion = async (workflowId: number, version: number) => { - try { - const res = await validateWorkflowVersion(workflowId, version); - if (res.valid) { - setValidationMsg(`v${version} 校验通过`); - } else { - setValidationMsg(res.issues.map((i) => i.message).join(';')); - } - } catch (err: any) { - setValidationMsg(err.message || '校验失败'); - } - }; - - const handlePublishVersion = async (workflowId: number, version: number) => { - if (!window.confirm(`确认发布工作流版本 v${version}?发布后不可原地修改。`)) return; - try { - await publishWorkflowVersion(workflowId, version); - showNotification('success', `已发布 v${version}`); - refreshWorkflows(); - loadWorkflowDetail(workflowId); - } catch (err: any) { - showNotification('error', err.message || '发布失败'); - } - }; - - const refreshRoles = async () => { - try { - const list = await authClient.listRoles(); - setEditRoles(list); - } catch { - setEditRoles(roles); - } - }; - - useEffect(() => { - if (isOpen) { - void refreshUsers().catch(() => { - // 错误已写入 actionTip;打开时失败不阻断模态 - }); - refreshRoles(); - refreshWorkflows(); - setAllowReg(systemConfig.allowRegistration); - setRequireAppr(systemConfig.requireApproval); - } - }, [isOpen, roles, systemConfig]); - - if (!isOpen) return null; - - const showNotification = (type: 'success' | 'error', msg: string) => { - setActionTip({ type, msg }); - setTimeout(() => setActionTip(null), 3000); - }; - - // 1. 人员职位与角色分配 - const handleUserRoleChange = async (userId: number, roleId: string) => { - const role = editRoles.find((r) => r.id === roleId); - try { - await authClient.updateUserRole(userId, roleId, role?.name || '方案工程师'); - onUpdateUserRole(userId, roleId); - } catch (err: any) { - showNotification('error', err.message || '更新角色失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', '已成功变更该成员的所属职位与系统角色'); - } catch { - showNotification('error', '角色已更新,但全局用户目录同步失败,请刷新后重试'); - } - }; - - // 2. 账号状态(激活/停用) - const handleToggleUserStatus = async (user: UserAccount) => { - const nextStatus = user.status === 'active' ? 'disabled' : 'active'; - try { - await authClient.updateUserStatus(user.id, nextStatus); - } catch (err: any) { - showNotification('error', err.message || '切换状态失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', `已将账号【${user.name}】状态切换为:${nextStatus === 'active' ? '启用' : '停用'}`); - } catch { - showNotification('error', '账号状态已更新,但全局用户目录同步失败,请刷新后重试'); - } - }; - - // 3. 审批通过或驳回 - const handleApproveRegistration = async (user: UserAccount, targetRoleId: string) => { - const role = editRoles.find((r) => r.id === targetRoleId); - try { - await authClient.updateUserStatus(user.id, 'active', targetRoleId, role?.name || '方案工程师'); - } catch (err: any) { - showNotification('error', err.message || '审批操作失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', `已批准【${user.name}】注册申请,已正式授予【${role?.name}】职位`); - } catch { - showNotification('error', '审批已生效,但全局用户目录同步失败,请刷新后重试'); - } - }; - - const handleRejectRegistration = async (user: UserAccount) => { - if (!window.confirm(`确定要驳回【${user.name}】的注册申请吗?该账号将被置为禁用。`)) return; - try { - await authClient.updateUserStatus(user.id, 'disabled'); - } catch (err: any) { - showNotification('error', err.message || '驳回操作失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', `已驳回【${user.name}】的注册申请`); - } catch { - showNotification('error', '驳回已生效,但全局用户目录同步失败,请刷新后重试'); - } - }; - - // 4. 重置用户密码为初始并强制改密 - const handleResetUserPassword = async (user: UserAccount) => { - const tempPass = 'ResetPass123'; - if (!window.confirm(`确定要重置【${user.name}】的密码吗?重置后初始密码为 ${tempPass},该用户下次登录将被强制改密。`)) return; - try { - await authClient.adminResetPassword(user.id, tempPass); - } catch (err: any) { - showNotification('error', err.message || '重置密码失败'); - return; - } - try { - await refreshUsers(); - showNotification('success', `已重置【${user.name}】密码为 ${tempPass},已标记首次登录强制改密`); - } catch { - showNotification('error', '密码已重置,但全局用户目录同步失败,请刷新后重试'); - } - }; - - // 5. 管理员新建账号 - const handleCreateUserSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newUname.trim() || !newRealName.trim() || !newInitPass) { - showNotification('error', '请填写完整账号、姓名与初始密码'); - return; - } - try { - await authClient.createAdminUser({ - username: newUname.trim(), - name: newRealName.trim(), - initialPassword: newInitPass, - roleId: newRoleId, - position: newPosition, - department: newDept, - }); - setShowCreateUserModal(false); - setNewUname(''); - setNewRealName(''); - try { - await refreshUsers(); - showNotification('success', `已成功开通受控账号【${newRealName}】,已自动标记首次登录强制改密`); - } catch { - showNotification( - 'error', - `账号【${newRealName}】已开通,但全局用户目录同步失败,立项成员候选可能暂不可见该账号`, - ); - } - } catch (err: any) { - showNotification('error', err.message || '新建员工账号失败'); - } - }; - - // 6. 保存准入策略 - const handleSavePolicy = async () => { - try { - await authClient.updateSystemAuthConfig({ - allowRegistration: allowReg, - requireApproval: requireAppr, - }); - onUpdateSystemConfig({ - allowRegistration: allowReg, - requireApproval: requireAppr, - }); - showNotification('success', '系统准入策略已即刻全局生效'); - } catch (err: any) { - showNotification('error', err.message || '更新策略失败'); - } - }; - - // 7. 角色权限矩阵操作 - const currentSelectedRole = editRoles.find((r) => r.id === selectedRoleId) || editRoles[0]; - - const handleTogglePermission = async (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]; - try { - const updated = await authClient.updateRole(currentSelectedRole.id, { - name: currentSelectedRole.name, - description: currentSelectedRole.description, - permissions: updatedPermissions, - }); - const updatedRoles = editRoles.map((r) => (r.id === updated.id ? updated : r)); - setEditRoles(updatedRoles); - onSaveRoles(updatedRoles); - showNotification('success', `已更新【${currentSelectedRole.name}】权限矩阵`); - } catch (err: any) { - showNotification('error', err.message || '更新权限失败'); - } - }; - - const handleCreateRole = async () => { - if (!newRoleName.trim() || !newRoleCode.trim()) return; - try { - const newRole = await authClient.createRole({ - code: newRoleCode.trim().toUpperCase(), - name: newRoleName.trim(), - description: newRoleDesc.trim() || '自定义受控职位角色', - permissions: ['workflow:view_all', 'doc:export'], - }); - const nextRoles = [...editRoles, newRole]; - setEditRoles(nextRoles); - onSaveRoles(nextRoles); - setSelectedRoleId(newRole.id); - setIsCreatingRole(false); - setNewRoleName(''); - setNewRoleCode(''); - setNewRoleDesc(''); - showNotification('success', `已创建新业务角色【${newRole.name}】`); - } catch (err: any) { - showNotification('error', err.message || '创建角色失败'); - } - }; - - const handleDeleteRole = async (roleId: string) => { - const roleToDelete = editRoles.find((r) => r.id === roleId); - if (!roleToDelete || roleToDelete.isSystem) return; - if (!window.confirm(`确定要删除角色【${roleToDelete.name}】吗?删除后该角色人员将自动退回普通工程师。`)) { - return; - } - try { - await authClient.deleteRole(roleId); - const nextRoles = editRoles.filter((r) => r.id !== roleId); - setEditRoles(nextRoles); - onSaveRoles(nextRoles); - setSelectedRoleId(nextRoles[0]?.id || 'role-admin'); - showNotification('success', `已删除角色【${roleToDelete.name}】`); - } catch (err: any) { - showNotification('error', err.message || '删除角色失败'); - } - }; - - // 待审批列表 - const pendingUsers = adminUsers.filter((u) => u.status === 'pending_approval'); - // 过滤后的用户列表 - const filteredUsers = adminUsers.filter( - (u) => - u.name.toLowerCase().includes(userSearchText.toLowerCase()) || - (u.username && u.username.toLowerCase().includes(userSearchText.toLowerCase())) || - (u.department && u.department.toLowerCase().includes(userSearchText.toLowerCase())) - ); - - return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > -
- {/* 顶部标题栏 */} -
-
-
- -
-
-
- - 系统管理控制台 - - - ADMIN 独占 - - - 运行环境: {env.toUpperCase()} - -
-
- 人员职位治理 · 注册审批中心 · 系统准入策略 · RBAC 角色权限矩阵 -
-
-
- -
- {actionTip && ( - - {actionTip.type === 'success' ? : } - {actionTip.msg} - - )} - - -
-
- - {/* 主体左右分栏 */} -
- {/* 左侧垂直导航 Tab */} -
-
- - - - - - - - - - - -
- - {/* 底部系统信息 */} -
-
水木东方医疗器械 AI 工作台
-
Auth & RBAC v2.4
-
-
- - {/* 右侧主视口 */} -
- {/* Tab 1: 人员与职位治理 */} - {activeTab === 'users' && ( -
-
-
- - setUserSearchText(e.target.value)} - placeholder="搜索姓名、用户名或部门..." - style={{ - width: '100%', - padding: '7px 10px 7px 32px', - borderRadius: '8px', - backgroundColor: 'var(--bg-app)', - border: '1px solid var(--border)', - color: 'var(--text-strong)', - fontSize: '12px', - boxSizing: 'border-box', - }} - /> -
- - -
- - {/* 用户列表表格 */} -
- - - - - - - - - - - - - {filteredUsers.map((u) => { - const isSelf = u.id === currentUser.id; - return ( - - - - - - - - - ); - })} - -
成员姓名登录账号 / 工号所属部门系统职位与角色账号状态操作治理
-
-
- {u.name.slice(0, 1)} -
- {u.name} - {isSelf && ( - - 当前账号 - - )} -
-
-
{u.username || '系统预设'}
-
{u.employeeId || 'EMP-未设定'}
-
- {u.department || '未分配部门'} - - - - - {u.status === 'active' ? '正常在职' : u.status === 'pending_approval' ? '待审核' : '已停用'} - - -
- - {!isSelf && ( - - )} -
-
-
-
- )} - - {/* Tab 2: 注册审批中心 */} - {activeTab === 'approvals' && ( -
-
-
- 新员工注册审批与职位指派中心 -
-
- 依据 NMPA 体系规范,自主注册申请需经系统管理员人工核准并正式授予业务职位。 -
-
- - {pendingUsers.length === 0 ? ( -
- -
- 当前没有待审批的注册申请 -
-
- 所有新注册员工均已完成合规审批并完成业务职位委派。 -
-
- ) : ( -
- {pendingUsers.map((u) => { - return ( -
-
-
- - {u.name} - - - 账号: {u.username} - - - 待审核 - -
-
- 部门: {u.department || '未填写'} - 工号: {u.employeeId || '未填写'} - 申请时间: {u.createdAt || '刚刚'} -
-
- -
- - - - - -
-
- ); - })} -
- )} -
- )} - - {/* Tab 3: 系统准入策略 */} - {activeTab === 'policy' && ( -
-
-
- 系统全局准入与注册审核策略 -
-
- 控制系统外部开放度,关闭自主注册或开启强制审核均实时阻断越权账号。 -
-
- - {/* 开关 1: 开放自主注册 */} -
-
-
- 允许新用户自主注册账号 -
-
- 开启后,登录页将呈现「用户自主注册」选项卡;关闭后,仅管理员可在控制台开通账号。 -
-
- setAllowReg(e.target.checked)} - style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }} - /> -
- - {/* 开关 2: 注册需审核 */} -
-
-
- 新注册账号必须由管理员审核通过 -
-
- 开启后,新注册账号处于「待审核」状态禁止登录,直至管理员在审批中心核准并正式分配职位。 -
-
- setRequireAppr(e.target.checked)} - style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }} - /> -
- -
- -
-
- )} - - {/* Tab 4: RBAC 角色权限矩阵 */} - {activeTab === 'roles' && ( -
- {/* 角色列表 */} -
-
-
- 全局受控角色库 ({editRoles.length}) -
- {editRoles.map((r) => { - const isSel = r.id === selectedRoleId; - return ( -
setSelectedRoleId(r.id)} - style={{ - padding: '8px 10px', - borderRadius: '6px', - backgroundColor: isSel ? 'var(--bg-panel)' : 'transparent', - border: isSel ? '1px solid var(--brand-500)' : '1px solid transparent', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - }} - > -
-
- {r.name} -
-
- {r.code} · {r.permissions.length} 项权限 -
-
- {!r.isSystem && ( - - )} -
- ); - })} -
- - {/* 新增自定义角色 */} -
- {isCreatingRole ? ( -
- setNewRoleName(e.target.value)} - placeholder="角色名称 (如 临床协调员)" - style={{ width: '100%', padding: '6px 8px', borderRadius: '4px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '11px', boxSizing: 'border-box' }} - /> - setNewRoleCode(e.target.value)} - placeholder="标识代号 (如 CRA)" - style={{ width: '100%', padding: '6px 8px', borderRadius: '4px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '11px', boxSizing: 'border-box' }} - /> -
- - -
-
- ) : ( - - )} -
-
- - {/* 对应角色的权限复选矩阵 */} -
-
-
- - 【{currentSelectedRole?.name}】权限配置矩阵 - - {currentSelectedRole?.isSystem && ( - - 系统内置受控角色 (只读) - - )} -
-
- {currentSelectedRole?.description} -
-
- -
- {ALL_PERMISSION_ITEMS.map((item) => { - const isChecked = - currentSelectedRole?.code === 'ADMIN' || - currentSelectedRole?.permissions.includes(item.key); - const isLocked = currentSelectedRole?.isSystem; - - return ( -
!isLocked && handleTogglePermission(item.key)} - style={{ - padding: '12px 14px', - borderRadius: '8px', - backgroundColor: isChecked ? 'var(--bg-subtle)' : 'var(--bg-app)', - border: isChecked ? '1px solid var(--border)' : '1px dashed var(--border)', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - cursor: isLocked ? 'not-allowed' : 'pointer', - }} - > -
-
- - {item.label} - - - {item.category} - -
-
- {item.desc} -
-
- - {}} - style={{ width: '16px', height: '16px', accentColor: 'var(--brand-500)', cursor: isLocked ? 'not-allowed' : 'pointer' }} - /> -
- ); - })} -
-
-
- )} - - {/* Tab 5: 系统状态与审计 */} - {activeTab === 'workflows' && ( -
-

工作流定义(DB 权威)

-

- 已发布版本不可变;新项目选用固定版本快照(项目激活冻结见 Phase 3)。 -

-
-
- {adminWorkflows.map((wf) => ( - - ))} -
-
- {selectedWorkflowId && ( - <> -
版本列表
- {workflowVersions.map((v) => ( -
-
- v{v.version} · {v.status} -
-
- hash: {v.definition_hash.slice(0, 16)}… -
-
- - {v.status === 'draft' && ( - - )} -
-
- ))} - {validationMsg && ( -
{validationMsg}
- )} - - )} -
-
-
-
模板库 ({adminTemplates.length})
-
- {adminTemplates.map((t) => t.asset_key).join(', ') || '暂无模板(需 PG 环境 bootstrap)'} -
-
-
- )} - - {activeTab === 'status' && ( -
-
-
- 系统安全与运行状态 -
-
- 环境拓扑隔离与安全凭据审计。 -
-
- -
-
-
当前运行环境态
-
- {env.toUpperCase()} -
-
- {env === 'dev' ? '开发态 (允许测试快速身份切换)' : '正式受控态 (强制真实登出/登录)'} -
-
- -
-
密码加密算法
-
- Bcrypt (加盐哈希) -
-
- 电子签名与审计事件由服务端记录并防篡改校验 -
-
- -
-
当前系统人员总数
-
- {adminUsers.length} 人 -
-
- 在职正常: {adminUsers.filter((u) => u.status === 'active').length} 人 · 待审: {pendingUsers.length} 人 -
-
- -
-
超管凭据配置通道
-
- .env 动态环境变量 -
-
- ADMIN_USERNAME 与 ADMIN_PASSWORD 受控注入 -
-
-
-
- )} -
-
-
- - {/* 管理员直接开通员工账号的内层弹窗 */} - {showCreateUserModal && ( -
{ - if (e.target === e.currentTarget) setShowCreateUserModal(false); - }} - > -
-
- 开通受控员工账号 -
-
- 由管理员开通的员工,首次登录系统将被强制修改初始密码。 -
- -
-
- - setNewUname(e.target.value)} - placeholder="如 li_si" - style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} - /> -
- -
- - setNewRealName(e.target.value)} - placeholder="如 李四" - style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} - /> -
- -
- - -
- -
-
- - setNewDept(e.target.value)} - style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} - /> -
-
- - setNewInitPass(e.target.value)} - style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} - /> -
-
- -
- - -
-
-
-
- )} -
- ); -}; diff --git a/web/src/components/admin/console/AdminConsoleView.tsx b/web/src/components/admin/console/AdminConsoleView.tsx new file mode 100644 index 00000000..c32363b2 --- /dev/null +++ b/web/src/components/admin/console/AdminConsoleView.tsx @@ -0,0 +1,333 @@ +import React, { useEffect, useState } from 'react'; +import { + Activity, + ArrowLeft, + Database, + FileText, + GitBranch, + Shield, + ShieldAlert, + ShieldX, + Sliders, + ToggleLeft, + Users, +} from 'lucide-react'; +import { fetchSystemOverview } from '../../../api/systemAdmin'; +import { UserProfile } from '../../../types/docgov'; +import { RoleDefinition } from '../../../types/rbac'; +import { DocsSection } from './sections/DocsSection'; +import { FeaturesSection } from './sections/FeaturesSection'; +import { KbSection } from './sections/KbSection'; +import { OverviewSection } from './sections/OverviewSection'; +import { RolesSection } from './sections/RolesSection'; +import { SettingsSection } from './sections/SettingsSection'; +import { UsersSection } from './sections/UsersSection'; +import { WorkflowsSection } from './sections/WorkflowsSection'; + +export interface AdminConsoleViewProps { + currentUser: UserProfile; + env?: string; + onExit: () => void; + /** 账号目录变更后同步父级 App allUsers(立项成员候选依赖,见 UI-01) */ + onUsersChanged?: () => void | Promise; + /** 角色库变更后同步父级 App roles(工作台 RBAC 计算依赖) */ + onRolesChanged?: (roles: RoleDefinition[]) => void; +} + +type SectionKey = + | 'overview' + | 'kb' + | 'features' + | 'settings' + | 'workflows' + | 'roles' + | 'users' + | 'docs'; + +const NAV_ITEMS: Array<{ key: SectionKey; label: string; icon: React.ReactNode }> = [ + { key: 'overview', label: '系统运行总览', icon: }, + { key: 'kb', label: '知识库连接', icon: }, + { key: 'features', label: '特性功能开关', icon: }, + { key: 'settings', label: '系统设置白名单', icon: }, + { key: 'workflows', label: '工作流定义', icon: }, + { key: 'roles', label: 'RBAC 角色权限', icon: }, + { key: 'users', label: '用户与准入治理', icon: }, + { key: 'docs', label: '文档与模板库', icon: }, +]; + +type AuthzGate = 'checking' | 'ready' | 'denied'; + +export const AdminConsoleView: React.FC = ({ + currentUser, + env = 'dev', + onExit, + onUsersChanged, + onRolesChanged, +}) => { + const [activeSection, setActiveSection] = useState('overview'); + const [gate, setGate] = useState('checking'); + + // 挂载时调用 overview 做二次鉴权:403 视为非管理员,提示并退回工作台; + // 其余错误(端点未就绪/网络)不阻断进入,由各 section 自行呈现错误态。 + useEffect(() => { + let cancelled = false; + fetchSystemOverview() + .then(() => { + if (!cancelled) setGate('ready'); + }) + .catch((err) => { + if (cancelled) return; + const status = (err as { status?: number })?.status; + setGate(status === 403 ? 'denied' : 'ready'); + }); + return () => { + cancelled = true; + }; + }, []); + + const renderSection = () => { + switch (activeSection) { + case 'overview': + return ; + case 'kb': + return ; + case 'features': + return ; + case 'settings': + return ; + case 'workflows': + return ; + case 'roles': + return ; + case 'users': + return ; + case 'docs': + return ; + } + }; + + return ( +
+ {/* 顶部标题栏 */} +
+
+ + +
+ +
+
+
+ + 系统管理 + + + ADMIN 独占 + + + 运行环境: {(env || 'dev').toUpperCase()} + +
+
+ 运行总览 · 特性开关 · 设置白名单 · 工作流 · 角色权限 · 用户准入 · 文档模板 +
+
+
+
+ + {gate === 'denied' ? ( +
+
+ +
+ 当前账号无系统管理权限 +
+
+ 服务端已拒绝访问(403)。系统管理台仅对 role-admin 账号开放,请返回工作台。 +
+ +
+
+ ) : ( + /* 主体左右分栏 */ +
+ {/* 左侧分组导航 */} +
+
+ {NAV_ITEMS.map((item) => ( + + ))} +
+ + {/* 底部系统信息 */} +
+
水木东方医疗器械 AI 工作台
+
Auth & RBAC v2.4
+
+
+ + {/* 右侧内容区 */} +
+ {gate === 'checking' ? ( +
+ 正在校验管理员权限… +
+ ) : ( + renderSection() + )} +
+
+ )} +
+ ); +}; diff --git a/web/src/components/admin/console/sections/DocsSection.tsx b/web/src/components/admin/console/sections/DocsSection.tsx new file mode 100644 index 00000000..0585f825 --- /dev/null +++ b/web/src/components/admin/console/sections/DocsSection.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { FileText } from 'lucide-react'; +import { listAdminTemplates } from '../../../../api/workflowAdmin'; +import { + SectionError, + SectionHeader, + SectionLoading, + sectionCardStyle, + sectionTableStyle, + sectionTdStyle, + sectionThStyle, +} from './sectionCommon'; + +type AdminTemplate = { id: number; asset_key: string; name: string; status: string }; + +export const DocsSection: React.FC = () => { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setTemplates(await listAdminTemplates()); + } catch (err) { + setTemplates([]); + setError(err instanceof Error ? err.message : '获取模板列表失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+ + + {loading ? ( + + ) : error ? ( + void load()} /> + ) : ( +
+ + + + + + + + + + {templates.length === 0 && ( + + + + )} + {templates.map((t) => ( + + + + + + ))} + +
模板名称资产标识 (asset_key)状态
+ 暂无模板(需 PG 环境 bootstrap) +
+
+ + {t.name} +
+
+ {t.asset_key} + {t.status || '-'}
+
+ )} + +
+ 一期仅提供模板库只读摘要。项目文档的编制、受控流转、版本与可见性治理均在 + 工作台右侧文档区 + 进行;点击右上角「返回工作台」即可回到协同工作界面。 +
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/FeaturesSection.tsx b/web/src/components/admin/console/sections/FeaturesSection.tsx new file mode 100644 index 00000000..5a9a28c1 --- /dev/null +++ b/web/src/components/admin/console/sections/FeaturesSection.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { fetchSystemFeatures } from '../../../../api/systemAdmin'; +import type { SystemFeature, SystemFeatureSource } from '../../../../types/systemAdmin'; +import { + RestartBadge, + SectionError, + SectionHeader, + SectionLoading, + formatConfigValue, + sectionTableStyle, + sectionTdStyle, + sectionThStyle, +} from './sectionCommon'; + +const SOURCE_META: Record = { + env: { label: '环境变量', color: 'var(--info)', bg: 'var(--info-bg)' }, + db: { label: '数据库', color: 'var(--success)', bg: 'var(--success-bg)' }, + default: { label: '默认值', color: 'var(--text-muted)', bg: 'var(--bg-subtle)' }, +}; + +export const FeaturesSection: React.FC = () => { + const [features, setFeatures] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setFeatures(await fetchSystemFeatures()); + } catch (err) { + setError(err instanceof Error ? err.message : '获取特性开关失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) return ; + if (error) return void load()} />; + + return ( +
+ +
+ + + + + + + + + + + + {features.length === 0 && ( + + + + )} + {features.map((f) => { + const src = SOURCE_META[f.source] ?? SOURCE_META.default; + return ( + + + + + + + + ); + })} + +
名称分组当前值来源标注
+ 暂无特性开关数据 +
+
{f.label || f.key}
+
{f.key}
+ {f.description && ( +
+ {f.description} +
+ )} +
{f.group || '-'} + {formatConfigValue(f.value)} + + + {src.label} + + {f.requiresRestart && }
+
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/KbSection.tsx b/web/src/components/admin/console/sections/KbSection.tsx new file mode 100644 index 00000000..0895bd58 --- /dev/null +++ b/web/src/components/admin/console/sections/KbSection.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { fetchSystemKb } from '../../../../api/systemAdmin'; +import type { SystemKbInfo } from '../../../../types/systemAdmin'; +import { + SectionError, + SectionHeader, + SectionLoading, + StatusDot, + sectionCardStyle, + sectionTdStyle, +} from './sectionCommon'; + +export const KbSection: React.FC = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setData(await fetchSystemKb()); + } catch (err) { + setError(err instanceof Error ? err.message : '获取知识库连接信息失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) return ; + if (error || !data) return void load()} />; + + const rows: Array<{ label: string; value: React.ReactNode }> = [ + { + label: '在线状态', + value: ( + + + + {data.online ? '在线' : '离线'} + + + ), + }, + { label: '服务地址', value: `${data.host || '-'}:${data.port ?? '-'}` }, + { label: '数据库', value: data.database || '-' }, + { label: '连接账号', value: data.user || '-' }, + { label: 'Embedding 模型', value: data.embeddingModel || '-' }, + { label: 'Reranker 重排序', value: data.rerankerEnabled ? '已启用' : '未启用' }, + { label: 'SSL 模式', value: data.sslMode || '-' }, + ]; + + return ( +
+ +
+ + + {rows.map((row) => ( + + + + + ))} + +
+ {row.label} + {row.value}
+
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/OverviewSection.tsx b/web/src/components/admin/console/sections/OverviewSection.tsx new file mode 100644 index 00000000..fa52e3b0 --- /dev/null +++ b/web/src/components/admin/console/sections/OverviewSection.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { fetchSystemOverview } from '../../../../api/systemAdmin'; +import type { SystemOverview } from '../../../../types/systemAdmin'; +import { + SectionError, + SectionHeader, + SectionLoading, + StatusDot, + formatUptime, + sectionCardStyle, +} from './sectionCommon'; + +const OverviewCard: React.FC<{ + label: string; + value: React.ReactNode; + hint?: string; + ok?: boolean; +}> = ({ label, value, hint, ok }) => ( +
+
+ {typeof ok === 'boolean' && } + {label} +
+
+ {value} +
+ {hint && ( +
{hint}
+ )} +
+); + +export const OverviewSection: React.FC = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setData(await fetchSystemOverview()); + } catch (err) { + setError(err instanceof Error ? err.message : '获取系统总览失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) return ; + if (error || !data) return void load()} />; + + const healthy = data.status === 'ok' || data.status === 'healthy'; + return ( +
+ +
+ + + + + + +
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/RolesSection.tsx b/web/src/components/admin/console/sections/RolesSection.tsx new file mode 100644 index 00000000..122aa3e9 --- /dev/null +++ b/web/src/components/admin/console/sections/RolesSection.tsx @@ -0,0 +1,371 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { AlertCircle, CheckCircle2, Plus, Trash2 } from 'lucide-react'; +import { authClient } from '../../../../api/auth'; +import { PermissionKey, RoleDefinition } from '../../../../types/rbac'; +import { SectionError, SectionLoading } from './sectionCommon'; + +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 interface RolesSectionProps { + /** 角色库变更后同步父级 App roles(工作台 RBAC 计算依赖此目录) */ + onRolesChanged?: (roles: RoleDefinition[]) => void; +} + +export const RolesSection: React.FC = ({ onRolesChanged }) => { + const [editRoles, setEditRoles] = useState([]); + const [selectedRoleId, setSelectedRoleId] = useState('role-admin'); + const [isCreatingRole, setIsCreatingRole] = useState(false); + const [newRoleName, setNewRoleName] = useState(''); + const [newRoleCode, setNewRoleCode] = useState(''); + const [newRoleDesc, setNewRoleDesc] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tip, setTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + + const showTip = (type: 'success' | 'error', msg: string) => { + setTip({ type, msg }); + setTimeout(() => setTip(null), 3000); + }; + + const applyRoles = (next: RoleDefinition[]) => { + setEditRoles(next); + onRolesChanged?.(next); + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const list = await authClient.listRoles(); + setEditRoles(list); + setSelectedRoleId((prev) => (list.some((r) => r.id === prev) ? prev : list[0]?.id || 'role-admin')); + } catch (err) { + setError(err instanceof Error ? err.message : '获取角色库失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const currentSelectedRole = editRoles.find((r) => r.id === selectedRoleId) || editRoles[0]; + + const handleTogglePermission = async (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]; + try { + const updated = await authClient.updateRole(currentSelectedRole.id, { + name: currentSelectedRole.name, + description: currentSelectedRole.description, + permissions: updatedPermissions, + }); + applyRoles(editRoles.map((r) => (r.id === updated.id ? updated : r))); + showTip('success', `已更新【${currentSelectedRole.name}】权限矩阵`); + } catch (err: any) { + showTip('error', err.message || '更新权限失败'); + } + }; + + const handleCreateRole = async () => { + if (!newRoleName.trim() || !newRoleCode.trim()) return; + try { + const newRole = await authClient.createRole({ + code: newRoleCode.trim().toUpperCase(), + name: newRoleName.trim(), + description: newRoleDesc.trim() || '自定义受控职位角色', + permissions: ['workflow:view_all', 'doc:export'], + }); + applyRoles([...editRoles, newRole]); + setSelectedRoleId(newRole.id); + setIsCreatingRole(false); + setNewRoleName(''); + setNewRoleCode(''); + setNewRoleDesc(''); + showTip('success', `已创建新业务角色【${newRole.name}】`); + } catch (err: any) { + showTip('error', err.message || '创建角色失败'); + } + }; + + const handleDeleteRole = async (roleId: string) => { + const roleToDelete = editRoles.find((r) => r.id === roleId); + if (!roleToDelete || roleToDelete.isSystem) return; + if (!window.confirm(`确定要删除角色【${roleToDelete.name}】吗?删除后该角色人员将自动退回普通工程师。`)) { + return; + } + try { + await authClient.deleteRole(roleId); + const nextRoles = editRoles.filter((r) => r.id !== roleId); + applyRoles(nextRoles); + setSelectedRoleId(nextRoles[0]?.id || 'role-admin'); + showTip('success', `已删除角色【${roleToDelete.name}】`); + } catch (err: any) { + showTip('error', err.message || '删除角色失败'); + } + }; + + if (loading) return ; + if (error) return void load()} />; + + return ( +
+ {tip && ( +
+ {tip.type === 'success' ? : } + {tip.msg} +
+ )} +
+ {/* 角色列表 */} +
+
+
+ 全局受控角色库 ({editRoles.length}) +
+ {editRoles.map((r) => { + const isSel = r.id === selectedRoleId; + return ( +
setSelectedRoleId(r.id)} + style={{ + padding: '8px 10px', + borderRadius: '6px', + backgroundColor: isSel ? 'var(--bg-panel)' : 'transparent', + border: isSel ? '1px solid var(--brand-500)' : '1px solid transparent', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }} + > +
+
+ {r.name} +
+
+ {r.code} · {r.permissions.length} 项权限 +
+
+ {!r.isSystem && ( + + )} +
+ ); + })} +
+ + {/* 新增自定义角色 */} +
+ {isCreatingRole ? ( +
+ setNewRoleName(e.target.value)} + placeholder="角色名称 (如 临床协调员)" + style={{ width: '100%', padding: '6px 8px', borderRadius: '4px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '11px', boxSizing: 'border-box' }} + /> + setNewRoleCode(e.target.value)} + placeholder="标识代号 (如 CRA)" + style={{ width: '100%', padding: '6px 8px', borderRadius: '4px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '11px', boxSizing: 'border-box' }} + /> +
+ + +
+
+ ) : ( + + )} +
+
+ + {/* 对应角色的权限复选矩阵 */} +
+
+
+ + 【{currentSelectedRole?.name}】权限配置矩阵 + + {currentSelectedRole?.isSystem && ( + + 系统内置受控角色 (只读) + + )} +
+
+ {currentSelectedRole?.description} +
+
+ +
+ {ALL_PERMISSION_ITEMS.map((item) => { + const isChecked = + currentSelectedRole?.code === 'ADMIN' || + currentSelectedRole?.permissions.includes(item.key); + const isLocked = currentSelectedRole?.isSystem; + + return ( +
!isLocked && handleTogglePermission(item.key)} + style={{ + padding: '12px 14px', + borderRadius: '8px', + backgroundColor: isChecked ? 'var(--bg-subtle)' : 'var(--bg-app)', + border: isChecked ? '1px solid var(--border)' : '1px dashed var(--border)', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: isLocked ? 'not-allowed' : 'pointer', + }} + > +
+
+ + {item.label} + + + {item.category} + +
+
+ {item.desc} +
+
+ + {}} + style={{ width: '16px', height: '16px', accentColor: 'var(--brand-500)', cursor: isLocked ? 'not-allowed' : 'pointer' }} + /> +
+ ); + })} +
+
+
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/SettingsSection.tsx b/web/src/components/admin/console/sections/SettingsSection.tsx new file mode 100644 index 00000000..c93ba25d --- /dev/null +++ b/web/src/components/admin/console/sections/SettingsSection.tsx @@ -0,0 +1,212 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { AlertCircle, CheckCircle2 } from 'lucide-react'; +import { fetchSystemSettings, updateSystemSetting } from '../../../../api/systemAdmin'; +import type { SystemConfigValue, SystemSetting } from '../../../../types/systemAdmin'; +import { + RestartBadge, + SectionError, + SectionHeader, + SectionLoading, + formatConfigValue, + sectionCardStyle, +} from './sectionCommon'; + +function isBooleanSetting(s: SystemSetting): boolean { + return s.type === 'boolean' || s.type === 'bool' || typeof s.value === 'boolean'; +} + +export const SettingsSection: React.FC = () => { + const [settings, setSettings] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [savingKey, setSavingKey] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tip, setTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + + const showTip = (type: 'success' | 'error', msg: string) => { + setTip({ type, msg }); + setTimeout(() => setTip(null), 3000); + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const list = await fetchSystemSettings(); + setSettings(list); + setDrafts(Object.fromEntries(list.map((s) => [s.key, s.value]))); + } catch (err) { + setError(err instanceof Error ? err.message : '获取系统设置失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleSave = async (setting: SystemSetting) => { + setSavingKey(setting.key); + try { + const updated = await updateSystemSetting(setting.key, drafts[setting.key]); + setSettings((prev) => prev.map((s) => (s.key === updated.key ? updated : s))); + setDrafts((prev) => ({ ...prev, [updated.key]: updated.value })); + showTip( + 'success', + updated.requiresRestart ? `已保存【${updated.label || updated.key}】,重启服务后生效` : `已保存【${updated.label || updated.key}】`, + ); + } catch (err) { + showTip('error', err instanceof Error ? err.message : '保存设置失败'); + } finally { + setSavingKey(null); + } + }; + + if (loading) return ; + if (error) return void load()} />; + + const groups: Array<{ group: string; items: SystemSetting[] }> = []; + for (const s of settings) { + const g = s.group || '其他'; + const found = groups.find((x) => x.group === g); + if (found) found.items.push(s); + else groups.push({ group: g, items: [s] }); + } + + return ( +
+
+ + {tip && ( + + {tip.type === 'success' ? : } + {tip.msg} + + )} +
+ + {groups.length === 0 && } + + {groups.map((g) => ( +
+
{g.group}
+ {g.items.map((s) => { + const draft = drafts[s.key]; + const dirty = s.writable && draft !== s.value; + return ( +
+
+
+ + {s.label || s.key} + + {!s.writable && ( + + 只读 + + )} + {s.requiresRestart && } +
+ {s.description && ( +
+ {s.description} +
+ )} +
+ {s.key} · 默认值: {formatConfigValue(s.default)} + {s.updatedAt && ` · 更新于 ${s.updatedAt}${s.updatedBy ? ` by ${s.updatedBy}` : ''}`} +
+
+ +
+ {isBooleanSetting(s) ? ( + setDrafts((prev) => ({ ...prev, [s.key]: e.target.checked }))} + style={{ + width: '18px', + height: '18px', + cursor: s.writable ? 'pointer' : 'not-allowed', + accentColor: 'var(--brand-500)', + }} + /> + ) : ( + setDrafts((prev) => ({ ...prev, [s.key]: e.target.value }))} + style={{ + width: '220px', + padding: '6px 10px', + borderRadius: '6px', + backgroundColor: 'var(--bg-app)', + border: '1px solid var(--border)', + color: s.writable ? 'var(--text-strong)' : 'var(--text-muted)', + fontSize: '12px', + boxSizing: 'border-box', + cursor: s.writable ? 'text' : 'not-allowed', + }} + /> + )} + {s.writable && ( + + )} +
+
+ ); + })} +
+ ))} +
+ ); +}; diff --git a/web/src/components/admin/console/sections/UsersSection.tsx b/web/src/components/admin/console/sections/UsersSection.tsx new file mode 100644 index 00000000..49b0a2f0 --- /dev/null +++ b/web/src/components/admin/console/sections/UsersSection.tsx @@ -0,0 +1,898 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + AlertCircle, + Check, + CheckCircle2, + Search, + Sliders, + UserCheck, + UserPlus, + Users, + X, +} from 'lucide-react'; +import { authClient } from '../../../../api/auth'; +import { syncAdminUserDirectories } from '../../../../lib/adminUserDirectorySync'; +import { UserAccount } from '../../../../types/auth'; +import { UserProfile } from '../../../../types/docgov'; +import { RoleDefinition } from '../../../../types/rbac'; +import { SectionError, SectionLoading } from './sectionCommon'; + +type UsersTab = 'users' | 'approvals' | 'policy'; + +export interface UsersSectionProps { + currentUser: UserProfile; + /** 账号变更后同步父级 App allUsers(立项成员候选依赖此目录,见 UI-01) */ + onRefreshUsers?: () => void | Promise; +} + +export const UsersSection: React.FC = ({ currentUser, onRefreshUsers }) => { + const [activeTab, setActiveTab] = useState('users'); + + // 全量账号列表(含待审核及禁用状态) + const [adminUsers, setAdminUsers] = useState([]); + const [editRoles, setEditRoles] = useState([]); + const [userSearchText, setUserSearchText] = useState(''); + const [actionTip, setActionTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 管理员新建用户表单 + const [showCreateUserModal, setShowCreateUserModal] = useState(false); + const [newUname, setNewUname] = useState(''); + const [newRealName, setNewRealName] = useState(''); + const [newInitPass, setNewInitPass] = useState('InitPass123'); + const [newRoleId, setNewRoleId] = useState('role-engineer'); + const [newPosition, setNewPosition] = useState('方案工程师'); + const [newDept, setNewDept] = useState('研发中心'); + + // 准入策略本地状态 + const [allowReg, setAllowReg] = useState(true); + const [requireAppr, setRequireAppr] = useState(false); + + const showNotification = (type: 'success' | 'error', msg: string) => { + setActionTip({ type, msg }); + setTimeout(() => setActionTip(null), 3000); + }; + + // 刷新全量用户列表,并同步父级 App allUsers(立项成员候选依赖此目录) + const refreshUsers = async () => { + try { + await syncAdminUserDirectories({ + reloadLocalUsers: async () => { + const list = await authClient.listAllUsers(); + setAdminUsers(list); + }, + onRefreshUsers, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '刷新/同步用户目录失败'; + setActionTip({ type: 'error', msg }); + throw err; + } + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [users, roles, cfg] = await Promise.all([ + authClient.listAllUsers(), + authClient.listRoles(), + authClient.getSystemAuthConfig().catch(() => null), + ]); + setAdminUsers(users); + setEditRoles(roles); + if (cfg) { + setAllowReg(cfg.allowRegistration); + setRequireAppr(cfg.requireApproval); + } + } catch (err) { + setError(err instanceof Error ? err.message : '获取用户目录失败'); + } finally { + setLoading(false); + } + }, []); + + // 父级回调用 ref 持有:App 重渲染会产生新函数身份,避免 mount effect 被反复触发 + const onRefreshUsersRef = useRef(onRefreshUsers); + onRefreshUsersRef.current = onRefreshUsers; + + useEffect(() => { + void load().then(() => { + // 首屏也同步父级全局目录,保证立项成员候选为最新 + const cb = onRefreshUsersRef.current; + if (cb) void Promise.resolve(cb()).catch(() => {}); + }); + }, [load]); + + // 1. 人员职位与角色分配 + const handleUserRoleChange = async (userId: number, roleId: string) => { + const role = editRoles.find((r) => r.id === roleId); + try { + await authClient.updateUserRole(userId, roleId, role?.name || '方案工程师'); + } catch (err: any) { + showNotification('error', err.message || '更新角色失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', '已成功变更该成员的所属职位与系统角色'); + } catch { + showNotification('error', '角色已更新,但全局用户目录同步失败,请刷新后重试'); + } + }; + + // 2. 账号状态(激活/停用) + const handleToggleUserStatus = async (user: UserAccount) => { + const nextStatus = user.status === 'active' ? 'disabled' : 'active'; + try { + await authClient.updateUserStatus(user.id, nextStatus); + } catch (err: any) { + showNotification('error', err.message || '切换状态失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已将账号【${user.name}】状态切换为:${nextStatus === 'active' ? '启用' : '停用'}`); + } catch { + showNotification('error', '账号状态已更新,但全局用户目录同步失败,请刷新后重试'); + } + }; + + // 3. 审批通过或驳回 + const handleApproveRegistration = async (user: UserAccount, targetRoleId: string) => { + const role = editRoles.find((r) => r.id === targetRoleId); + try { + await authClient.updateUserStatus(user.id, 'active', targetRoleId, role?.name || '方案工程师'); + } catch (err: any) { + showNotification('error', err.message || '审批操作失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已批准【${user.name}】注册申请,已正式授予【${role?.name}】职位`); + } catch { + showNotification('error', '审批已生效,但全局用户目录同步失败,请刷新后重试'); + } + }; + + const handleRejectRegistration = async (user: UserAccount) => { + if (!window.confirm(`确定要驳回【${user.name}】的注册申请吗?该账号将被置为禁用。`)) return; + try { + await authClient.updateUserStatus(user.id, 'disabled'); + } catch (err: any) { + showNotification('error', err.message || '驳回操作失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已驳回【${user.name}】的注册申请`); + } catch { + showNotification('error', '驳回已生效,但全局用户目录同步失败,请刷新后重试'); + } + }; + + // 4. 重置用户密码为初始并强制改密 + const handleResetUserPassword = async (user: UserAccount) => { + const tempPass = 'ResetPass123'; + if (!window.confirm(`确定要重置【${user.name}】的密码吗?重置后初始密码为 ${tempPass},该用户下次登录将被强制改密。`)) return; + try { + await authClient.adminResetPassword(user.id, tempPass); + } catch (err: any) { + showNotification('error', err.message || '重置密码失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已重置【${user.name}】密码为 ${tempPass},已标记首次登录强制改密`); + } catch { + showNotification('error', '密码已重置,但全局用户目录同步失败,请刷新后重试'); + } + }; + + // 5. 管理员新建账号 + const handleCreateUserSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newUname.trim() || !newRealName.trim() || !newInitPass) { + showNotification('error', '请填写完整账号、姓名与初始密码'); + return; + } + try { + await authClient.createAdminUser({ + username: newUname.trim(), + name: newRealName.trim(), + initialPassword: newInitPass, + roleId: newRoleId, + position: newPosition, + department: newDept, + }); + setShowCreateUserModal(false); + setNewUname(''); + setNewRealName(''); + try { + await refreshUsers(); + showNotification('success', `已成功开通受控账号【${newRealName}】,已自动标记首次登录强制改密`); + } catch { + showNotification( + 'error', + `账号【${newRealName}】已开通,但全局用户目录同步失败,立项成员候选可能暂不可见该账号`, + ); + } + } catch (err: any) { + showNotification('error', err.message || '新建员工账号失败'); + } + }; + + // 6. 保存准入策略 + const handleSavePolicy = async () => { + try { + await authClient.updateSystemAuthConfig({ + allowRegistration: allowReg, + requireApproval: requireAppr, + }); + showNotification('success', '系统准入策略已即刻全局生效'); + } catch (err: any) { + showNotification('error', err.message || '更新策略失败'); + } + }; + + // 待审批列表 + const pendingUsers = adminUsers.filter((u) => u.status === 'pending_approval'); + // 过滤后的用户列表 + const filteredUsers = adminUsers.filter( + (u) => + u.name.toLowerCase().includes(userSearchText.toLowerCase()) || + (u.username && u.username.toLowerCase().includes(userSearchText.toLowerCase())) || + (u.department && u.department.toLowerCase().includes(userSearchText.toLowerCase())) + ); + + if (loading) return ; + if (error) return void load()} />; + + const subTabButton = (tab: UsersTab, label: string, icon: React.ReactNode, badge?: number) => ( + + ); + + return ( +
+ {/* 子 Tab:人员治理 / 注册审批 / 准入策略 */} +
+
+ {subTabButton('users', '人员与职位治理', )} + {subTabButton('approvals', '注册审批中心', , pendingUsers.length)} + {subTabButton('policy', '系统准入策略', )} +
+ {actionTip && ( + + {actionTip.type === 'success' ? : } + {actionTip.msg} + + )} +
+ + {/* 子 Tab 1: 人员与职位治理 */} + {activeTab === 'users' && ( +
+
+
+ + setUserSearchText(e.target.value)} + placeholder="搜索姓名、用户名或部门..." + style={{ + width: '100%', + padding: '7px 10px 7px 32px', + borderRadius: '8px', + backgroundColor: 'var(--bg-app)', + border: '1px solid var(--border)', + color: 'var(--text-strong)', + fontSize: '12px', + boxSizing: 'border-box', + }} + /> +
+ + +
+ + {/* 用户列表表格 */} +
+ + + + + + + + + + + + + {filteredUsers.map((u) => { + const isSelf = u.id === currentUser.id; + return ( + + + + + + + + + ); + })} + +
成员姓名登录账号 / 工号所属部门系统职位与角色账号状态操作治理
+
+
+ {u.name.slice(0, 1)} +
+ {u.name} + {isSelf && ( + + 当前账号 + + )} +
+
+
{u.username || '系统预设'}
+
{u.employeeId || 'EMP-未设定'}
+
+ {u.department || '未分配部门'} + + + + + {u.status === 'active' ? '正常在职' : u.status === 'pending_approval' ? '待审核' : '已停用'} + + +
+ + {!isSelf && ( + + )} +
+
+
+
+ )} + + {/* 子 Tab 2: 注册审批中心 */} + {activeTab === 'approvals' && ( +
+
+
+ 新员工注册审批与职位指派中心 +
+
+ 依据 NMPA 体系规范,自主注册申请需经系统管理员人工核准并正式授予业务职位。 +
+
+ + {pendingUsers.length === 0 ? ( +
+ +
+ 当前没有待审批的注册申请 +
+
+ 所有新注册员工均已完成合规审批并完成业务职位委派。 +
+
+ ) : ( +
+ {pendingUsers.map((u) => { + return ( +
+
+
+ + {u.name} + + + 账号: {u.username} + + + 待审核 + +
+
+ 部门: {u.department || '未填写'} + 工号: {u.employeeId || '未填写'} + 申请时间: {u.createdAt || '刚刚'} +
+
+ +
+ + + + + +
+
+ ); + })} +
+ )} +
+ )} + + {/* 子 Tab 3: 系统准入策略 */} + {activeTab === 'policy' && ( +
+
+
+ 系统全局准入与注册审核策略 +
+
+ 控制系统外部开放度,关闭自主注册或开启强制审核均实时阻断越权账号。 +
+
+ + {/* 开关 1: 开放自主注册 */} +
+
+
+ 允许新用户自主注册账号 +
+
+ 开启后,登录页将呈现「用户自主注册」选项卡;关闭后,仅管理员可在控制台开通账号。 +
+
+ setAllowReg(e.target.checked)} + style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }} + /> +
+ + {/* 开关 2: 注册需审核 */} +
+
+
+ 新注册账号必须由管理员审核通过 +
+
+ 开启后,新注册账号处于「待审核」状态禁止登录,直至管理员在审批中心核准并正式分配职位。 +
+
+ setRequireAppr(e.target.checked)} + style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: 'var(--brand-500)' }} + /> +
+ +
+ +
+
+ )} + + {/* 管理员直接开通员工账号的内层弹窗 */} + {showCreateUserModal && ( +
{ + if (e.target === e.currentTarget) setShowCreateUserModal(false); + }} + > +
+
+ 开通受控员工账号 +
+
+ 由管理员开通的员工,首次登录系统将被强制修改初始密码。 +
+ +
+
+ + setNewUname(e.target.value)} + placeholder="如 li_si" + style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} + /> +
+ +
+ + setNewRealName(e.target.value)} + placeholder="如 李四" + style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} + /> +
+ +
+ + +
+ +
+
+ + setNewDept(e.target.value)} + style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} + /> +
+
+ + setNewInitPass(e.target.value)} + style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }} + /> +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/web/src/components/admin/console/sections/WorkflowsSection.tsx b/web/src/components/admin/console/sections/WorkflowsSection.tsx new file mode 100644 index 00000000..47540d27 --- /dev/null +++ b/web/src/components/admin/console/sections/WorkflowsSection.tsx @@ -0,0 +1,157 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + getAdminWorkflow, + listAdminWorkflows, + publishWorkflowVersion, + validateWorkflowVersion, + WorkflowDefinitionSummary, + WorkflowVersionSummary, +} from '../../../../api/workflowAdmin'; +import { SectionError, SectionHeader, SectionLoading } from './sectionCommon'; + +export const WorkflowsSection: React.FC = () => { + const [adminWorkflows, setAdminWorkflows] = useState([]); + const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); + const [workflowVersions, setWorkflowVersions] = useState([]); + const [validationMsg, setValidationMsg] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refreshWorkflows = useCallback(async () => { + setLoading(true); + setError(null); + try { + const list = await listAdminWorkflows(); + setAdminWorkflows(list); + } catch (err) { + setAdminWorkflows([]); + setError(err instanceof Error ? err.message : '获取工作流列表失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refreshWorkflows(); + }, [refreshWorkflows]); + + const loadWorkflowDetail = async (id: number) => { + setSelectedWorkflowId(id); + try { + const detail = await getAdminWorkflow(id); + setWorkflowVersions(detail.versions ?? []); + } catch { + setWorkflowVersions([]); + } + }; + + const handleValidateVersion = async (workflowId: number, version: number) => { + try { + const res = await validateWorkflowVersion(workflowId, version); + if (res.valid) { + setValidationMsg(`v${version} 校验通过`); + } else { + setValidationMsg(res.issues.map((i) => i.message).join(';')); + } + } catch (err: any) { + setValidationMsg(err.message || '校验失败'); + } + }; + + const handlePublishVersion = async (workflowId: number, version: number) => { + if (!window.confirm(`确认发布工作流版本 v${version}?发布后不可原地修改。`)) return; + try { + await publishWorkflowVersion(workflowId, version); + setValidationMsg(`已发布 v${version}`); + refreshWorkflows(); + loadWorkflowDetail(workflowId); + } catch (err: any) { + setValidationMsg(err.message || '发布失败'); + } + }; + + if (loading) return ; + if (error) return void refreshWorkflows()} />; + + return ( +
+ +
+
+ {adminWorkflows.map((wf) => ( + + ))} + {adminWorkflows.length === 0 && ( +
暂无工作流定义
+ )} +
+
+ {selectedWorkflowId && ( + <> +
版本列表
+ {workflowVersions.map((v) => ( +
+
+ v{v.version} · {v.status} +
+
+ hash: {v.definition_hash.slice(0, 16)}… +
+
+ + {v.status === 'draft' && ( + + )} +
+
+ ))} + {validationMsg && ( +
{validationMsg}
+ )} + + )} +
+
+
+ ); +}; diff --git a/web/src/components/admin/console/sections/sectionCommon.tsx b/web/src/components/admin/console/sections/sectionCommon.tsx new file mode 100644 index 00000000..fa20a40f --- /dev/null +++ b/web/src/components/admin/console/sections/sectionCommon.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { AlertCircle, RefreshCw } from 'lucide-react'; + +export const sectionCardStyle: React.CSSProperties = { + padding: '16px', + borderRadius: '10px', + backgroundColor: 'var(--bg-subtle)', + border: '1px solid var(--border)', +}; + +export const sectionTableStyle: React.CSSProperties = { + width: '100%', + borderCollapse: 'collapse', + textAlign: 'left', + fontSize: '12px', +}; + +export const sectionThStyle: React.CSSProperties = { + padding: '10px 14px', + fontWeight: 600, +}; + +export const sectionTdStyle: React.CSSProperties = { + padding: '10px 14px', + borderBottom: '1px solid var(--border-light)', +}; + +export const SectionHeader: React.FC<{ title: string; desc: string }> = ({ title, desc }) => ( +
+
{title}
+
{desc}
+
+); + +export const SectionLoading: React.FC<{ text?: string }> = ({ text = '加载中…' }) => ( +
+ {text} +
+); + +export const SectionError: React.FC<{ message: string; onRetry?: () => void }> = ({ message, onRetry }) => ( +
+
+ + {message} +
+ {onRetry && ( + + )} +
+); + +export const RestartBadge: React.FC = () => ( + + 需重启生效 + +); + +export const StatusDot: React.FC<{ ok: boolean }> = ({ ok }) => ( + +); + +export function formatConfigValue(value: string | number | boolean): string { + if (typeof value === 'boolean') return value ? '开启' : '关闭'; + return String(value); +} + +export function formatUptime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '-'; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d} 天 ${h} 小时`; + if (h > 0) return `${h} 小时 ${m} 分`; + return `${m} 分 ${Math.floor(seconds % 60)} 秒`; +} diff --git a/web/src/components/chat/ChatColumn.test.ts b/web/src/components/chat/ChatColumn.test.ts new file mode 100644 index 00000000..0a01cf05 --- /dev/null +++ b/web/src/components/chat/ChatColumn.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('chat column lives outside the workbench shell', () => { + const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8'); + const column = readFileSync(new URL('./ChatColumn.tsx', import.meta.url), 'utf8'); + assert.equal((app.match(/ + import('../history/ChatExportModal').then((m) => ({ default: m.ChatExportModal })); + +export interface ChatColumnProps { + activeSessionId: string; + categoryStats: ChatCategoryStat[]; + chatWindow: UseChatWindowReturn; + isCalendarOpen: boolean; + setIsCalendarOpen: (next: boolean | ((prev: boolean) => boolean)) => void; + isSearchOpen: boolean; + setIsSearchOpen: (open: boolean) => void; + isDigestOpen: boolean; + setIsDigestOpen: (open: boolean) => void; + isExportOpen: boolean; + setIsExportOpen: (open: boolean) => void; + unreadTotal: number; + dateStats: ChatDateStat[]; + unreadMarks: UnreadMark[]; + unreadMessageIds: number[]; + isAllReadCompleted: boolean; + consumeNextUnread: () => Promise; + handleSelectDoc: (id: string) => void; + handleCardAction: (cardId: string, actionId: string, payload?: unknown) => void; + handleErrorAction: (messageId: string, action: 'retry' | 'continue' | 'restart') => void; + handleQuoteMessage: (message: ChatMessage) => void; + handleSaveMessageToPrivate: (message: ChatMessage) => void; + handleExportReplyDocx: (message: ChatMessage) => void | Promise; + pendingProposal: { changes: string; impact: string; risk: string } | null; + proposalError: string | null; + handleAcceptProposal: () => void; + handleRejectProposal: () => void; + handleSendMessage: React.ComponentProps['onSendMessage']; + isThinking: boolean; + isStreamingActive: boolean; + handleStopStreaming: () => void; + activeQuotes: QuotedMessage[]; + handleRemoveQuote: (index: number) => void; + isCurrentProjectExited: boolean; +} + +export function ChatColumn({ + activeSessionId, + categoryStats, + chatWindow, + isCalendarOpen, + setIsCalendarOpen, + isSearchOpen, + setIsSearchOpen, + isDigestOpen, + setIsDigestOpen, + isExportOpen, + setIsExportOpen, + unreadTotal, + dateStats, + unreadMarks, + unreadMessageIds, + isAllReadCompleted, + consumeNextUnread, + handleSelectDoc, + handleCardAction, + handleErrorAction, + handleQuoteMessage, + handleSaveMessageToPrivate, + handleExportReplyDocx, + pendingProposal, + proposalError, + handleAcceptProposal, + handleRejectProposal, + handleSendMessage, + isThinking, + isStreamingActive, + handleStopStreaming, + activeQuotes, + handleRemoveQuote, + isCurrentProjectExited, +}: ChatColumnProps) { + return ( +
+ { + if (lastMessageId && activeSessionId) { + const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages); + navigateToAnchor( + { sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 }, + { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } + ); + } + }} + onToggleCalendar={() => setIsCalendarOpen((prev) => !prev)} + isCalendarOpen={isCalendarOpen} + onOpenSearch={() => setIsSearchOpen(true)} + onOpenDigest={() => setIsDigestOpen(true)} + onOpenExport={() => setIsExportOpen(true)} + unreadTotal={unreadTotal} + /> + {isCalendarOpen && ( + { + chatWindow.jumpToDate(date); + setIsCalendarOpen(false); + }} + onClose={() => setIsCalendarOpen(false)} + /> + )} + {isSearchOpen && ( + { + navigateToAnchor( + { sessionId: activeSessionId, messageId: hit.message_id }, + { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } + ); + setIsSearchOpen(false); + }} + onClose={() => setIsSearchOpen(false)} + /> + )} + {isDigestOpen && ( + { + navigateToAnchor( + { sessionId: activeSessionId, messageId }, + { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } + ); + setIsDigestOpen(false); + }} + onClose={() => setIsDigestOpen(false)} + /> + )} + {isExportOpen && ( + setIsExportOpen(false), + sessionId: activeSessionId, + dayStack: chatWindow.displayDayStack, + }} + /> + )} +
+ { + navigateToAnchor( + { sessionId: activeSessionId, messageId: targetAnchor }, + { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } + ); + }} + selectedCategory={chatWindow.selectedCategory} + unreadMessageIds={unreadMessageIds} + onSaveToPrivate={handleSaveMessageToPrivate} + onExportDocx={handleExportReplyDocx} + onOpenDoc={handleSelectDoc} + /> + { + const targetId = await consumeNextUnread(); + if (targetId && activeSessionId) { + navigateToAnchor( + { sessionId: activeSessionId, messageId: targetId, highlightMs: 2000 }, + { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } + ); + } + }} + onNavigateToCategory={(_cat, lastMessageId) => { + if (lastMessageId && activeSessionId) { + const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages); + navigateToAnchor( + { sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 }, + { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } + ); + } + }} + onSelectCategory={chatWindow.setSelectedCategory} + /> +
+ {pendingProposal && ( +
+ {proposalError && ( +
+ {proposalError} +
+ )} + +
+ )} + +
+ ); +} diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index 6a3961b4..cdd0cdee 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ChatErrorInfo, ChatMessage } from '../../types/workbench'; import { assistantTaskStatusLabel } from '../../api/chatWindow'; import { isBlockedGovernanceBubble } from '../../lib/blockedGovernanceCopy'; @@ -7,7 +7,7 @@ import { RecommendedCaseCard } from './RecommendedCaseCard'; import { InteractiveCard } from './InteractiveCard'; import { ReplyActionBar } from './ReplyActionBar'; import { FileText, Bot, User, Loader2, AlertTriangle, Copy, Check, Quote as QuoteIcon, ExternalLink } from 'lucide-react'; -import { renderInlineMarkdown } from '../../engine/renderMarkdown'; +import { contentHasMath, ensureMathRenderer, renderInlineMarkdown } from '../../engine/renderMarkdown'; import { copyPlainText } from '../../features/replyExport/clipboard'; import { QuoteCard } from './QuoteCard'; @@ -71,10 +71,22 @@ const MessageBubbleInner: React.FC = ({ const [diagCopied, setDiagCopied] = useState(false); const [diagCopyFailed, setDiagCopyFailed] = useState(false); const [isHovered, setIsHovered] = useState(false); + const [mathTick, setMathTick] = useState(0); + + useEffect(() => { + if (!contentHasMath(message.content || '')) return; + let cancelled = false; + ensureMathRenderer().then(() => { + if (!cancelled) setMathTick((tick) => tick + 1); + }); + return () => { + cancelled = true; + }; + }, [message.content]); const renderedHtml = useMemo( () => renderInlineMarkdown(message.content || '', { preserveNewlines: true }), - [message.content] + [message.content, mathTick] ); const handleCopyDiag = async () => { diff --git a/web/src/components/chat/MessageStream.tsx b/web/src/components/chat/MessageStream.tsx index 260cdc90..25af67dc 100644 --- a/web/src/components/chat/MessageStream.tsx +++ b/web/src/components/chat/MessageStream.tsx @@ -22,6 +22,8 @@ export interface MessageStreamProps { onQuote?: (message: ChatMessage) => void; onNavigateToAnchor?: (messageId: number) => void; selectedCategory?: MessageCategory | 'all'; + /** 会话切换时重置滚动基线,避免旧会话消息数挡住新会话自动滚底 */ + sessionId?: string; /** P3 / Step 5: 未读消息 ID 集合 */ unreadMessageIds?: number[]; onSaveToPrivate?: (message: ChatMessage) => void; @@ -43,6 +45,7 @@ export const MessageStream: React.FC = ({ onQuote, onNavigateToAnchor, selectedCategory = 'all', + sessionId, unreadMessageIds = [], onSaveToPrivate, onExportDocx, @@ -154,6 +157,11 @@ export const MessageStream: React.FC = ({ } }, [totalMessagesCount, handle]); + useEffect(() => { + prevMsgCountRef.current = 0; + isInitialMountRef.current = true; + }, [sessionId]); + return (
= ({ if (onExportDocx) { await onExportDocx(message); } else { + const { downloadDocxReply } = await import('../../features/replyExport/downloadDocx'); await downloadDocxReply(message.content || '', undefined, { createdAt: message.createdAt || message.timestamp, }); diff --git a/web/src/components/doc/BlockEditorModal.tsx b/web/src/components/doc/BlockEditorModal.tsx index 0d9c4ec3..f2249d12 100644 --- a/web/src/components/doc/BlockEditorModal.tsx +++ b/web/src/components/doc/BlockEditorModal.tsx @@ -22,6 +22,7 @@ import { ListOrdered, } from 'lucide-react'; import katex from 'katex'; +import 'katex/dist/katex.min.css'; import { renderInlineMarkdown } from '../../engine/renderMarkdown'; import { BLOCK_EDITOR_DIALOG_LABEL, BLOCK_EDITOR_SAVE_NAME } from '../../lib/blockEditorDialog'; diff --git a/web/src/components/doc/BlockRenderer.tsx b/web/src/components/doc/BlockRenderer.tsx index 4a55d77f..abac2e06 100644 --- a/web/src/components/doc/BlockRenderer.tsx +++ b/web/src/components/doc/BlockRenderer.tsx @@ -7,14 +7,19 @@ import { HeadingBlockView } from './blocks/HeadingBlockView'; import { ParagraphBlockView } from './blocks/ParagraphBlockView'; import { TableBlockView } from './blocks/TableBlockView'; import { CalloutBlockView } from './blocks/CalloutBlockView'; -import { MermaidBlockView } from './blocks/MermaidBlockView'; -import { MathBlockView } from './blocks/MathBlockView'; import { ListBlockView } from './blocks/ListBlockView'; +import { OnDemand } from '../lazy/OnDemand'; +import { contentHasMath, ensureMathRenderer } from '../../engine/renderMarkdown'; import { BlockContextMenu } from './BlockContextMenu'; import { canAddBlockComment } from '../../lib/commentPolicy'; import { documentBlockDomAttrs } from '../../lib/documentBlockDom'; import { Plus, ArrowUp, ArrowDown, Trash2, MessageSquare } from 'lucide-react'; +const loadMermaidBlock = () => + import('./blocks/MermaidBlockView').then((m) => ({ default: m.MermaidBlockView })); +const loadMathBlock = () => + import('./blocks/MathBlockView').then((m) => ({ default: m.MathBlockView })); + export interface BlockRendererProps { ast: DocumentAST; currentRole: UserRole; @@ -29,6 +34,15 @@ export interface BlockRendererProps { overlayHunks?: DiffHunk[]; } +function subtreeHasInlineMath(block: DocumentBlock): boolean { + if (block.type !== 'math' && contentHasMath(block.content || '')) return true; + const cells = block.properties?.tableData; + if (cells?.some((row) => row.some((cell) => contentHasMath(cell)))) return true; + const items = block.properties?.listItems; + if (items?.some((item) => contentHasMath(item))) return true; + return (block.children || []).some(subtreeHasInlineMath); +} + export const BlockRenderer: React.FC = ({ ast, currentRole, @@ -50,6 +64,20 @@ export const BlockRenderer: React.FC = ({ index: number; } | null>(null); + const [mathTick, setMathTick] = useState(0); + + useEffect(() => { + const hasMath = subtreeHasInlineMath(ast.rootBlock); + if (!hasMath) return; + let cancelled = false; + ensureMathRenderer().then(() => { + if (!cancelled) setMathTick((tick) => tick + 1); + }); + return () => { + cancelled = true; + }; + }, [ast]); + useEffect(() => { if (highlightedBlockId) { const el = document.getElementById(`block-${highlightedBlockId}`); @@ -75,9 +103,21 @@ export const BlockRenderer: React.FC = ({ case 'callout': return ; case 'mermaid': - return ; + return ( + + ); case 'math': - return ; + return ( + + ); case 'list': return ; case 'divider': @@ -90,6 +130,7 @@ export const BlockRenderer: React.FC = ({ return (
= ({ 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/doc/DocumentPane.test.ts b/web/src/components/doc/DocumentPane.test.ts new file mode 100644 index 00000000..10a5e695 --- /dev/null +++ b/web/src/components/doc/DocumentPane.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('active document pane lives outside the workbench shell', () => { + const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8'); + const pane = readFileSync(new URL('./DocumentPane.tsx', import.meta.url), 'utf8'); + assert.equal((app.match(/ + import('./preview/FilePreviewPanel').then((m) => ({ default: m.FilePreviewPanel })); +const loadBlockEditorModal = () => + import('./BlockEditorModal').then((m) => ({ default: m.BlockEditorModal })); + +export interface DocumentPaneProps { + isCurrentProjectExited: boolean; + activeDocId: string; + deliverables: DeliverableItem[]; + visibleDeliverables: DeliverableItem[]; + activeCategory: DeliverableCategory; + setActiveCategory: Dispatch>; + handleSelectDoc: (id: string) => void; + deckDrawerOpen: boolean; + setDeckDrawerOpen: Dispatch>; + handleExportDocx: () => void | Promise; + handleExportXlsx: () => void | Promise; + handleExportMarkdown: () => void; + docRevOverlayMode: OverlayMode; + setDocRevOverlayMode: Dispatch>; + setDocRevOverlay: Dispatch>; + docRevRefreshKey: number; + docRevConflict: string | null; + docRevOverlay: DiffResult | null; + currentUser: UserProfile; + handleShareToProject: (docId: string) => Promise; + handleRevokeShare: (docId: string) => Promise; + handleDeleteDoc: (docId: string) => Promise; + setTargetDocForVisibility: Dispatch>; + setDocVisibilityModalOpen: Dispatch>; + docLoadErrors: Record; + docLoadErrorInfo: Record; + retryControlledDocLoad: () => void; + docAST: DocumentAST; + currentDocRole: UserRole; + openBlockEditor: (block: DocumentBlock) => void; + handleInsertBlock: (index: number, position: 'above' | 'below') => void; + handleMoveBlock: (index: number, direction: 'up' | 'down') => void; + handleDeleteBlock: (index: number) => void; + setCommentingBlock: Dispatch>; + handleSendMessage: (text: string) => void | Promise; + highlightedBlockId: string | null; + canOverrideVisibility: boolean; + handleClaim: () => void | Promise; + handleSubmitReview: () => void | Promise; + handlePassReview: () => void | Promise; + handleRejectReview: (reason: string) => void | Promise; + handleApprove: () => void | Promise; + handleRejectApprove: (reason: string) => void | Promise; + editorSession: BlockEditorSession | null; + handleSaveBlockEdit: (updated: DocumentBlock) => void | Promise; + closeBlockEditor: () => void; + commentingBlock: DocumentBlock | null; + handleAddComment: (comment: Omit) => void; +} + +export function DocumentPane(props: DocumentPaneProps) { + const { + isCurrentProjectExited, + activeDocId, + deliverables, + visibleDeliverables, + activeCategory, + setActiveCategory, + handleSelectDoc, + deckDrawerOpen, + setDeckDrawerOpen, + handleExportDocx, + handleExportXlsx, + handleExportMarkdown, + docRevOverlayMode, + setDocRevOverlayMode, + setDocRevOverlay, + docRevRefreshKey, + docRevConflict, + docRevOverlay, + currentUser, + handleShareToProject, + handleRevokeShare, + handleDeleteDoc, + setTargetDocForVisibility, + setDocVisibilityModalOpen, + docLoadErrors, + docLoadErrorInfo, + retryControlledDocLoad, + docAST, + currentDocRole, + openBlockEditor, + handleInsertBlock, + handleMoveBlock, + handleDeleteBlock, + setCommentingBlock, + handleSendMessage, + highlightedBlockId, + canOverrideVisibility, + handleClaim, + handleSubmitReview, + handlePassReview, + handleRejectReview, + handleApprove, + handleRejectApprove, + editorSession, + handleSaveBlockEdit, + closeBlockEditor, + commentingBlock, + handleAddComment, + } = props; + + return ( +isCurrentProjectExited ? ( +
+
+ +
+

+ 受控文档已执行合规脱密隔离 +

+

+ 您已退出该研发项目。依据医疗器械质量管理体系 (GMP/ISO 13485) 及商业保密规程,本项目技术规格书、设计输入草案与验证交付物已对您执行访问切断。历史聊天记录仅供审计留痕查阅。 +

+
+) : ( +
d.docId === activeDocId)?.title || + visibleDeliverables.find((d) => d.docId === activeDocId)?.title || + '' + } +> + {/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */} + setDeckDrawerOpen(!deckDrawerOpen)} + onExportDocx={handleExportDocx} + onExportXlsx={handleExportXlsx} + onExportMarkdown={handleExportMarkdown} + /> + {(() => { + const revDoc = + deliverables.find((d) => d.docId === activeDocId) || + visibleDeliverables.find((d) => d.docId === activeDocId); + const mode = revisionToolbarMountMode( + revDoc ?? { docId: activeDocId, isTemplateOnly: true }, + ); + if (mode === 'toolbar' && revDoc) { + return ( + + ); + } + const hint = documentRevisionInactiveHint( + revDoc ?? { docId: activeDocId, isTemplateOnly: true }, + ); + if (!hint) return null; + return ( +
+ {hint} +
+ ); + })()} + {docRevConflict && ( +
+ {docRevConflict} + +
+ )} + {docRevOverlayMode === 'manual' && docRevOverlay && docRevOverlay.hunks?.length > 0 && ( +
+ 人工差异 {docRevOverlay.summary.modifies + docRevOverlay.summary.inserts + docRevOverlay.summary.deletes} 处 +
+ )} + + {/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */} + setDeckDrawerOpen(false)} + deliverables={visibleDeliverables} + activeDocId={activeDocId} + currentUserId={currentUser.id} + onSelectDoc={handleSelectDoc} + onShareToProject={handleShareToProject} + onRevokeShare={handleRevokeShare} + onDeleteDoc={handleDeleteDoc} + onOpenDocVisibility={(doc) => { + setTargetDocForVisibility(doc); + setDocVisibilityModalOpen(true); + }} + /> + + {/* 核心文档内容渲染区:用户上传文档使用高保真文件预览器,工作流及对话入库文档使用结构化 BlockRenderer */} + {(() => { + const activeDeliverable = deliverables.find((d) => d.docId === activeDocId); + const isUploadedFile = Boolean( + activeDeliverable && + (activeDeliverable.sourceType === 'uploaded_file' || + Boolean(activeDeliverable.fileMeta) || + activeDeliverable.docId.startsWith('doc-upload-') || + Boolean(activeDeliverable.title?.match(/\.(docx?|pdf|xlsx?|csv|pptx?|png|jpe?g|svg|webp|txt|md|json)$/i)) || + (activeDeliverable.taskKey === 'user_requirements' && + !activeDeliverable.isFixedWorkflow && + (activeDeliverable.category === 'private' || activeDeliverable.category === 'shared'))) + ); + if (isUploadedFile && activeDeliverable) { + return ( + { + setTargetDocForVisibility(activeDeliverable); + setDocVisibilityModalOpen(true); + }, + }} + /> + ); + } + return ( + <> + {docLoadErrors[activeDocId] ? ( +
+ + {docLoadErrorInfo[activeDocId] + ? [ + docLoadErrorInfo[activeDocId].code, + docLoadErrorInfo[activeDocId].message, + docLoadErrorInfo[activeDocId].correlationId + ? `corr=${docLoadErrorInfo[activeDocId].correlationId}` + : '', + ] + .filter(Boolean) + .join(' ') + : '受控文档正文加载失败,请检查网络后重试。'} + + +
+ ) : null} + { + handleSendMessage( + `针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。` + ); + }} + highlightedBlockId={highlightedBlockId} + /> + + ); + })()} + + {/* 底部流转动作条 (受控流程文档使用 DocGovActionBar,自由私密/共享文档使用 FreeformDocumentActionBar) */} + {docAST.governanceMode === 'freeform' || docAST.space === 'my_private' || docAST.space === 'project_shared' || docAST.category === 'private' || docAST.category === 'shared' ? ( + copyRichReplyToClipboard(blockASTToMarkdown(docAST))} + onCopyMarkdown={() => copyMarkdownReply(blockASTToMarkdown(docAST))} + onDownloadMarkdown={() => downloadMarkdownReply(blockASTToMarkdown(docAST), docAST.title)} + onDownloadDocx={handleExportDocx} + /> + ) : ( + { + const currentDoc = deliverables.find((d) => d.docId === activeDocId) || null; + setTargetDocForVisibility(currentDoc); + setDocVisibilityModalOpen(true); + }} + onClaim={handleClaim} + onSubmitReview={handleSubmitReview} + onPassReview={handlePassReview} + onRejectReview={handleRejectReview} + onApprove={handleApprove} + onRejectApprove={handleRejectApprove} + onShareToProject={handleShareToProject} + onRevokeShare={handleRevokeShare} + /> + )} + + {/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */} + {editorSession && ( + + )} + + {/* 审核批注侧栏 */} + {commentingBlock && ( + setCommentingBlock(null)} + /> + )} +
+) + + ); +} diff --git a/web/src/components/doc/blocks/MathBlockView.tsx b/web/src/components/doc/blocks/MathBlockView.tsx index 776c8017..dd87c9ca 100644 --- a/web/src/components/doc/blocks/MathBlockView.tsx +++ b/web/src/components/doc/blocks/MathBlockView.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { DocumentBlock } from '../../../types/block'; import katex from 'katex'; +import 'katex/dist/katex.min.css'; import { Calculator } from 'lucide-react'; interface MathBlockViewProps { diff --git a/web/src/components/doc/preview/FilePreviewPanel.tsx b/web/src/components/doc/preview/FilePreviewPanel.tsx index 71d4b914..17a50dd5 100644 --- a/web/src/components/doc/preview/FilePreviewPanel.tsx +++ b/web/src/components/doc/preview/FilePreviewPanel.tsx @@ -15,12 +15,14 @@ import { ShieldCheck, } from 'lucide-react'; import { DeliverableItem } from '../../../types/workbench'; -import { DocxViewer } from './renderers/DocxViewer'; -import { PdfViewer } from './renderers/PdfViewer'; -import { ExcelViewer } from './renderers/ExcelViewer'; import { ImageViewer } from './renderers/ImageViewer'; +import { PdfViewer } from './renderers/PdfViewer'; import { PptViewer } from './renderers/PptViewer'; import { TextViewer } from './renderers/TextViewer'; +import { OnDemand } from '../../lazy/OnDemand'; + +const loadDocxViewer = () => import('./renderers/DocxViewer').then((m) => ({ default: m.DocxViewer })); +const loadExcelViewer = () => import('./renderers/ExcelViewer').then((m) => ({ default: m.ExcelViewer })); interface FilePreviewPanelProps { deliverable: DeliverableItem; @@ -273,11 +275,23 @@ export const FilePreviewPanel: React.FC = ({ } switch (fileType) { case 'docx': - return ; + return ( + + ); case 'pdf': return ; case 'excel': - return ; + return ( + + ); case 'image': return ; case 'ppt': diff --git a/web/src/components/doc/preview/renderers/ExcelViewer.tsx b/web/src/components/doc/preview/renderers/ExcelViewer.tsx index 272d45ff..70db8a0c 100644 --- a/web/src/components/doc/preview/renderers/ExcelViewer.tsx +++ b/web/src/components/doc/preview/renderers/ExcelViewer.tsx @@ -12,8 +12,9 @@ export const ExcelViewer: React.FC = ({ fileRaw, fileUrl }) => const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [sheetNames, setSheetNames] = useState([]); - const [activeSheetIndex, setActiveSheetIndex] = useState(0); const [sheetData, setSheetData] = useState>({}); + const [activeSheetIndex, setActiveSheetIndex] = useState(0); + const [truncatedSheets, setTruncatedSheets] = useState>({}); useEffect(() => { let isMounted = true; @@ -40,6 +41,8 @@ export const ExcelViewer: React.FC = ({ fileRaw, fileUrl }) => } const parsedSheets: Record = {}; + const truncated: Record = {}; + const previewRowLimit = 1000; for (const name of workbook.SheetNames) { const ws = workbook.Sheets[name]; if (ws) { @@ -48,7 +51,8 @@ export const ExcelViewer: React.FC = ({ fileRaw, fileUrl }) => defval: '', blankrows: false, }); - parsedSheets[name] = rawRows; + truncated[name] = rawRows.length > previewRowLimit; + parsedSheets[name] = rawRows.slice(0, previewRowLimit); } else { parsedSheets[name] = []; } @@ -57,6 +61,7 @@ export const ExcelViewer: React.FC = ({ fileRaw, fileUrl }) => if (isMounted) { setSheetNames(workbook.SheetNames); setSheetData(parsedSheets); + setTruncatedSheets(truncated); setActiveSheetIndex(0); setLoading(false); } @@ -173,6 +178,9 @@ export const ExcelViewer: React.FC = ({ fileRaw, fileUrl }) => 工作表:{currentSheetName} · {rows.length} 行 × {maxCols} 列 + {truncatedSheets[currentSheetName] && ( + · 仅预览前 1000 行 + )}
共 {sheetNames.length} 个工作表 diff --git a/web/src/components/layout/WorkbenchView.test.ts b/web/src/components/layout/WorkbenchView.test.ts new file mode 100644 index 00000000..745325e9 --- /dev/null +++ b/web/src/components/layout/WorkbenchView.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('workbench shell lives outside App', () => { + const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8'); + const view = readFileSync(new URL('./WorkbenchView.tsx', import.meta.url), 'utf8'); + assert.equal((app.match(/ void; + setActiveDocId: (id: string) => void; + setActiveCategory: (category: DeliverableCategory) => void; + setCreateProjectModalOpen: (open: boolean) => void; + canCreateProject: boolean; + currentUser: UserProfile; + currentDocRole: UserRole; + setRightsModalOpen: Dispatch>; + setUserSettingsOpen: Dispatch>; + setView: (view: 'workbench' | 'admin') => void; + setProjectMembersModalOpen: Dispatch>; + currentWfState: WorkbenchWorkflowState; + workflowGovernanceEnabled: boolean; + setProjectSettingsOpen: Dispatch>; + setProjectWorkflowsState: Dispatch>>; + workflowBanner: ReactNode; + chatContent: ReactNode; + docContent: ReactNode; + rightsModalOpen: boolean; + visibleDeliverables: DeliverableItem[]; + activeDocId: string; + canAssignLead: boolean; + handleSelectDoc: (id: string) => void; + handleRoleChangeForDoc: (docId: string, role: DocPermissionRole) => void; + session: LoginResult | null; + setSession: Dispatch>; + handleLogout: () => void; + userSettingsOpen: boolean; + roles: RoleDefinition[]; + setAllUsers: Dispatch>; + setCurrentUser: Dispatch>; + docVisibilityModalOpen: boolean; + setDocVisibilityModalOpen: Dispatch>; + targetDocForVisibility: DeliverableItem | null; + handleSaveDocVisibilityRule: (docId: string, rule: DocVisibilityRule | undefined) => void; + createProjectModalOpen: boolean; + createProjectSubmitting: boolean; + setCreateProjectError: Dispatch>; + handleCreateProjectConfirm: (data: CreateProjectConfirmData) => void | Promise; + createProjectError: string | null; + createProjectSubmitPhase: CreateProjectSubmitPhase | null; + allUsers: UserProfile[]; + projectMembersModalOpen: boolean; + currentProject: ProjectInfo | undefined; + canManageProjectMembers: boolean; + projectMembersError: string | null; + handleAddProjectMember: (member: Omit) => void | Promise; + handleUpdateProjectMemberRole: (userId: number, role: ProjectMemberRole) => void | Promise; + handleRemoveProjectMember: (userId: number) => void | Promise; + handleRestoreProjectMember: (userId: number) => void | Promise; + projectSettingsOpen: boolean; + currentActiveSession: ProjectSession | undefined; +} + +export function WorkbenchView({ + visibleSessions, + activeSessionId, + sessions, + deliverables, + currentProjectId, + setActiveSessionId, + setActiveDocId, + setActiveCategory, + setCreateProjectModalOpen, + canCreateProject, + currentUser, + currentDocRole, + setRightsModalOpen, + setUserSettingsOpen, + setView, + setProjectMembersModalOpen, + currentWfState, + workflowGovernanceEnabled, + setProjectSettingsOpen, + setProjectWorkflowsState, + workflowBanner, + chatContent, + docContent, + rightsModalOpen, + visibleDeliverables, + activeDocId, + canAssignLead, + handleSelectDoc, + handleRoleChangeForDoc, + session, + setSession, + handleLogout, + userSettingsOpen, + roles, + setAllUsers, + setCurrentUser, + docVisibilityModalOpen, + setDocVisibilityModalOpen, + targetDocForVisibility, + handleSaveDocVisibilityRule, + createProjectModalOpen, + createProjectSubmitting, + setCreateProjectError, + handleCreateProjectConfirm, + createProjectError, + createProjectSubmitPhase, + allUsers, + projectMembersModalOpen, + currentProject, + canManageProjectMembers, + projectMembersError, + handleAddProjectMember, + handleUpdateProjectMemberRole, + handleRemoveProjectMember, + handleRestoreProjectMember, + projectSettingsOpen, + currentActiveSession, +}: WorkbenchViewProps) { + return ( + <> + { + if (window.location.hash) { + history.replaceState(null, '', window.location.pathname + window.location.search); + } + setActiveSessionId(sessId); + const targetSess = sessions.find((s) => s.sessionId === sessId); + if (targetSess && targetSess.projectId !== currentProjectId) { + const targetProjDocs = deliverables.filter((d) => d.projectId === targetSess.projectId); + if (targetProjDocs.length > 0) { + setActiveDocId(targetProjDocs[0].docId); + setActiveCategory(targetProjDocs[0].category); + } + } + }} + onNewSession={() => setCreateProjectModalOpen(true)} + canCreateProject={canCreateProject} + currentUser={currentUser} + currentRole={currentDocRole} + currentUserName={currentUser.name} + onChangeRole={(_, __) => {}} + onOpenRightsModal={() => setRightsModalOpen(true)} + onOpenUserSettings={() => setUserSettingsOpen(true)} + onOpenAdminSettings={() => setView('admin')} + onOpenProjectMembers={() => setProjectMembersModalOpen(true)} + activeWorkflowKey={currentWfState.activeKey} + workflowVersion={currentWfState.version} + lifecycleStatus={currentWfState.lifecycleStatus} + isCreator={currentWfState.creatorId === currentUser.id} + governanceEnabled={workflowGovernanceEnabled} + onOpenProjectSettings={() => setProjectSettingsOpen(true)} + onWorkflowChanged={(newKey, newVer) => { + setProjectWorkflowsState((prev) => ({ + ...prev, + [currentProjectId]: { + ...currentWfState, + activeKey: newKey, + version: newVer, + lifecycleStatus: 'active', + }, + })); + }} + workflowBanner={workflowBanner} + chatContent={chatContent} + docContent={docContent} + /> + setRightsModalOpen(false)} + currentUser={currentUser} + currentProjectTitle={sessions.find((s) => s.sessionId === activeSessionId)?.title || '当前项目'} + deliverables={visibleDeliverables} + activeDocId={activeDocId} + canAssignLead={canAssignLead} + onSelectDoc={handleSelectDoc} + onRoleChangeForDoc={handleRoleChangeForDoc} + /> + { + if (!session) return; + const updated = { ...session, mustChangePassword: false }; + setSession(updated); + authClient.saveSession(updated); + }} + onLogout={handleLogout} + /> + setUserSettingsOpen(false)} + currentUser={currentUser} + roles={roles} + onUpdateProfile={(updatedProfile) => { + setAllUsers((prev) => + prev.map((u) => (u.id === currentUser.id ? { ...u, ...updatedProfile } : u)) + ); + setCurrentUser((prev) => ({ ...prev, ...updatedProfile })); + }} + onLogout={handleLogout} + /> + setDocVisibilityModalOpen(false)} + doc={targetDocForVisibility} + roles={roles} + currentUserName={currentUser.name} + onSaveRule={handleSaveDocVisibilityRule} + /> + { + if (createProjectSubmitting) return; + setCreateProjectModalOpen(false); + setCreateProjectError(null); + }} + onSubmit={handleCreateProjectConfirm} + submitError={createProjectError} + submitting={createProjectSubmitting} + submitPhase={createProjectSubmitPhase} + currentUser={currentUser} + availableUsers={allUsers} + /> + setProjectMembersModalOpen(false)} + project={currentProject} + currentUser={currentUser} + availableUsers={allUsers} + canManage={canManageProjectMembers} + membersError={projectMembersError} + onAddMember={handleAddProjectMember} + onUpdateMemberRole={handleUpdateProjectMemberRole} + onRemoveMember={handleRemoveProjectMember} + onRestoreMember={handleRestoreProjectMember} + /> + m.status === 'active') + .map((m) => ({ + userId: m.userId, + name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`, + }))} + onClose={() => setProjectSettingsOpen(false)} + onActivated={(activeKey) => { + setProjectWorkflowsState((prev) => ({ + ...prev, + [currentProjectId]: { + ...currentWfState, + activeKey, + lifecycleStatus: 'active', + }, + })); + }} + /> + + ); +} diff --git a/web/src/components/lazy/OnDemand.tsx b/web/src/components/lazy/OnDemand.tsx new file mode 100644 index 00000000..81b36bf8 --- /dev/null +++ b/web/src/components/lazy/OnDemand.tsx @@ -0,0 +1,52 @@ +import { Component, lazy, Suspense, useMemo, useState, type ComponentType, type ReactNode } from 'react'; + +type BoundaryProps = { + label: string; + onRetry: () => void; + children: ReactNode; +}; + +type BoundaryState = { error: Error | null }; + +/** Catches a failed dynamic import. Retry builds a new lazy() so a rejected chunk is not sticky. */ +export class LazyBoundary extends Component { + state: BoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): BoundaryState { + return { error }; + } + + render() { + if (this.state.error) { + return ( +
+

+ {this.props.label}加载失败,可重试。这次失败不会提交文档或聊天动作。 +

+ +
+ ); + } + return ( + {this.props.label}加载中…
}> + {this.props.children} + + ); + } +} + +export function OnDemand

(props: { + label: string; + load: () => Promise<{ default: ComponentType

}>; + componentProps: P; +}) { + const [attempt, setAttempt] = useState(0); + const Comp = useMemo(() => lazy(props.load), [attempt, props.load]); + return ( + setAttempt((n) => n + 1)}> + + + ); +} 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..bdcdc98e 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { X, ChevronRight, ChevronLeft, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'; +import { X, ChevronRight, ChevronDown, ChevronLeft, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'; import { fetchProjectSettings, fetchDefaultWorkflowPlan, @@ -29,14 +29,19 @@ import { } from '../../lib/projectSettingsMode'; import { canToggleHumanApproval, - classifyClientDuty, clientDutyIssues, formatDutyIssues, locateDutyIssue, needsThreeMembersForDuties, + resolveClientDocTaskDuty, + toggleAuthorOnly, toggleHumanApproval, + stageStartsCollapsed, + applyStageHumanApproval, + stageApprovalKeys, } from '../../lib/projectSettingsDuties'; import { ACTIVATION_UNMANAGED_FREEZE_HINT } from '../../lib/blockedGovernanceCopy'; +import { WorkflowPrerequisiteBanner } from '../workflow/WorkflowPrerequisiteBanner'; import type { ProjectSettingsIssue } from '../../api/projectSettingsError'; interface ProjectMemberOption { @@ -68,6 +73,7 @@ export const ProjectSettingsModal: React.FC = ({ const [error, setError] = useState(null); const [settings, setSettings] = useState(null); const [plan, setPlan] = useState(null); + const [openStages, setOpenStages] = useState>({}); const [preview, setPreview] = useState(null); const [selectedKey, setSelectedKey] = useState(''); const [terminateReason, setTerminateReason] = useState(''); @@ -147,6 +153,24 @@ export const ProjectSettingsModal: React.FC = ({ setPlan({ ...plan, stages }); }; + const applyStageApproval = (stageIdx: number, managed: boolean) => { + if (!plan) return; + const stage = plan.stages[stageIdx]; + if (!stage) return; + const keys = stageApprovalKeys(stage.doc_tasks, showEditor); + const stages = plan.stages.map((item, index) => + index === stageIdx + ? { ...item, doc_tasks: applyStageHumanApproval(item.doc_tasks, managed, showEditor) } + : item, + ); + setPlan({ ...plan, stages }); + setRequireApprovalKeys((current) => { + if (managed) return Array.from(new Set([...current, ...keys])); + const drop = new Set(keys); + return current.filter((key) => !drop.has(key)); + }); + }; + const handleSavePlan = async () => { if (!plan || !settings || !settings.can_edit_plan) return; @@ -471,11 +495,13 @@ export const ProjectSettingsModal: React.FC = ({ 暂无已发布工作流,请先在系统设置中发布工作流定义。
)} +
)} {showEditor && chrome.showDutySelects && step === 'configure' && plan && (
+

允许裁剪的文档可选择是否纳入人工审批。关闭后仍会生成并进入版本账本,只是不走三权。

@@ -498,11 +524,49 @@ export const ProjectSettingsModal: React.FC = ({ 请先保存一次规划以从工作流定义加载阶段结构,或返回上一步重新选择。
)} - {plan.stages.map((st, si) => ( + {plan.stages.map((st, si) => { + const expanded = openStages[st.stage_key] ?? !stageStartsCollapsed(st.doc_tasks.length, si); + return (
-
{st.stage_name || st.stage_key}
- {st.doc_tasks.map((dt, ti) => { - const managed = classifyClientDuty(dt.duties) !== 'none' || requireApprovalKeys.includes(dt.task_key); + + {expanded && showEditor && ( +
+ + +
+ )} + {expanded && st.doc_tasks.map((dt, ti) => { + 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 +594,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}
@@ -563,7 +684,8 @@ export const ProjectSettingsModal: React.FC = ({ ); })}
- ))} + ); + })}
)} @@ -587,6 +709,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/ProjectWorkflowSwitcher.tsx b/web/src/components/workflow/ProjectWorkflowSwitcher.tsx index 2f0f5356..b204a32c 100644 --- a/web/src/components/workflow/ProjectWorkflowSwitcher.tsx +++ b/web/src/components/workflow/ProjectWorkflowSwitcher.tsx @@ -18,6 +18,7 @@ const FALLBACK_OPTIONS = [ { key: 'main', label: '商务对接流程', icon: '⚡' }, { key: 'product_manager', label: '智能产品经理流程', icon: '📋' }, { key: 'test_manager', label: '智能测试工程师流程', icon: '🧪' }, + { key: 'rdev', label: '研发设计控制流程', icon: '📐' }, ]; export const ProjectWorkflowSwitcher: React.FC = ({ diff --git a/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx b/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx index ffe842ef..77fab940 100644 --- a/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx +++ b/web/src/components/workflow/WorkflowPrerequisiteBanner.tsx @@ -1,63 +1,26 @@ -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; - } +import { AlertCircle } from 'lucide-react'; +import { workflowPrerequisiteMessage } from '../../lib/workflowPrerequisite'; +export function WorkflowPrerequisiteBanner({ workflowKey }: { workflowKey: string }) { + const message = workflowPrerequisiteMessage(workflowKey); + if (!message) return null; return (
-
- - - 前置依赖未就绪: - 智能测试经理工作流强依赖同项目已批准的《系统需求规格书 (RD-SR)》。请先由产品经理完成编制并经三级审批批准。 - -
- + + {message}
); -}; +} 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/initFanout.test.ts b/web/src/data/initFanout.test.ts new file mode 100644 index 00000000..a9727c6e --- /dev/null +++ b/web/src/data/initFanout.test.ts @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ProjectInfo } from '../types/workbench'; +import { loginMemberSyncIds, RequestEpoch, seedViewerMembership, shareInflight } from './initFanout'; + +function project(id: number, memberIds: number[] = []): ProjectInfo { + return { + id, + title: `p${id}`, + deviceType: '', + description: '', + creatorId: 1, + creatorName: '创建者', + createdAt: '2026-09-19', + members: memberIds.map((userId) => ({ + userId, + userName: `u${userId}`, + role: 'creator' as const, + status: 'active' as const, + joinedAt: '2026-09-19', + })), + }; +} + +test('login member sync stays one request for 1, 30 and 100 projects', () => { + for (const count of [1, 30, 100]) { + const ids = Array.from({ length: count }, (_, i) => i + 1); + assert.deepEqual(loginMemberSyncIds(ids, 1), [1]); + } + assert.deepEqual(loginMemberSyncIds([2, 3], 9), []); +}); + +test('viewer seed keeps session visibility without a member-list call', () => { + const seeded = seedViewerMembership(project(8, [1]), 42, '成员甲'); + assert.deepEqual( + seeded.members.map((member) => member.userId), + [1, 42] + ); + assert.equal(seeded.members[1].status, 'active'); + assert.equal(seedViewerMembership(seeded, 42, '成员甲'), seeded); +}); + +test('later session response cannot overwrite a newer user', () => { + const epoch = new RequestEpoch(); + const first = epoch.next(); + const second = epoch.next(); + assert.equal(epoch.isCurrent(first), false); + assert.equal(epoch.isCurrent(second), true); +}); + +test('in-flight member sync collapses to one call', async () => { + const inflight = new Map>(); + let calls = 0; + const run = () => + shareInflight(inflight, 7, async () => { + calls += 1; + return 7; + }); + const [a, b] = await Promise.all([run(), run()]); + assert.equal(a, 7); + assert.equal(b, 7); + assert.equal(calls, 1); +}); diff --git a/web/src/data/initFanout.ts b/web/src/data/initFanout.ts new file mode 100644 index 00000000..4afca07f --- /dev/null +++ b/web/src/data/initFanout.ts @@ -0,0 +1,54 @@ +import { ProjectInfo, ProjectMember } from '../types/workbench'; + +// 登录只同步当前项目的完整名册。项目列表已经按当前用户过滤, +// 其他项目先种入查看者本人,完整成员等到打开面板再取。 +export function loginMemberSyncIds(projectIds: number[], currentProjectId: number): number[] { + if (currentProjectId > 0 && projectIds.includes(currentProjectId)) { + return [currentProjectId]; + } + return []; +} + +export function seedViewerMembership( + project: ProjectInfo, + viewerId: number, + viewerName: string +): ProjectInfo { + if (!viewerId || viewerId <= 0) return project; + if (project.members.some((member) => member.userId === viewerId)) return project; + const seeded: ProjectMember = { + userId: viewerId, + userName: viewerName || '当前用户', + role: 'member', + status: 'active', + joinedAt: project.createdAt, + }; + return { ...project, members: [...project.members, seeded] }; +} + +export class RequestEpoch { + private current = 0; + + next(): number { + this.current += 1; + return this.current; + } + + isCurrent(token: number): boolean { + return token === this.current; + } +} + +export function shareInflight( + inflight: Map>, + key: number, + run: () => Promise +): Promise { + const existing = inflight.get(key); + if (existing) return existing; + const pending = run().finally(() => { + if (inflight.get(key) === pending) inflight.delete(key); + }); + inflight.set(key, pending); + return pending; +} diff --git a/web/src/data/storage.ts b/web/src/data/storage.ts index 3759020b..32a9ef85 100644 --- a/web/src/data/storage.ts +++ b/web/src/data/storage.ts @@ -13,6 +13,17 @@ import { enrichMessagesWithCategories } from '../engine/categoryHelper'; // v6:修正 NeuroPulse 驳回追问误挂「疲劳寿命」旧答复;对话 LLM 每轮独立工作区 const STORAGE_PREFIX = 'smdf_agent_workbench_v6_'; +// CACHE_SCHEMA 是窗口缓存 JSON 的载荷版本。缺字段或版本不符时丢弃,避免旧结构被当成有效缓存。 +export const CACHE_SCHEMA = 1; + +type VersionedCache = { schema: number; data: T }; + +function isVersionedCache(value: unknown): value is VersionedCache { + if (!value || typeof value !== 'object') return false; + const row = value as { schema?: unknown; data?: unknown }; + return typeof row.schema === 'number' && 'data' in row; +} + export const STORAGE_KEYS = { SESSIONS: `${STORAGE_PREFIX}sessions`, ACTIVE_SESSION: `${STORAGE_PREFIX}active_session`, @@ -137,11 +148,6 @@ export function loadMessages(): ChatMessage[] { return enrichMessagesWithCategories(result); } -/** @deprecated 全量 MESSAGES 持久化已废弃,请使用 saveSessionWindowCache */ -export function saveMessages(_messages: ChatMessage[]): void { - // no-op: 权威历史来自服务端窗口接口 -} - // ================================================================================== // P0: 会话窗口 LRU 缓存与输入草稿持久化 (替代原全量 MESSAGES 持久化) // ================================================================================== @@ -161,14 +167,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); } }); } @@ -176,12 +182,15 @@ function updateWindowLRU(sessionId: string): void { } export function loadSessionWindowCache(sessionId: string, userId?: number): ChatDayBucket[] | null { - return safeGetItem(getWindowCacheKey(sessionId, userId), null); + const raw = safeGetItem(getWindowCacheKey(sessionId, userId), null); + if (!isVersionedCache(raw) || raw.schema !== CACHE_SCHEMA) return null; + return raw.data; } export function saveSessionWindowCache(sessionId: string, buckets: ChatDayBucket[], userId?: number): void { - safeSetItem(getWindowCacheKey(sessionId, userId), buckets); - updateWindowLRU(sessionId); + const cacheKey = getWindowCacheKey(sessionId, userId); + safeSetItem(cacheKey, { schema: CACHE_SCHEMA, data: 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..3d9ca3da --- /dev/null +++ b/web/src/data/storage.windowCache.test.ts @@ -0,0 +1,111 @@ +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: 无 schema 的旧载荷不当作有效缓存', () => { + const { store, restore } = installMemoryLocalStorage(); + try { + store.set('smdf_chat_window_u7_sess-old', JSON.stringify([{ dayKey: 'stale' }])); + assert.equal(loadSessionWindowCache('sess-old', 7), 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/katexRender.ts b/web/src/engine/katexRender.ts new file mode 100644 index 00000000..9d9093b8 --- /dev/null +++ b/web/src/engine/katexRender.ts @@ -0,0 +1,9 @@ +import katex from 'katex'; + +/** 仅在出现公式时由 renderMarkdown 动态加载,避免进入登录入口包。 */ +export function renderKatexHtml(source: string, displayMode: boolean): string { + return katex.renderToString(source, { + displayMode, + throwOnError: false, + }); +} 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/engine/renderMarkdown.test.ts b/web/src/engine/renderMarkdown.test.ts index 267029d2..c5cb721a 100644 --- a/web/src/engine/renderMarkdown.test.ts +++ b/web/src/engine/renderMarkdown.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { renderInlineMarkdown } from './renderMarkdown'; +import { contentHasMath, ensureMathRenderer, renderInlineMarkdown } from './renderMarkdown'; import { sanitizeMermaidCode } from '../components/doc/blocks/MermaidBlockView'; test('renderInlineMarkdown: renders bold, italic, strikethrough', () => { @@ -17,6 +17,10 @@ test('renderInlineMarkdown: escapes raw HTML tags to prevent XSS', () => { assert.ok(!scriptHtml.includes('alert(1)'); + assert.ok(!forged.includes(''); assert.ok(!/]/i.test(imgHtml), 'must not emit executable img tag'); assert.ok(imgHtml.includes('<img')); @@ -67,6 +71,16 @@ test('renderInlineMarkdown: renders headings lists and tables for chat replies', assert.ok(html.includes('结论')); }); +test('renderInlineMarkdown loads katex only after a formula is seen', async () => { + assert.equal(contentHasMath('报价 12 元'), false); + const pending = renderInlineMarkdown('能量 $E=mc^2$'); + assert.match(pending, /math-pending/); + assert.equal(pending.includes('class="katex"'), false); + await ensureMathRenderer(); + const html = renderInlineMarkdown('能量 $E=mc^2$'); + assert.match(html, /class="katex"/); +}); + test('sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators', () => { const rawMermaid = `graph LR LaserSource[NIR近红外与白光LED复用光源] --> FPGA[FPGA双路色彩配准与超低延迟融合引擎] diff --git a/web/src/engine/renderMarkdown.ts b/web/src/engine/renderMarkdown.ts index 47c7e705..9b9a2a2d 100644 --- a/web/src/engine/renderMarkdown.ts +++ b/web/src/engine/renderMarkdown.ts @@ -1,9 +1,47 @@ -import katex from 'katex'; - export interface RenderMarkdownOptions { preserveNewlines?: boolean; } +type MathRenderer = (source: string, displayMode: boolean) => string; + +let mathRenderer: MathRenderer | null = null; +let mathRendererLoading: Promise | null = null; + +export function contentHasMath(content: string): boolean { + return /\$\$[\s\S]*?\$\$|\$[^$\n]+?\$/.test(content); +} + +/** 首次遇到公式才加载 KaTeX 及其样式。没有公式的会话不请求这个包。 */ +export function ensureMathRenderer(): Promise { + if (mathRenderer) return Promise.resolve(); + if (!mathRendererLoading) { + mathRendererLoading = (async () => { + if (typeof document !== 'undefined') { + await import('katex/dist/katex.min.css'); + } + const mod = await import('./katexRender'); + mathRenderer = mod.renderKatexHtml; + })(); + } + return mathRendererLoading; +} + +function renderMathSource(math: string, displayMode: boolean): string { + const trimmed = math.trim(); + if (mathRenderer) { + try { + return mathRenderer(trimmed, displayMode); + } catch { + // 落到转义文本,避免把未渲染的源码再送进 Markdown 规则。 + } + } + const escaped = escapeHtml(trimmed); + if (displayMode) { + return `$$${escaped}$$`; + } + return `$${escaped}$`; +} + /** * 健壮的行内 Markdown、LaTeX 公式与有序/无序列表渲染引擎 * 支持: @@ -28,45 +66,27 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt // 1. 占位保护预处理:优先解析并提取数学公式,避免公式内部的 * 或 _ 被 Markdown 解析器误伤 // 注意:占位符严禁使用包含 _ 或 * 等 Markdown 特殊字符,采用纯大写字母标识符 const mathPlaceholders: string[] = []; - const placeholderPrefix = 'XMATHTOKENX'; + // 每次渲染换盐,避免用户文本伪造 XMATHTOKENX0XENDX 在 escapeHtml 之后被还原成 HTML + const tokenSalt = Math.random().toString(36).slice(2, 10).toUpperCase(); + const placeholderPrefix = `XMATHTOKENX${tokenSalt}X`; // 1.1 块级独立公式 $$ ... $$ text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => { - try { - const rendered = katex.renderToString(math.trim(), { - displayMode: true, - throwOnError: false, - }); - const idx = mathPlaceholders.length; - mathPlaceholders.push(rendered); - return `${placeholderPrefix}${idx}XENDX`; - } catch { - const idx = mathPlaceholders.length; - mathPlaceholders.push(`$$${math}$$`); - return `${placeholderPrefix}${idx}XENDX`; - } + const idx = mathPlaceholders.length; + mathPlaceholders.push(renderMathSource(math, true)); + return `${placeholderPrefix}${idx}XENDX`; }); // 1.2 行内公式 $ ... $ text = text.replace(/\$([^\$\n]+?)\$/g, (_, math) => { - try { - const rendered = katex.renderToString(math.trim(), { - displayMode: false, - throwOnError: false, - }); - const idx = mathPlaceholders.length; - mathPlaceholders.push(rendered); - return `${placeholderPrefix}${idx}XENDX`; - } catch { - const idx = mathPlaceholders.length; - mathPlaceholders.push(`$${math}$`); - return `${placeholderPrefix}${idx}XENDX`; - } + const idx = mathPlaceholders.length; + mathPlaceholders.push(renderMathSource(math, false)); + return `${placeholderPrefix}${idx}XENDX`; }); // 2. 行内代码保护 `code` const codePlaceholders: string[] = []; - const codePlaceholderPrefix = 'XCODETOKENX'; + const codePlaceholderPrefix = `XCODETOKENX${tokenSalt}X`; text = text.replace(/`([^`\n]+)`/g, (_, code) => { const rendered = `${escapeHtml(code)}`; const idx = codePlaceholders.length; diff --git a/web/src/features/replyExport/clipboard.ts b/web/src/features/replyExport/clipboard.ts index e6b0c878..8884fdd0 100644 --- a/web/src/features/replyExport/clipboard.ts +++ b/web/src/features/replyExport/clipboard.ts @@ -1,5 +1,4 @@ import { parseReplyMarkdown } from '../../engine/replyDocument/parseReplyMarkdown'; -import { renderClipboardHtml } from '../../engine/replyDocument/renderClipboardHtml'; import { renderPlainText } from '../../engine/replyDocument/renderPlainText'; export interface CopyResult { @@ -14,6 +13,7 @@ export interface CopyResult { */ export async function copyRichReplyToClipboard(markdown: string, title?: string): Promise { const ir = parseReplyMarkdown(markdown, title); + const { renderClipboardHtml } = await import('../../engine/replyDocument/renderClipboardHtml'); const html = renderClipboardHtml(ir); const plain = renderPlainText(ir); diff --git a/web/src/features/replyExport/download.ts b/web/src/features/replyExport/download.ts index 7f78523f..afaa1442 100644 --- a/web/src/features/replyExport/download.ts +++ b/web/src/features/replyExport/download.ts @@ -1,7 +1,4 @@ -import { Packer } from 'docx'; import { deriveReplyTitle, sanitizeFilename } from './title'; -import { markdownToBlockAST } from '../../engine/markdownToBlock'; -import { buildDocxModel } from '../../engine/docxRenderer'; export interface DownloadOptions { includeSource?: boolean; @@ -42,53 +39,3 @@ export function downloadMarkdownReply( return { filename }; } -/** - * 触发 Word (.docx) 专用轻量排版导出并直接下载 - */ -export async function downloadDocxReply( - markdown: string, - title?: string, - options: DownloadOptions = {} -): Promise<{ filename: string; blob: Blob }> { - const effectiveTitle = title || deriveReplyTitle(markdown, options.createdAt); - const dateStr = new Date().toISOString().substring(0, 10); - const baseName = sanitizeFilename(effectiveTitle); - const filename = options.filenameOverride || `${baseName}_${dateStr}.docx`; - - let exportContent = (markdown || '').trim(); - if (options.includeSource) { - const metaTime = options.createdAt || new Date().toLocaleString(); - exportContent += `\n\n---\n*来源:水木东方项目助手回复 · 生成时间:${metaTime}*`; - } - - const ast = markdownToBlockAST(exportContent, { - docId: `reply-docx-${Date.now()}`, - title: effectiveTitle, - taskKey: 'reply_export', - version: 'V1.0', - category: 'private', - lifecycleState: 'drafting', - isClaimed: true, - author: '智能体助手回复', - reviewer: '', - approver: '', - updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), - }); - - const doc = await buildDocxModel(ast); - const blob = await Packer.toBlob(doc); - - if (typeof window !== 'undefined' && typeof document !== 'undefined') { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 1000); - } - - return { filename, blob }; -} - diff --git a/web/src/features/replyExport/downloadDocx.ts b/web/src/features/replyExport/downloadDocx.ts new file mode 100644 index 00000000..a83f1efa --- /dev/null +++ b/web/src/features/replyExport/downloadDocx.ts @@ -0,0 +1,53 @@ +import { Packer } from 'docx'; +import { deriveReplyTitle, sanitizeFilename } from './title'; +import { markdownToBlockAST } from '../../engine/markdownToBlock'; +import { buildDocxModel } from '../../engine/docxRenderer'; +import type { DownloadOptions } from './download'; + +/** Word 导出。只在点击时动态引入,避免 docx 进入登录壳。 */ +export async function downloadDocxReply( + markdown: string, + title?: string, + options: DownloadOptions = {} +): Promise<{ filename: string; blob: Blob }> { + const effectiveTitle = title || deriveReplyTitle(markdown, options.createdAt); + const dateStr = new Date().toISOString().substring(0, 10); + const baseName = sanitizeFilename(effectiveTitle); + const filename = options.filenameOverride || `${baseName}_${dateStr}.docx`; + + let exportContent = (markdown || '').trim(); + if (options.includeSource) { + const metaTime = options.createdAt || new Date().toLocaleString(); + exportContent += `\n\n---\n*来源:水木东方项目助手回复 · 生成时间:${metaTime}*`; + } + + const ast = markdownToBlockAST(exportContent, { + docId: `reply-docx-${Date.now()}`, + title: effectiveTitle, + taskKey: 'reply_export', + version: 'V1.0', + category: 'private', + lifecycleState: 'drafting', + isClaimed: true, + author: '智能体助手回复', + reviewer: '', + approver: '', + updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), + }); + + const doc = await buildDocxModel(ast); + const blob = await Packer.toBlob(doc); + + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + return { filename, blob }; +} diff --git a/web/src/hooks/useAuthenticatedWorkspaceSync.test.ts b/web/src/hooks/useAuthenticatedWorkspaceSync.test.ts new file mode 100644 index 00000000..970905e4 --- /dev/null +++ b/web/src/hooks/useAuthenticatedWorkspaceSync.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('login workspace sync lives in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useAuthenticatedWorkspaceSync.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useAuthenticatedWorkspaceSync\(/g) || []).length, 1); + assert.equal(app.includes('fetchProjects('), false); + assert.equal(app.includes('fetchChatSessions('), false); + assert.equal(hook.includes('fetchProjects'), true); + assert.equal(hook.includes('fetchChatSessions'), true); + assert.equal(hook.includes('loginMemberSyncIds'), true); + assert.equal(hook.includes('.fetchMe()'), true); +}); diff --git a/web/src/hooks/useAuthenticatedWorkspaceSync.ts b/web/src/hooks/useAuthenticatedWorkspaceSync.ts new file mode 100644 index 00000000..f03a8c6d --- /dev/null +++ b/web/src/hooks/useAuthenticatedWorkspaceSync.ts @@ -0,0 +1,194 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { authClient } from '../api/auth'; +import { fetchChatSessions } from '../api/chatSessions'; +import { fetchProjects } from '../api/projectWorkflow'; +import { loginMemberSyncIds, RequestEpoch, seedViewerMembership } from '../data/initFanout'; +import { mergeSessionUser } from '../lib/mergeSessionUser'; +import { mapServerProjectToProjectInfo, mergeProjectLists } from '../lib/projectAdapter'; +import { mapServerSessionToProjectSession, mergeSessionLists } from '../lib/sessionAdapter'; +import type { LoginResult, SystemAuthConfig } from '../types/auth'; +import type { UserProfile } from '../types/docgov'; +import type { ProjectInfo, ProjectSession } from '../types/workbench'; + +export interface AuthenticatedWorkspaceSyncInput { + allUsers: UserProfile[]; + setCurrentUser: Dispatch>; + setSession: Dispatch>; + setSessionValidated: Dispatch>; + setSystemEnv: Dispatch>; + setSystemAuthConfig: Dispatch>; + setCapabilities: Dispatch>; + session: LoginResult | null; + sessionValidated: boolean | null; + currentUser: UserProfile; + currentProjectId: number; + syncProjectMembersFromServer: (projectId: number, creatorId: number) => Promise; + setProjects: Dispatch>; + setProjectSyncNotice: Dispatch>; + projectMembersModalOpen: boolean; + projectsRef: MutableRefObject; + setSessions: Dispatch>; + setSessionSyncNotice: Dispatch>; + sessionEpochRef: MutableRefObject; +} + +/** + * 已登录工作台的启动同步:校验会话、拉项目、按需同步当前项目成员、拉会话列表。 + * 首次校验故意只跑一次,沿用入口组件原来的空依赖,避免用户目录更新把登录态重放一遍。 + */ +export function useAuthenticatedWorkspaceSync(input: AuthenticatedWorkspaceSyncInput): void { + const { + allUsers, + setCurrentUser, + setSession, + setSessionValidated, + setSystemEnv, + setSystemAuthConfig, + setCapabilities, + session, + sessionValidated, + currentUser, + currentProjectId, + syncProjectMembersFromServer, + setProjects, + setProjectSyncNotice, + projectMembersModalOpen, + projectsRef, + setSessions, + setSessionSyncNotice, + sessionEpochRef, + } = input; + + useEffect(() => { + authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {}); + const saved = authClient.loadSavedSession(); + if (saved) { + setSession(saved); + if (saved.env) setSystemEnv(saved.env); + setCapabilities(saved.capabilities ?? []); + const match = allUsers.find((u) => u.id === saved.user.id); + setCurrentUser(mergeSessionUser(saved.user, match)); + void authClient + .fetchMe() + .then((me) => { + setCapabilities(me.capabilities ?? []); + setCurrentUser(mergeSessionUser(me.user, match)); + setSessionValidated(true); + }) + .catch(() => { + authClient.clearLocalSession(); + setSession(null); + setCapabilities([]); + setSessionValidated(false); + }); + } else { + setSessionValidated(false); + } + // 与原入口一致:只在挂载时恢复本地会话。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!session || sessionValidated !== true) { + setProjectSyncNotice(null); + return; + } + + let cancelled = false; + void (async () => { + try { + const serverProjects = await fetchProjects(); + if (cancelled) return; + setProjects((prev) => { + const mapped = (serverProjects ?? []).map((project) => + seedViewerMembership( + mapServerProjectToProjectInfo( + project, + allUsers, + prev.find((item) => item.id === project.id) + ), + currentUser.id, + currentUser.name + ) + ); + return mergeProjectLists(prev, mapped); + }); + const syncIds = loginMemberSyncIds( + (serverProjects ?? []).map((project) => project.id), + currentProjectId + ); + await Promise.all( + syncIds.map((id) => { + const project = serverProjects.find((item) => item.id === id); + if (!project) return Promise.resolve(null); + return syncProjectMembersFromServer(project.id, project.creator_user_id); + }) + ); + setProjectSyncNotice(null); + } catch (err) { + if (cancelled) return; + console.warn('[projects] 服务端项目列表同步失败,继续使用本地缓存:', err); + setProjectSyncNotice('项目列表暂用本地缓存:无法连接服务端,新建项目可能无法在其他设备同步。'); + } + })(); + + return () => { + cancelled = true; + }; + }, [ + session, + sessionValidated, + allUsers, + currentUser.id, + currentUser.name, + currentProjectId, + syncProjectMembersFromServer, + setProjects, + setProjectSyncNotice, + ]); + + useEffect(() => { + if (!projectMembersModalOpen || !currentProjectId || !session || sessionValidated !== true) return; + const project = projectsRef.current.find((item) => item.id === currentProjectId); + if (!project) return; + void syncProjectMembersFromServer(project.id, project.creatorId); + }, [projectMembersModalOpen, currentProjectId, session, sessionValidated, syncProjectMembersFromServer, projectsRef]); + + useEffect(() => { + if (!session || sessionValidated !== true || !currentUser.id) { + setSessionSyncNotice(null); + return; + } + + const abort = new AbortController(); + const epoch = sessionEpochRef.current.next(); + void (async () => { + try { + const serverSessions = await fetchChatSessions(currentUser.id, undefined, 100, abort.signal); + if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return; + const knownProjects = projectsRef.current; + setSessions((prev) => { + const mapped = (serverSessions ?? []).map((item) => + mapServerSessionToProjectSession( + item, + knownProjects, + prev.find((sessionItem) => sessionItem.sessionId === item.session_id) + ) + ); + return mergeSessionLists(prev, mapped); + }); + setSessionSyncNotice(null); + } catch (err) { + if (abort.signal.aborted || !sessionEpochRef.current.isCurrent(epoch)) return; + console.warn('[sessions] 服务端会话列表同步失败,继续使用本地缓存:', err); + setSessionSyncNotice( + '会话列表暂用本地缓存:无法连接服务端,新建会话可能无法在其他设备同步。' + ); + } + })(); + + return () => { + abort.abort(); + }; + }, [session, sessionValidated, currentUser.id, setSessions, setSessionSyncNotice, sessionEpochRef, projectsRef]); +} diff --git a/web/src/hooks/useBlockEditing.test.ts b/web/src/hooks/useBlockEditing.test.ts new file mode 100644 index 00000000..2e87530a --- /dev/null +++ b/web/src/hooks/useBlockEditing.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('block editing lives in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useBlockEditing.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useBlockEditing\(/g) || []).length, 1); + assert.equal(app.includes('saveControlledBlockEdit('), false); + assert.equal(hook.includes('saveControlledBlockEdit'), true); + assert.equal(hook.includes('markSaved'), true); + assert.equal(hook.includes('recomputeNumbering'), true); + assert.equal(hook.includes('canAddBlockComment'), true); +}); diff --git a/web/src/hooks/useBlockEditing.ts b/web/src/hooks/useBlockEditing.ts new file mode 100644 index 00000000..2015ac19 --- /dev/null +++ b/web/src/hooks/useBlockEditing.ts @@ -0,0 +1,144 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import type { DiffResult } from '../features/documentRevision/types'; +import { recomputeNumbering } from '../engine/autoNumber'; +import { + createEditorHydrationGate, +} from '../lib/editorHydrationGate'; +import { canAddBlockComment } from '../lib/commentPolicy'; +import { saveControlledBlockEdit } from '../lib/saveControlledBlockEdit'; +import type { BlockEditorSession } from '../lib/blockSavePolicy'; +import type { BlockComment, DocumentAST, DocumentBlock } from '../types/block'; +import type { UserProfile, UserRole } from '../types/docgov'; +import type { DeliverableItem } from '../types/workbench'; + +type Gate = ReturnType; + +export interface BlockEditingInput { + docAST: DocumentAST; + setDocAST: (next: DocumentAST) => void; + activeDocId: string; + deliverables: DeliverableItem[]; + currentUser: UserProfile; + currentDocRole: UserRole; + editorSession: BlockEditorSession | null; + commentingBlock: DocumentBlock | null; + editorHydrationGateRef: MutableRefObject; + setDocCache: Dispatch>>; + setDocRevConflict: Dispatch>; + setDocRevOverlay: Dispatch>; + setDocRevRefreshKey: Dispatch>; + closeBlockEditor: () => void; +} + +export interface BlockEditingActions { + handleSaveBlockEdit: (updated: DocumentBlock) => Promise; + handleInsertBlock: (index: number, position: 'above' | 'below') => void; + handleMoveBlock: (index: number, direction: 'up' | 'down') => void; + handleDeleteBlock: (index: number) => void; + handleAddComment: (comment: Omit) => void; +} + +export function useBlockEditing(input: BlockEditingInput): BlockEditingActions { + const { + docAST, + setDocAST, + activeDocId, + deliverables, + currentUser, + currentDocRole, + editorSession, + commentingBlock, + editorHydrationGateRef, + setDocCache, + setDocRevConflict, + setDocRevOverlay, + setDocRevRefreshKey, + closeBlockEditor, + } = input; + + const handleSaveBlockEdit = async (updated: DocumentBlock) => { + const target = deliverables.find((d) => d.docId === activeDocId); + if ( + target && + (target.sourceType === 'uploaded_file' || Boolean(target.fileMeta)) && + target.governanceMode === 'freeform' && + !docAST.rootBlock?.children?.length + ) { + closeBlockEditor(); + return; + } + const result = await saveControlledBlockEdit({ + editorDocId: editorSession?.docId, + activeDocId, + ast: docAST, + updated, + actorUserId: currentUser.id, + }); + if (!result.ok) { + setDocRevConflict(result.message); + return; + } + editorHydrationGateRef.current.markSaved(); + setDocAST(result.ast); + setDocCache((prev) => ({ ...prev, [activeDocId]: result.ast })); + closeBlockEditor(); + setDocRevConflict(null); + setDocRevOverlay(result.overlay); + setDocRevRefreshKey((k) => k + 1); + }; + + const handleInsertBlock = (index: number, position: 'above' | 'below') => { + const newBlock: DocumentBlock = { + id: 'blk_' + Math.random().toString(36).substring(2, 9), + type: 'paragraph', + content: '【新插入技术要求】:在此填写具体受控技术规范或临床评定指标...', + metadata: { + lastModifiedBy: `${currentUser.name} (${currentUser.position})`, + lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), + }, + }; + const children = docAST.rootBlock.children || []; + const insertAt = position === 'above' ? index : index + 1; + children.splice(insertAt, 0, newBlock); + recomputeNumbering(docAST.rootBlock); + setDocAST({ ...docAST }); + }; + + const handleMoveBlock = (index: number, direction: 'up' | 'down') => { + const children = docAST.rootBlock.children || []; + const targetIdx = direction === 'up' ? index - 1 : index + 1; + if (targetIdx >= 0 && targetIdx < children.length) { + const temp = children[index]; + children[index] = children[targetIdx]; + children[targetIdx] = temp; + recomputeNumbering(docAST.rootBlock); + setDocAST({ ...docAST }); + } + }; + + const handleDeleteBlock = (index: number) => { + const children = docAST.rootBlock.children || []; + children.splice(index, 1); + recomputeNumbering(docAST.rootBlock); + setDocAST({ ...docAST }); + }; + + const handleAddComment = (comment: Omit) => { + if (!commentingBlock || !canAddBlockComment(currentDocRole)) return; + const newCmt: BlockComment = { + ...comment, + id: 'cmt_' + Math.random().toString(36).substring(2, 8), + createdAt: new Date().toTimeString().substring(0, 5), + }; + commentingBlock.comments = [...(commentingBlock.comments || []), newCmt]; + setDocAST({ ...docAST }); + }; + + return { + handleSaveBlockEdit, + handleInsertBlock, + handleMoveBlock, + handleDeleteBlock, + handleAddComment, + }; +} diff --git a/web/src/hooks/useCardActions.test.ts b/web/src/hooks/useCardActions.test.ts new file mode 100644 index 00000000..d4abdf03 --- /dev/null +++ b/web/src/hooks/useCardActions.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('interactive card actions live in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useCardActions.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useCardActions\(/g) || []).length, 1); + assert.equal(app.includes('submitIntentPlanDecision'), false); + assert.equal(hook.includes('isPlanConfirmCard'), true); + assert.equal(hook.includes('httpStatus === 409'), true); + assert.equal(hook.includes("actionId === 'confirm_reject'"), true); + assert.equal(hook.includes("actionId === 'undo'"), true); +}); diff --git a/web/src/hooks/useCardActions.ts b/web/src/hooks/useCardActions.ts new file mode 100644 index 00000000..2fb78fe5 --- /dev/null +++ b/web/src/hooks/useCardActions.ts @@ -0,0 +1,371 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { + fetchIntentPlan, + submitIntentPlanDecision, +} from '../api/intentPlans'; +import { + ALL_MOCK_DOCUMENTS, +} from '@demo-seeds'; +import { + applyConfirmRejectToDoc, + applyUndoRejectToDoc, + cancelCardInMessages, + resolveCardInMessages, +} from '../engine/cardActionEngine'; +import { markdownToBlockAST } from '../engine/markdownToBlock'; +import { + applyPlanViewToCard, + isPlanConfirmCard, + markPlanCard, + reconcileConflictCard, +} from '../engine/planCard'; +import type { DocumentAST } from '../types/block'; +import type { DocLifecycleState, UserProfile } from '../types/docgov'; +import type { + ChatMessage, + DeliverableCategory, + DeliverableItem, + InteractiveCardData, +} from '../types/workbench'; + +export interface CardActionsInput { + messages: ChatMessage[]; + currentProjectId: number; + activeSessionId: string; + activeDocId: string; + currentUser: UserProfile; + deliverables: DeliverableItem[]; + setMessages: Dispatch>; + setDeliverables: Dispatch>; + setDocCache: Dispatch>>; + setActiveDocId: Dispatch>; + setActiveCategory: Dispatch>; + setHighlightedBlockId: Dispatch>; +} + +export function useCardActions(input: CardActionsInput) { + const { + messages, + currentProjectId, + activeSessionId, + activeDocId, + currentUser, + deliverables, + setMessages, + setDeliverables, + setDocCache, + setActiveDocId, + setActiveCategory, + setHighlightedBlockId, + } = input; + + const handleCardAction = (cardId: string, actionId: string, payload?: any) => { + // 查找对应的卡片 + let targetCard: InteractiveCardData | undefined; + for (const m of messages) { + if (m.interactiveCard && m.interactiveCard.cardId === cardId) { + targetCard = m.interactiveCard; + break; + } + } + + if (isPlanConfirmCard(targetCard, payload)) { + const planId = String(payload?.plan_id || targetCard?.planId || ''); + const expectedRevision = Number(payload?.expected_revision || targetCard?.planRevision || 0); + const kind = (payload?.kind || (actionId === 'plan_cancel' ? 'cancel' : 'confirm')) as + | 'confirm' + | 'cancel'; + const projectId = currentProjectId; + const sessionId = activeSessionId; + setMessages((prev) => + prev.map((m) => + m.interactiveCard?.cardId === cardId + ? { ...m, interactiveCard: markPlanCard(m.interactiveCard, 'executing') } + : m + ) + ); + void (async () => { + const decisionNonce = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${cardId}:${actionId}:${expectedRevision}:${Date.now()}:${Math.random()}`; + const result = await submitIntentPlanDecision({ + planId, + projectId, + sessionId, + expectedRevision, + decisionNonce, + decisionDigest: `${kind}:${expectedRevision}`, + kind, + }); + setMessages((prev) => + prev.map((m) => { + if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m; + if (result.httpStatus === 409) { + return { ...m, interactiveCard: reconcileConflictCard(m.interactiveCard, result.plan) }; + } + if (result.plan) { + return { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, result.plan) }; + } + return m; + }) + ); + if (!result.plan && result.httpStatus >= 400) { + const latest = await fetchIntentPlan(planId, projectId, sessionId).catch(() => null); + if (latest) { + setMessages((prev) => + prev.map((m) => + m.interactiveCard?.cardId === cardId && m.interactiveCard + ? { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, latest) } + : m + ) + ); + } + } + })(); + return; + } + + const targetDocId = + payload?.targetDocId || payload?.docId || targetCard?.targetDocId || activeDocId; + const reason = + payload?.reason || '关键竞品性能指标与电气安全指标待补充,退回编制人重新修订。'; + + if (actionId === 'confirm_reject') { + // 1. 调用既有文档驳回逻辑 (将目标文档生命周期由 reviewing 回退为 drafting) + setDeliverables((prev) => + prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'drafting' } : d)) + ); + + // 2. 扫描目标文档 AST,在匹配块上追加 type: 'issue' 批注 + let targetBlockId: string | null = null; + let targetSectionName = '第3节'; + setDocCache((prev) => { + let currentDoc = prev[targetDocId]; + if (!currentDoc && ALL_MOCK_DOCUMENTS[targetDocId]) { + const meta = ALL_MOCK_DOCUMENTS[targetDocId]; + currentDoc = markdownToBlockAST(meta.markdown || '', { + docId: meta.docId || targetDocId, + title: meta.title, + taskKey: meta.taskKey, + version: meta.version, + category: meta.category as DeliverableCategory | undefined, + creatorId: meta.creatorId, + creatorName: meta.creatorName, + sharedAt: meta.sharedAt, + lifecycleState: meta.lifecycleState as DocLifecycleState | undefined, + isClaimed: meta.isClaimed, + author: meta.author, + reviewer: meta.reviewer, + approver: meta.approver, + }); + } + if (!currentDoc) return prev; + + const { updatedDoc, matchingBlock } = applyConfirmRejectToDoc( + currentDoc, + cardId, + currentUser.name, + reason, + targetCard?.sectionTitle || payload?.targetSection + ); + targetBlockId = matchingBlock.id; + if (matchingBlock.numbering) { + targetSectionName = `第${matchingBlock.numbering.replace('.', '')}节`; + } + + return { + ...prev, + [targetDocId]: updatedDoc, + }; + }); + + // 3. 更新卡片状态为 resolved + const sectionLabel = + targetCard?.sectionTitle?.match(/第[0-9一二三四五六七八九]+节/)?.[0] || targetSectionName || '第3节'; + const resolutionSummary = `已正式退回编制阶段,批注已挂载至${sectionLabel}`; + + setMessages((prev) => { + const updatedMessages = resolveCardInMessages( + prev, + cardId, + resolutionSummary, + currentUser.name + ); + return [ + ...updatedMessages, + { + id: 'sys-rej-' + Date.now(), + sessionId: activeSessionId, + role: 'system', + content: `⚠️ ${currentUser.name}(审核负责人)驳回了《${targetCard?.targetDocTitle || targetDocId}》,已退回编制阶段。驳回原因:${reason}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]; + }); + + // 4. 切换当前右侧激活文档为目标文档并高亮匹配块 + setActiveDocId(targetDocId); + const targetDeliverable = deliverables.find((d) => d.docId === targetDocId); + if (targetDeliverable) { + setActiveCategory(targetDeliverable.category); + } + if (targetBlockId) { + setHighlightedBlockId(targetBlockId); + setTimeout(() => { + setHighlightedBlockId(null); + }, 2500); + } + } else if (actionId === 'cancel') { + // 标记卡片为 cancelled 并追加说明 + setMessages((prev) => [ + ...cancelCardInMessages(prev, cardId), + { + id: 'ast-cancel-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🚫 取消操作', + content: '已取消驳回操作,文档保持待审核状态。', + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } else if (actionId === 'undo') { + // 撤销驳回:恢复为待审核状态 (reviewing) + setDeliverables((prev) => + prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d)) + ); + + // 移除注入的批注并恢复 reviewing 状态 + setDocCache((prev) => { + const currentDoc = prev[targetDocId]; + if (!currentDoc) return prev; + const updatedDoc = applyUndoRejectToDoc(currentDoc, cardId); + return { + ...prev, + [targetDocId]: updatedDoc, + }; + }); + + // 标记卡片为 cancelled 并追加说明 + setMessages((prev) => [ + ...cancelCardInMessages(prev, cardId), + { + id: 'ast-undo-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '↩️ 撤回驳回', + content: '已在合法窗口期撤回驳回,文档已恢复为待审核状态。', + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } else if (actionId === 'confirm_final_approve') { + // 终审批准 + setDeliverables((prev) => + prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approved' } : d)) + ); + setDocCache((prev) => { + const currentDoc = prev[targetDocId]; + if (!currentDoc) return prev; + return { + ...prev, + [targetDocId]: { ...currentDoc, lifecycleState: 'approved' as const }, + }; + }); + + setMessages((prev) => [ + ...resolveCardInMessages( + prev, + cardId, + '已终审批准生效', + currentUser.name + ), + { + id: 'appr-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🏆 终审批准生效', + content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${targetCard?.targetDocTitle || targetDocId}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`, + timestamp: new Date().toTimeString().substring(0, 5), + recommendedCases: [ + { + docId: targetDocId, + taskKey: targetCard?.targetDocId || 'research', + artifactTitle: targetCard?.targetDocTitle || '受控交付物', + sectionTitle: '终审已生效标杆案例', + similarityScore: 1.0, + keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'], + summarySnippet: '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。', + }, + ], + }, + ]); + } else if (actionId === 'confirm_review_pass' || actionId === 'confirm_review') { + // 审核通过:流转至待终审状态 (approving) + setDeliverables((prev) => + prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'approving' } : d)) + ); + setDocCache((prev) => { + const currentDoc = prev[targetDocId]; + if (!currentDoc) return prev; + return { + ...prev, + [targetDocId]: { ...currentDoc, lifecycleState: 'approving' as const }, + }; + }); + + setMessages((prev) => [ + ...resolveCardInMessages( + prev, + cardId, + '审核已通过,流转至终审阶段', + currentUser.name + ), + { + id: 'rev-pass-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '✅ 审核通过确认', + content: `✅ ${currentUser.name}(审核人)已审核通过《${targetCard?.targetDocTitle || targetDocId}》!正文继续保持锁定,已流转至终审批准人进行最终批准。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } else if (actionId === 'confirm_submit_review' || actionId === 'submit_review') { + // 提交审核:流转至待审核状态 (reviewing) + setDeliverables((prev) => + prev.map((d) => (d.docId === targetDocId ? { ...d, lifecycleState: 'reviewing' } : d)) + ); + setDocCache((prev) => { + const currentDoc = prev[targetDocId]; + if (!currentDoc) return prev; + return { + ...prev, + [targetDocId]: { ...currentDoc, lifecycleState: 'reviewing' as const }, + }; + }); + + setMessages((prev) => [ + ...resolveCardInMessages( + prev, + cardId, + '已正式提交审核,正文锁定', + currentUser.name + ), + { + id: 'sub-rev-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '📤 提交审核确认', + content: `📤 ${currentUser.name}(编制人)已正式提交《${targetCard?.targetDocTitle || targetDocId}》进入审核流程!正文已锁定,李主管(审核人)可在工作台或会话流中进行合规复核。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } + }; + + return handleCardAction; +} 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/useControlledDocumentHydration.test.ts b/web/src/hooks/useControlledDocumentHydration.test.ts new file mode 100644 index 00000000..9d0a8f56 --- /dev/null +++ b/web/src/hooks/useControlledDocumentHydration.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('controlled document hydration lives in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useControlledDocumentHydration.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useControlledDocumentHydration\(/g) || []).length, 1); + assert.equal(app.includes('自动拉取受控文档正文失败'), false); + assert.equal(app.includes('重试拉取受控文档正文失败'), false); + assert.equal(hook.includes('beginHydration'), true); + assert.equal(hook.includes('hydrateControlledDocumentContent'), true); + assert.equal(hook.includes('decide(token).apply'), true); +}); diff --git a/web/src/hooks/useControlledDocumentHydration.ts b/web/src/hooks/useControlledDocumentHydration.ts new file mode 100644 index 00000000..dbd7b3be --- /dev/null +++ b/web/src/hooks/useControlledDocumentHydration.ts @@ -0,0 +1,202 @@ +import { useCallback, useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { ControlledContentRequestError } from '../api/projectDocuments'; +import { + buildControlledLoadErrorSkeleton, + buildControlledLoadingSkeleton, + hydrateControlledDocumentContent, + resolveCanonicalDocId, + shouldLoadControlledContent, +} from '../lib/controlledDocHydrate'; +import { + createEditorHydrationGate, + type HydrationToken, +} from '../lib/editorHydrationGate'; +import type { LoginResult } from '../types/auth'; +import type { DocumentAST } from '../types/block'; +import type { DeliverableItem } from '../types/workbench'; + +export type DocLoadErrorInfo = { code?: string; message: string; correlationId?: string }; + +type Gate = ReturnType; + +export interface ControlledDocumentHydrationInput { + session: LoginResult | null; + sessionValidated: boolean | null; + activeDocId: string; + setActiveDocId: Dispatch>; + deliverables: DeliverableItem[]; + docCache: Record; + setDocCache: Dispatch>>; + setControlledContentReady: Dispatch>>; + setDocLoadErrors: Dispatch>>; + setDocLoadErrorInfo: Dispatch>>; + setDocRevRefreshKey: Dispatch>; + editorHydrationGateRef: MutableRefObject; + activeHydrateFingerprint: string; + hydrateResumeKey: number; + docAST: DocumentAST; +} + +export interface ControlledDocumentHydration { + applyHydratedDocument: ( + token: HydrationToken, + ast: DocumentAST | null | undefined, + docId: string, + opts?: { refreshToolbar?: boolean }, + ) => boolean; + retryControlledDocLoad: () => void; +} + +function loadErrorInfo(err: unknown): DocLoadErrorInfo { + return { + code: err instanceof ControlledContentRequestError ? err.code : undefined, + message: err instanceof Error ? err.message : String(err), + correlationId: err instanceof ControlledContentRequestError ? err.correlationId : undefined, + }; +} + +export function useControlledDocumentHydration( + input: ControlledDocumentHydrationInput, +): ControlledDocumentHydration { + const { + session, + sessionValidated, + activeDocId, + setActiveDocId, + deliverables, + docCache, + setDocCache, + setControlledContentReady, + setDocLoadErrors, + setDocLoadErrorInfo, + setDocRevRefreshKey, + editorHydrationGateRef, + activeHydrateFingerprint, + hydrateResumeKey, + docAST, + } = input; + + useEffect(() => { + const canonicalId = resolveCanonicalDocId(activeDocId, deliverables); + if (canonicalId === activeDocId) return; + setActiveDocId(canonicalId); + setDocCache((prev) => { + if (!prev[activeDocId]) return prev; + const next = { ...prev }; + delete next[activeDocId]; + return next; + }); + }, [deliverables, activeDocId, setActiveDocId, setDocCache]); + + const applyHydratedDocument = useCallback( + ( + token: HydrationToken, + ast: DocumentAST | null | undefined, + docId: string, + opts?: { refreshToolbar?: boolean }, + ) => { + if (!ast) return false; + if (!editorHydrationGateRef.current.decide(token).apply) return false; + setDocCache((prev) => ({ ...prev, [docId]: ast })); + setControlledContentReady((prev) => ({ ...prev, [docId]: true })); + setDocLoadErrors((prev) => ({ ...prev, [docId]: false })); + setDocLoadErrorInfo((prev) => { + if (!prev[docId]) return prev; + const next = { ...prev }; + delete next[docId]; + return next; + }); + if (opts?.refreshToolbar !== false) { + setDocRevRefreshKey((k) => k + 1); + } + return true; + }, + [editorHydrationGateRef, setControlledContentReady, setDocCache, setDocLoadErrorInfo, setDocLoadErrors, setDocRevRefreshKey], + ); + + useEffect(() => { + if (!session || sessionValidated !== true) return; + const target = deliverables.find((d) => d.docId === activeDocId); + if (!target || !shouldLoadControlledContent(target)) return; + const token = editorHydrationGateRef.current.beginHydration(activeDocId); + if (!token) return; + + let cancelled = false; + setDocLoadErrors((prev) => { + const next = { ...prev }; + delete next[activeDocId]; + return next; + }); + void hydrateControlledDocumentContent(activeDocId, target) + .then((ast) => { + if (cancelled) return; + applyHydratedDocument(token, ast, activeDocId); + }) + .catch((err) => { + console.warn('自动拉取受控文档正文失败:', err); + if (cancelled) return; + if (!editorHydrationGateRef.current.decide(token).apply) return; + setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); + setDocLoadErrorInfo((prev) => ({ ...prev, [activeDocId]: loadErrorInfo(err) })); + setDocCache((prev) => ({ + ...prev, + [activeDocId]: buildControlledLoadErrorSkeleton(target), + })); + }); + + return () => { + cancelled = true; + }; + }, [session, sessionValidated, activeDocId, activeHydrateFingerprint, applyHydratedDocument, hydrateResumeKey]); + + const retryControlledDocLoad = useCallback(() => { + const target = deliverables.find((d) => d.docId === activeDocId); + if (!target || !shouldLoadControlledContent(target)) return; + const token = editorHydrationGateRef.current.beginHydration(activeDocId); + if (!token) return; + + setDocLoadErrors((prev) => { + const next = { ...prev }; + delete next[activeDocId]; + return next; + }); + setControlledContentReady((prev) => ({ ...prev, [activeDocId]: false })); + setDocCache((prev) => ({ + ...prev, + [activeDocId]: buildControlledLoadingSkeleton(target), + })); + + void hydrateControlledDocumentContent(activeDocId, target) + .then((ast) => { + applyHydratedDocument(token, ast, activeDocId); + }) + .catch((err) => { + console.warn('重试拉取受控文档正文失败:', err); + if (!editorHydrationGateRef.current.decide(token).apply) return; + setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); + setDocLoadErrorInfo((prev) => ({ ...prev, [activeDocId]: loadErrorInfo(err) })); + setDocCache((prev) => ({ + ...prev, + [activeDocId]: buildControlledLoadErrorSkeleton(target), + })); + }); + }, [ + activeDocId, + deliverables, + applyHydratedDocument, + editorHydrationGateRef, + setControlledContentReady, + setDocCache, + setDocLoadErrorInfo, + setDocLoadErrors, + ]); + + useEffect(() => { + if (!docAST || docCache[docAST.docId]) return; + const target = deliverables.find((d) => d.docId === docAST.docId); + if (target && shouldLoadControlledContent(target)) return; + setDocCache((prev) => ({ ...prev, [docAST.docId]: docAST })); + }, [docAST, docCache, deliverables, setDocCache]); + + return { applyHydratedDocument, retryControlledDocLoad }; +} diff --git a/web/src/hooks/useCreateProjectConfirm.test.ts b/web/src/hooks/useCreateProjectConfirm.test.ts new file mode 100644 index 00000000..5c879936 --- /dev/null +++ b/web/src/hooks/useCreateProjectConfirm.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('project creation lives in one hook called before the login return', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useCreateProjectConfirm.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useCreateProjectConfirm\(/g) || []).length, 1); + const callAt = app.indexOf('useCreateProjectConfirm('); + const loginReturnAt = app.indexOf('if (!session)'); + assert.equal(callAt > 0 && callAt < loginReturnAt, true); + assert.equal(app.includes('ensureCreatedProjectWithMembers'), false); + assert.equal(hook.includes('create 刚成功即写入 bootstrap'), true); + assert.equal(hook.includes('shouldCloneMedicalTemplates'), true); + assert.equal(hook.includes('不会因本地状态假成功'), true); +}); diff --git a/web/src/hooks/useCreateProjectConfirm.ts b/web/src/hooks/useCreateProjectConfirm.ts new file mode 100644 index 00000000..293bc1fe --- /dev/null +++ b/web/src/hooks/useCreateProjectConfirm.ts @@ -0,0 +1,232 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { upsertChatSession } from '../api/chatSessions'; +import { addProjectMember } from '../api/projectMembers'; +import { createProject } from '../api/projectWorkflow'; +import { buildPrivateSessionId } from '../data/storage'; +import { createProjectTemplateDeliverables } from '../engine/templateFactory'; +import { ensureCreatedProjectWithMembers } from '../lib/createProjectMembersPersist'; +import { + beginCreateProjectSubmit, + endCreateProjectSubmit, + resolveCreateProjectSubmitPhase, + shouldCloneMedicalTemplates, + type CreateProjectSubmitPhase, +} from '../lib/createProjectSubmitGuard'; +import type { CreateProjectConfirmData } from '../components/project/CreateProjectModal'; +import type { DocumentAST } from '../types/block'; +import type { UserProfile } from '../types/docgov'; +import type { + ChatMessage, + DeliverableCategory, + DeliverableItem, + ProjectInfo, + ProjectMember, + ProjectSession, +} from '../types/workbench'; + +export interface CreateProjectConfirmInput { + currentUser: UserProfile; + createProjectInFlightRef: MutableRefObject; + createProjectBootstrapIdRef: MutableRefObject; + setCreateProjectError: Dispatch>; + setCreateProjectSubmitting: Dispatch>; + setCreateProjectSubmitPhase: Dispatch>; + setCreateProjectModalOpen: Dispatch>; + setProjects: Dispatch>; + setSessions: Dispatch>; + setMessages: Dispatch>; + setDeliverables: Dispatch>; + setDocCache: Dispatch>>; + setActiveSessionId: Dispatch>; + setActiveDocId: Dispatch>; + setActiveCategory: Dispatch>; +} + +export function useCreateProjectConfirm(input: CreateProjectConfirmInput) { + const { + currentUser, + createProjectInFlightRef, + createProjectBootstrapIdRef, + setCreateProjectError, + setCreateProjectSubmitting, + setCreateProjectSubmitPhase, + setCreateProjectModalOpen, + setProjects, + setSessions, + setMessages, + setDeliverables, + setDocCache, + setActiveSessionId, + setActiveDocId, + setActiveCategory, + } = input; + + // 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员) + return async (data: CreateProjectConfirmData) => { + if (!beginCreateProjectSubmit(createProjectInFlightRef)) { + return; + } + setCreateProjectError(null); + setCreateProjectSubmitting(true); + setCreateProjectSubmitPhase( + resolveCreateProjectSubmitPhase(createProjectBootstrapIdRef.current), + ); + + let newProjId: number; + try { + try { + const ensured = await ensureCreatedProjectWithMembers({ + bootstrapProjectId: createProjectBootstrapIdRef.current, + createProject: async () => { + const created = await createProject({ + name: data.title, + device_type: data.deviceType || undefined, + intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description, + product_domain: data.productDomain, + project_type_key: data.projectTypeKey, + team_mode: data.teamMode, + execution_mode: data.executionMode, + problem_statement: data.problemStatement, + target_users: data.targetUsers, + target_outcome: data.targetOutcome, + initial_workflow_key: + data.productDomain === 'non_medical_software' ? 'software_bootstrap' : undefined, + }); + // create 刚成功即写入 bootstrap:成员失败窗口内串行重试不会再 create + createProjectBootstrapIdRef.current = created.id; + return created; + }, + creatorUserId: currentUser.id, + initialMembers: data.initialMembers, + addMember: addProjectMember, + onPhase: setCreateProjectSubmitPhase, + }); + newProjId = ensured.projectId; + createProjectBootstrapIdRef.current = ensured.projectId; + } catch (err) { + const withId = err as Error & { projectId?: number }; + const bootstrapId = + typeof withId.projectId === 'number' && withId.projectId > 0 + ? withId.projectId + : createProjectBootstrapIdRef.current; + if (bootstrapId) { + createProjectBootstrapIdRef.current = bootstrapId; + } + const message = + err instanceof Error + ? err.message + : '创建项目或同步初始成员失败,请稍后重试(不会因本地状态假成功)'; + setCreateProjectError( + bootstrapId + ? `项目已创建(#${bootstrapId}),但初始成员未全部落库:${message}。请再次确认以幂等补齐,勿关闭后当作成功。` + : message, + ); + return; + } + + const nowStr = new Date().toLocaleString(); + + // 1. 构建项目与成员元数据(服务端成员已持久化) + const members: ProjectMember[] = data.initialMembers.map((m) => ({ + userId: m.userId, + userName: m.userName, + department: m.department, + position: m.position, + role: m.role, + status: 'active', + joinedAt: nowStr, + })); + + const newProject: ProjectInfo = { + id: newProjId, + title: data.title, + deviceType: data.deviceType, + description: data.description, + creatorId: currentUser.id, + creatorName: currentUser.name, + createdAt: nowStr, + members, + }; + + // 2. 为每个选中的项目成员生成独立私聊会话 + const newSessionsToAppend: ProjectSession[] = []; + const newMessagesToAppend: ChatMessage[] = []; + let creatorSessionId = ''; + + members.forEach((m) => { + const sessId = buildPrivateSessionId(`p${newProjId}`, m.userId); + if (m.userId === currentUser.id) { + creatorSessionId = sessId; + } + newSessionsToAppend.push({ + sessionId: sessId, + userId: m.userId, + projectId: newProjId, + title: data.title, + deviceType: data.deviceType, + stage: 'V0.1', + stageLabel: 'V0.1 需求摸底', + messageCount: 1, + updatedAt: '刚刚', + }); + + newMessagesToAppend.push({ + id: `m-init-${m.userId}-${Date.now()}`, + sessionId: sessId, + role: 'system', + content: + m.userId === currentUser.id + ? `【立项成功】项目《${data.title}》已成功建立!全套研发工作流与阶段文档模板已克隆就绪,协同参谋小队已就位,进入【V0.1 需求摸底】阶段。您可以点击附件图标上传临床需求 URS,或直接输入您的产品构想。` + : `【项目协同通知】您已被项目创建人 ${currentUser.name} 邀请加入《${data.title}》,并分配法定职责为【${m.role}】。`, + timestamp: new Date().toTimeString().substring(0, 5), + }); + }); + + // 3. 仅医疗项目克隆 7 份受控模板;软件项目走服务端 catalog / docs_only + const clonedDocs = shouldCloneMedicalTemplates(data.productDomain) + ? createProjectTemplateDeliverables({ + projectId: newProjId, + projectName: data.title, + deviceType: data.deviceType, + creatorId: currentUser.id, + creatorName: currentUser.name, + }) + : { deliverables: [], docASTMap: {} }; + const clonedAsts = clonedDocs.docASTMap; + + setProjects((prev) => [newProject, ...prev]); + setSessions((prev) => [...newSessionsToAppend, ...prev]); + setMessages((prev) => [...prev, ...newMessagesToAppend]); + + void Promise.all( + newSessionsToAppend.map((sessionItem) => + upsertChatSession({ + session_id: sessionItem.sessionId, + title: sessionItem.title, + user_id: sessionItem.userId ?? currentUser.id, + project_id: sessionItem.projectId, + }) + ) + ).catch((err) => { + console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err); + }); + setDeliverables((prev) => [...prev, ...clonedDocs.deliverables]); + setDocCache((prev) => ({ ...prev, ...clonedAsts })); + + if (creatorSessionId) { + setActiveSessionId(creatorSessionId); + } + if (clonedDocs.deliverables[0]?.docId) { + setActiveDocId(clonedDocs.deliverables[0].docId); + setActiveCategory('workflow'); + } + createProjectBootstrapIdRef.current = null; + setCreateProjectModalOpen(false); + setCreateProjectError(null); + } finally { + endCreateProjectSubmit(createProjectInFlightRef); + setCreateProjectSubmitting(false); + setCreateProjectSubmitPhase(null); + } + }; +} diff --git a/web/src/hooks/useDocumentLifecycleActions.test.ts b/web/src/hooks/useDocumentLifecycleActions.test.ts new file mode 100644 index 00000000..fdd8d946 --- /dev/null +++ b/web/src/hooks/useDocumentLifecycleActions.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('document lifecycle actions live in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useDocumentLifecycleActions.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useDocumentLifecycleActions\(/g) || []).length, 1); + assert.equal(app.includes('claimDocument('), false); + assert.equal(app.includes('approverDecision('), false); + assert.equal(hook.includes('claimDocument'), true); + assert.equal(hook.includes('submitForReview'), true); + assert.equal(hook.includes('reviewDecision'), true); + assert.equal(hook.includes('approverDecision'), true); + assert.equal(hook.includes("rollback_to: 'drafting'"), true); +}); diff --git a/web/src/hooks/useDocumentLifecycleActions.ts b/web/src/hooks/useDocumentLifecycleActions.ts new file mode 100644 index 00000000..766ffd1e --- /dev/null +++ b/web/src/hooks/useDocumentLifecycleActions.ts @@ -0,0 +1,236 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { + approverDecision, + claimDocument, + mapLeaseToAstEditLease, + reviewDecision, + submitForReview, + type DocGovRecord, +} from '../api/docgov'; +import type { DocumentAST } from '../types/block'; +import type { DocLifecycleState, UserProfile } from '../types/docgov'; +import type { ChatMessage, DeliverableItem } from '../types/workbench'; + +export interface DocumentLifecycleInput { + docAST: DocumentAST; + setDocAST: (next: DocumentAST) => void; + activeDocId: string; + currentUser: UserProfile; + activeSessionId: string; + setDeliverables: Dispatch>; + setMessages: Dispatch>; +} + +export interface DocumentLifecycleActions { + handleClaim: () => Promise; + handleSubmitReview: () => Promise; + handlePassReview: () => Promise; + handleRejectReview: (reason: string) => Promise; + handleApprove: () => Promise; + handleRejectApprove: (reason: string) => Promise; +} + +export function useDocumentLifecycleActions(input: DocumentLifecycleInput): DocumentLifecycleActions { + const { + docAST, + setDocAST, + activeDocId, + currentUser, + activeSessionId, + setDeliverables, + setMessages, + } = input; + + 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.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 pushDocGovError = (actionLabel: string, err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + setMessages((prev) => [ + ...prev, + { + id: 'docgov-err-' + Date.now(), + sessionId: activeSessionId, + role: 'system', + content: `⚠️ ${actionLabel}失败:${message}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + }; + + 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); + } + }; + + 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); + } + }; + + return { + handleClaim, + handleSubmitReview, + handlePassReview, + handleRejectReview, + handleApprove, + handleRejectApprove, + }; +} diff --git a/web/src/hooks/useFreeformDocActions.test.ts b/web/src/hooks/useFreeformDocActions.test.ts new file mode 100644 index 00000000..0b21bd08 --- /dev/null +++ b/web/src/hooks/useFreeformDocActions.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('freeform document actions live in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useFreeformDocActions.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useFreeformDocActions\(/g) || []).length, 1); + assert.equal(app.includes('shareFreeformDoc('), false); + assert.equal(app.includes('saveMessageToPrivate('), false); + assert.equal(hook.includes('shareFreeformDoc'), true); + assert.equal(hook.includes('unshareFreeformDoc'), true); + assert.equal(hook.includes('deleteFreeformDoc'), true); + assert.equal(hook.includes('saveMessageToPrivate'), true); + assert.equal(hook.includes("category: 'private'"), true); +}); diff --git a/web/src/hooks/useFreeformDocActions.ts b/web/src/hooks/useFreeformDocActions.ts new file mode 100644 index 00000000..6990caea --- /dev/null +++ b/web/src/hooks/useFreeformDocActions.ts @@ -0,0 +1,226 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { + deleteFreeformDoc, + saveMessageToPrivate, + shareFreeformDoc, + unshareFreeformDoc, +} from '../api/freeformDoc'; +import { markdownToBlockAST } from '../engine/markdownToBlock'; +import { deriveReplyTitle } from '../features/replyExport/title'; +import type { DocumentAST } from '../types/block'; +import type { UserProfile } from '../types/docgov'; +import type { ChatMessage, DeliverableCategory, DeliverableItem } from '../types/workbench'; + +export interface FreeformDocActionsInput { + activeDocId: string; + activeSessionId: string; + currentProjectId: number; + currentUser: UserProfile; + deliverables: DeliverableItem[]; + setDeliverables: Dispatch>; + setDocCache: Dispatch>>; + setActiveDocId: Dispatch>; + setActiveCategory: Dispatch>; + setMessages: Dispatch>; + setDeckDrawerOpen: Dispatch>; +} + +export interface FreeformDocActions { + handleShareToProject: (docId: string) => Promise; + handleRevokeShare: (docId: string) => Promise; + handleDeleteDoc: (docId: string) => Promise; + handleSaveMessageToPrivate: (message: ChatMessage) => Promise; +} + +export function useFreeformDocActions(input: FreeformDocActionsInput): FreeformDocActions { + const { + activeDocId, + activeSessionId, + currentProjectId, + currentUser, + deliverables, + setDeliverables, + setDocCache, + setActiveDocId, + setActiveCategory, + setMessages, + setDeckDrawerOpen, + } = input; + + const handleShareToProject = async (docId: string) => { + try { + if (docId.startsWith('doc-free-')) { + await shareFreeformDoc(docId); + } + } catch (e) { + console.error('Share doc error:', e); + } + const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16); + setDeliverables((prev) => + prev.map((d) => (d.docId === docId ? { ...d, category: 'shared', space: 'project_shared', sharedAt: nowStr } : d)), + ); + setDocCache((prev) => { + if (!prev[docId]) return prev; + return { + ...prev, + [docId]: { ...prev[docId], category: 'shared', space: 'project_shared', sharedAt: nowStr }, + }; + }); + setActiveCategory('shared'); + const targetDoc = deliverables.find((d) => d.docId === docId); + setMessages((prev) => [ + ...prev, + { + id: 'ast-share-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🌐 一键共享至项目参考库', + content: `已为您将私密推演文档《${targetDoc?.title || '文档'}》一键共享至【项目协作参考库】!项目全员均可作为方案参考进行查阅与批注。您作为创建者,可随时点击「取消共享」恢复为个人私密状态。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + }; + + const handleRevokeShare = async (docId: string) => { + try { + if (docId.startsWith('doc-free-')) { + await unshareFreeformDoc(docId); + } + } catch (e) { + console.error('Unshare doc error:', e); + } + setDeliverables((prev) => + prev.map((d) => (d.docId === docId ? { ...d, category: 'private', space: 'my_private', sharedAt: undefined } : d)), + ); + setDocCache((prev) => { + if (!prev[docId]) return prev; + return { + ...prev, + [docId]: { ...prev[docId], category: 'private', space: 'my_private', sharedAt: undefined }, + }; + }); + setActiveCategory('private'); + const targetDoc = deliverables.find((d) => d.docId === docId); + setMessages((prev) => [ + ...prev, + { + id: 'ast-revoke-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🔒 撤回共享至个人私密', + content: `已撤回共享!《${targetDoc?.title || '文档'}》已从项目协作库移除并恢复为您的【个人私密文档】,其他团队成员在界面与大盘中已不可见。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + }; + + const handleDeleteDoc = async (docId: string) => { + try { + if (docId.startsWith('doc-free-')) { + await deleteFreeformDoc(docId); + } + } catch (e) { + console.error('Delete doc error:', e); + } + setDeliverables((prev) => prev.filter((d) => d.docId !== docId)); + if (activeDocId === docId) { + const remaining = deliverables.filter((d) => d.docId !== docId); + const fallback = remaining.find((d) => d.category === 'workflow') || remaining[0]; + if (fallback) { + setActiveDocId(fallback.docId); + setActiveCategory(fallback.category); + } + } + }; + + const handleSaveMessageToPrivate = async (message: ChatMessage) => { + try { + const serverId = message.serverId || 0; + let resData; + if (serverId > 0) { + try { + const resp = await saveMessageToPrivate( + serverId, + currentProjectId, + undefined, + message.contentSha256, + ); + resData = resp.data; + } catch (serverErr: unknown) { + console.warn('服务端自由文档落库失败,降级为本地草稿保存:', serverErr); + } + } + + const docId = resData?.document.doc_id || `doc-free-${Date.now()}`; + const title = resData?.document.title || deriveReplyTitle(message.content, message.timestamp); + const markdown = resData?.content_markdown || message.content; + const nowStr = new Date().toISOString().replace('T', ' ').substring(0, 16); + + const newDocItem: DeliverableItem = { + docId, + projectId: currentProjectId, + taskKey: 'freeform_private', + title, + stage: 'V0.1', + category: 'private', + creatorId: currentUser.id, + creatorName: currentUser.name, + isFixedWorkflow: false, + isTemplateOnly: false, + lifecycleState: 'drafting', + isClaimed: true, + author: currentUser.name, + reviewer: '', + approver: '', + updatedAt: nowStr, + summary: message.content.slice(0, 120), + governanceMode: 'freeform', + space: 'my_private', + }; + + const newAST = markdownToBlockAST(markdown, { + docId, + title, + taskKey: 'freeform_private', + version: `${resData?.document.current_version_id || 1}`, + category: 'private', + creatorId: currentUser.id, + creatorName: currentUser.name, + lifecycleState: 'drafting', + isClaimed: true, + author: currentUser.name, + reviewer: '', + approver: '', + }); + newAST.governanceMode = 'freeform'; + newAST.space = 'my_private'; + + setDeliverables((prev) => { + const exists = prev.some((d) => d.docId === docId); + if (exists) return prev; + return [newDocItem, ...prev]; + }); + + setDocCache((prev) => ({ + ...prev, + [docId]: newAST, + })); + + setActiveDocId(docId); + setActiveCategory('private'); + setDeckDrawerOpen(false); + } catch (err) { + console.error('保存到我的私密失败:', err); + throw err; + } + }; + + return { + handleShareToProject, + handleRevokeShare, + handleDeleteDoc, + handleSaveMessageToPrivate, + }; +} diff --git a/web/src/hooks/useProjectMemberActions.test.ts b/web/src/hooks/useProjectMemberActions.test.ts new file mode 100644 index 00000000..a3ed812a --- /dev/null +++ b/web/src/hooks/useProjectMemberActions.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('project member writes live in one hook before the login return', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useProjectMemberActions.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useProjectMemberActions\(/g) || []).length, 1); + assert.equal(app.indexOf('useProjectMemberActions(') < app.indexOf('if (!session)'), true); + assert.equal(app.includes('afterProjectMemberPersisted'), false); + assert.equal(hook.includes('afterProjectMemberPersisted'), true); + assert.equal(hook.includes('removeProjectMember'), true); + assert.equal(hook.includes("status: 'active'"), true); +}); diff --git a/web/src/hooks/useProjectMemberActions.ts b/web/src/hooks/useProjectMemberActions.ts new file mode 100644 index 00000000..e93648a8 --- /dev/null +++ b/web/src/hooks/useProjectMemberActions.ts @@ -0,0 +1,152 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { + addProjectMember, + removeProjectMember, + updateProjectMember, +} from '../api/projectMembers'; +import { buildPrivateSessionId } from '../data/storage'; +import { afterProjectMemberPersisted } from '../lib/afterProjectMemberPersisted'; +import { buildManagedMemberUpsertPayload } from '../lib/createProjectMembersPersist'; +import type { + ChatMessage, + ProjectInfo, + ProjectMember, + ProjectMemberRole, + ProjectSession, +} from '../types/workbench'; + +export interface ProjectMemberActionsInput { + currentProject: ProjectInfo | undefined; + syncProjectMembersFromServer: (projectId: number, creatorId: number) => Promise; + setWorkflowDocsEpoch: Dispatch>; + setSessions: Dispatch>; + setMessages: Dispatch>; + setProjectMembersError: Dispatch>; +} + +export interface ProjectMemberActions { + handleAddProjectMember: (newMember: Omit) => Promise; + handleUpdateProjectMemberRole: (userId: number, newRole: ProjectMemberRole) => Promise; + handleRemoveProjectMember: (userId: number) => Promise; + handleRestoreProjectMember: (userId: number) => Promise; +} + +export function useProjectMemberActions(input: ProjectMemberActionsInput): ProjectMemberActions { + const { + currentProject, + syncProjectMembersFromServer, + setWorkflowDocsEpoch, + setSessions, + setMessages, + setProjectMembersError, + } = input; + + const handleAddProjectMember = async (newMember: Omit) => { + if (!currentProject) return; + try { + await addProjectMember( + currentProject.id, + buildManagedMemberUpsertPayload( + { userId: newMember.userId, role: newMember.role }, + currentProject.creatorId, + ), + ); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), + }); + + const newSessId = buildPrivateSessionId(`p${currentProject.id}`, newMember.userId); + setSessions((prev) => { + const exists = prev.some( + (s) => s.projectId === currentProject.id && s.userId === newMember.userId, + ); + if (exists) return prev; + return [ + { + sessionId: newSessId, + userId: newMember.userId, + projectId: currentProject.id, + title: currentProject.title, + deviceType: currentProject.deviceType, + stage: 'V0.1', + stageLabel: 'V0.1 需求摸底', + messageCount: 1, + updatedAt: '刚刚', + }, + ...prev, + ]; + }); + + setMessages((prev) => [ + ...prev, + { + id: `m-join-${newMember.userId}-${Date.now()}`, + sessionId: newSessId, + role: 'system', + content: `【项目协同通知】您已被项目创建人 ${currentProject.creatorName} 加入项目《${currentProject.title}》,并分配项目角色为【${newMember.role}】。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } catch (err) { + setProjectMembersError(err instanceof Error ? err.message : '添加成员失败'); + throw err; + } + }; + + const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => { + if (!currentProject) return; + try { + await updateProjectMember( + currentProject.id, + buildManagedMemberUpsertPayload({ userId, role: newRole }, currentProject.creatorId), + ); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), + }); + } catch (err) { + setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败'); + } + }; + + const handleRemoveProjectMember = async (userId: number) => { + if (!currentProject) return; + try { + await removeProjectMember(currentProject.id, userId); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), + }); + } catch (err) { + setProjectMembersError(err instanceof Error ? err.message : '移出成员失败'); + } + }; + + const handleRestoreProjectMember = async (userId: number) => { + if (!currentProject) return; + try { + const member = currentProject.members.find((m) => m.userId === userId); + await updateProjectMember( + currentProject.id, + buildManagedMemberUpsertPayload( + { userId, role: member?.role ?? 'member', status: 'active' }, + currentProject.creatorId, + ), + ); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), + }); + } catch (err) { + setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败'); + } + }; + + return { + handleAddProjectMember, + handleUpdateProjectMemberRole, + handleRemoveProjectMember, + handleRestoreProjectMember, + }; +} diff --git a/web/src/hooks/useSendMessage.test.ts b/web/src/hooks/useSendMessage.test.ts new file mode 100644 index 00000000..8722c899 --- /dev/null +++ b/web/src/hooks/useSendMessage.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('sending a message lives in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useSendMessage.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useSendMessage\(/g) || []).length, 1); + assert.equal(app.includes('附件服务端注册失败'), false); + assert.equal(hook.includes('registerUploadAttachments'), true); + assert.equal(hook.includes('runTaskAttempt'), true); + assert.equal(hook.includes('runStreamAttempt'), true); + assert.equal(hook.includes('失败硬拦'), true); + assert.equal(hook.includes('handlePassReview'), true); +}); diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts new file mode 100644 index 00000000..835edf2e --- /dev/null +++ b/web/src/hooks/useSendMessage.ts @@ -0,0 +1,607 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { createFreeformDoc } from '../api/freeformDoc'; +import { proposeModification } from '../api/docgov'; +import { uploadProjectAsset } from '../api/projectAssets'; +import { saveFile } from '../data/fileStore'; +import { + createRejectConfirmationCard, + decrementActiveCardsTtl, + findLastActiveCard, + findMatchingBlockForReview, + isAffirmativeResponse, +} from '../engine/cardActionEngine'; +import { markdownToBlockAST } from '../engine/markdownToBlock'; +import { resolveTaskTemplateForMessage } from '../engine/templateMatch'; +import { createClientTurnId } from '../lib/createClientTurnId'; +import { canonicalDocIdForTask } from '../lib/controlledDocHydrate'; +import { holdsDocDuty } from '../lib/docGovActionVisibility'; +import { resolveSessionProjectId } from '../lib/sessionAdapter'; +import { registerUploadAttachments } from '../lib/uploadFreeformRegister'; +import type { DocumentAST } from '../types/block'; +import type { UserProfile, UserRole } from '../types/docgov'; +import type { SendMessageResult } from '../types/sendMessage'; +import type { + ChatMessage, + DeliverableCategory, + DeliverableItem, + ProjectSession, + QuotedMessage, + TaskRunContext, + TaskRunMaterial, +} from '../types/workbench'; + +/** + * 将附件转为任务材料:二进制先上传 CAS 登记 asset_id;小文本可 inline Base64。 + */ +async function readAttachmentsAsMaterials( + attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[], + projectId?: number, +): Promise { + const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']); + const results: TaskRunMaterial[] = []; + for (const att of attachments) { + if (att.assetId && att.assetId > 0) { + results.push({ filename: att.name, asset_id: att.assetId }); + continue; + } + if (!att.fileRaw) continue; + const extMatch = att.name.match(/\.([^.]+)$/); + const ext = extMatch ? extMatch[1].toLowerCase() : ''; + if (!TEXT_EXTS.has(ext)) { + if (!projectId || projectId <= 0) { + throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`); + } + const uploaded = await uploadProjectAsset(projectId, att.fileRaw as Blob, att.name); + results.push({ filename: uploaded.filename || att.name, asset_id: uploaded.asset_id }); + continue; + } + try { + const content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result as string; + const b64 = btoa(unescape(encodeURIComponent(text))); + resolve(b64); + }; + reader.onerror = () => reject(reader.error); + reader.readAsText(att.fileRaw as Blob, 'utf-8'); + }); + results.push({ filename: att.name, content }); + } catch { + throw new Error(`读取文本附件 ${att.name} 失败`); + } + } + return results; +} + +export interface PendingProposal { + proposalId: string; + changes: string; + impact: string; + risk: string; + patchBlockId?: string; +} + +export interface SendMessageInput { + currentProjectId: number; + currentUser: UserProfile; + activeSessionId: string; + activeDocId: string; + activeDocIdRef: MutableRefObject; + messages: ChatMessage[]; + docAST: DocumentAST; + docCache: Record; + currentDocRole: UserRole; + deliverables: DeliverableItem[]; + visibleDeliverables: DeliverableItem[]; + currentProjectDeliverables: DeliverableItem[]; + sessions: ProjectSession[]; + setDeliverables: Dispatch>; + setDocCache: Dispatch>>; + setActiveDocId: Dispatch>; + setActiveCategory: Dispatch>; + setActiveQuotes: Dispatch>; + setMessages: Dispatch>; + setProposalError: Dispatch>; + setPendingProposal: Dispatch>; + taskRunStream: { setThinking: (sessionId: string, thinking: boolean) => void }; + handleCardAction: (cardId: string, actionId: string, payload?: any) => void; + runTaskAttempt: (task: TaskRunContext, asstId: string, mode: 'run', attemptIndex: number) => void; + runStreamAttempt: ( + userText: string, + asstId: string, + attemptIndex: number, + opts?: { userMsgId?: string; clientTurnId?: string; quote?: QuotedMessage }, + ) => void; + handlePassReview: () => Promise; + handleApprove: () => Promise; +} + +export function useSendMessage(input: SendMessageInput) { + const { + currentProjectId, + currentUser, + activeSessionId, + activeDocId, + activeDocIdRef, + messages, + docAST, + docCache, + currentDocRole, + deliverables, + visibleDeliverables, + currentProjectDeliverables, + sessions, + setDeliverables, + setDocCache, + setActiveDocId, + setActiveCategory, + setActiveQuotes, + setMessages, + setProposalError, + setPendingProposal, + taskRunStream, + handleCardAction, + runTaskAttempt, + runStreamAttempt, + handlePassReview, + handleApprove, + } = input; + + return async ( + text: string, + attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[], + quotes?: QuotedMessage[] + ): Promise => { + const trimmed = text.trim(); + let clientTurnId: string; + try { + clientTurnId = createClientTurnId(); + } catch (err) { + return { + accepted: false, + error: err instanceof Error ? err.message : '无法生成发送标识,请重试', + }; + } + + // 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退 + let processedAttachments = attachments; + if (attachments && attachments.length > 0) { + let registered; + try { + registered = await registerUploadAttachments({ + attachments, + projectId: currentProjectId, + userId: currentUser.id, + userName: currentUser.name, + createFreeformDoc, + }); + } catch (err) { + return { + accepted: false, + error: err instanceof Error ? err.message : '附件服务端注册失败,请重试', + }; + } + + const newDeliverablesList = registered.map((r) => r.deliverable); + const newDocCacheEntries: Record = {}; + for (const r of registered) { + const baseTitle = r.deliverable.title; + const initialMd = r.detail.content_markdown; + const newAST = markdownToBlockAST(initialMd, { + docId: r.serverDocId, + title: baseTitle, + taskKey: 'user_requirements', + version: 'V1.0', + category: 'private', + creatorId: currentUser.id, + creatorName: currentUser.name, + lifecycleState: 'drafting', + isClaimed: true, + author: currentUser.name, + reviewer: '', + approver: '', + }); + newAST.governanceMode = 'freeform'; + newAST.space = 'my_private'; + newDocCacheEntries[r.serverDocId] = newAST; + if (r.attachment.fileRaw) { + saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) => + console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e), + ); + } + } + + processedAttachments = registered.map((r) => r.attachment); + const newDocIds = new Set(newDeliverablesList.map((d) => d.docId)); + setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]); + setDocCache((prev) => ({ + ...prev, + ...newDocCacheEntries, + })); + + if (processedAttachments.length > 0 && processedAttachments[0].docId) { + setActiveDocId(processedAttachments[0].docId); + setActiveCategory('private'); + } + } + + const userMsg: ChatMessage = { + id: 'msg-' + Date.now(), + clientTurnId, + clientMessageId: `${clientTurnId}:user`, + sessionId: activeSessionId, + role: 'user', + authorName: currentUser.name, + content: text, + timestamp: new Date().toTimeString().substring(0, 5), + attachments: processedAttachments, + quotes: quotes && quotes.length > 0 ? quotes : undefined, + }; + setActiveQuotes([]); + + // 检查是否为肯定回复(双轨同效判定:打字回复等同于点击卡片默认动作) + const isAffirmative = isAffirmativeResponse(trimmed); + const lastActive = findLastActiveCard(messages, activeSessionId); + + if (isAffirmative && lastActive) { + const { card } = lastActive; + const defaultAction = card.actions.find((a) => a.isAffirmativeDefault) || card.actions[0]; + if (defaultAction) { + setMessages((prev) => [...prev, userMsg]); + handleCardAction(card.cardId, defaultAction.actionId, defaultAction.payload); + return { accepted: true }; + } + } + + // 递减消息列表中尚未完结的 active 卡片 ttlTurns,若 <= 0 则标记为 expired + setMessages((prev) => [...decrementActiveCardsTtl(prev, activeSessionId), userMsg]); + + // 命中以下任一演示用关键词捷径时才走本地模拟分支 (与真实后端 SSE 路径互斥,判定条件与下方分支体一一对应) + const isGenDocShortcut = + text.includes('生成文档') || + text.includes('起草文档') || + text.includes('测算') || + text.includes('推演') || + text.includes('备忘') || + text.includes('生成报告'); + const isRejectShortcut = text.includes('驳回') || text.includes('退回'); + const isModifyShortcut = + (text.includes('修改') || text.includes('更新') || text.includes('调整')) && + docAST.isClaimed && + currentDocRole === 'author'; + const isReviewPassShortcut = + (text.includes('通过') || text.includes('同意')) && + ((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); + const resolvedTask = resolveTaskTemplateForMessage( + text, + currentProjectDeliverables, + sendActiveDoc, + ); + const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut; + + // P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复) + if (resolvedTask) { + const asstId = 'ast-' + Date.now(); + const currentProj = sessions.find((s) => s.sessionId === activeSessionId); + // 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续 + const clientTurnId = `turn_${Date.now()}`; + const projId = resolveSessionProjectId(currentProj, currentProjectId); + if (projId <= 0) { + console.error('[task] 无法解析会话所属项目', activeSessionId); + return { accepted: false }; + } + const taskKey = resolvedTask.taskKey; + const matchedDeliverable = deliverables.find((d) => { + if (d.projectId !== projId) return false; + if (resolvedTask.docId) return d.docId === resolvedTask.docId; + return d.taskKey === taskKey; + }); + const activeLease = matchedDeliverable?.editLease; + const leaseIsActive = + !!activeLease?.holderType && + (!activeLease.expiresAt || new Date(activeLease.expiresAt).getTime() > Date.now()); + if (leaseIsActive) { + setMessages((prev) => [ + ...prev, + { + id: 'ast-busy-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '项目助手', + content: `《${matchedDeliverable?.title || '该文档'}》正在由${activeLease?.holderType === 'squad' ? '项目助手' : '其他用户'}编辑/生成中。当前请求未启动新任务;完成后状态会自动解除。`, + timestamp: new Date().toTimeString().substring(0, 5), + status: 'completed', + }, + ]); + return { accepted: true }; + } + const baseTask: TaskRunContext = { + taskKey, + projectName: currentProj?.title || '医疗器械研发演示项目', + customerName: '北京水木东方', + deviceType: currentProj?.deviceType || '医疗器械', + sessionId: activeSessionId, + userId: currentUser.id, + projectId: projId, + clientTurnId, + userMessage: text, + docId: + resolvedTask.docId || + canonicalDocIdForTask( + projId, + taskKey, + deliverables, + matchedDeliverable?.docId, + ), + }; + const placeholder: ChatMessage = { + id: asstId, + sessionId: activeSessionId, + role: 'assistant', + authorName: '项目助手', + content: '', + timestamp: new Date().toTimeString().substring(0, 5), + status: 'pending', + livePhaseLabel: '任务执行中…', + taskRequest: baseTask, + }; + setMessages((prev) => [...prev, placeholder]); + // 异步读取附件内容后再触发任务,避免文档材料丢失 + void (async () => { + const materials = await readAttachmentsAsMaterials( + processedAttachments || [], + baseTask.projectId, + ); + const task: TaskRunContext = materials.length > 0 ? { ...baseTask, materials } : baseTask; + runTaskAttempt(task, asstId, 'run', 0); + })(); + return { accepted: true }; + } + + if (!isMockShortcut) { + // 真实后端 SSE 路径:立即挂起「等待中」占位气泡,无人为延迟,随后由 runStreamAttempt 驱动状态机 + // (pending → streaming_status → completed/failed/auto_retrying/cancelled),严禁静默兜底话术 + const asstId = 'ast-' + Date.now(); + const placeholder: ChatMessage = { + id: asstId, + clientTurnId, + clientMessageId: `${clientTurnId}:assistant`, + sessionId: activeSessionId, + role: 'assistant', + authorName: '项目助手', + content: '', + timestamp: new Date().toTimeString().substring(0, 5), + status: 'pending', + livePhaseLabel: '助手正在处理…', + sourceUserMessageId: userMsg.id, + }; + setMessages((prev) => [...prev, placeholder]); + runStreamAttempt(text, asstId, 0, { + userMsgId: userMsg.id, + clientTurnId, + quote: quotes && quotes.length > 0 ? quotes[0] : undefined, + }); + return { accepted: true }; + } + + taskRunStream.setThinking(activeSessionId, true); + + setTimeout(() => { + taskRunStream.setThinking(activeSessionId, false); + + // 1. 用户要求助手生成新文档/测算/推演/备忘 (默认自动归档至【我的私密生成】,保护隐私) + if (isGenDocShortcut) { + const newDocId = `doc-private-${Date.now()}`; + const docTitle = text.includes('备忘') + ? `${currentUser.name}专属:临床与商业推演备忘录` + : text.includes('测算') + ? `${currentUser.name}专属:BOM器件成本与毛利灵敏度测算` + : `${currentUser.name}专属:AI协同专项推演草案`; + + const newMeta: DeliverableItem = { + docId: newDocId, + projectId: resolveSessionProjectId( + sessions.find((s) => s.sessionId === activeSessionId), + currentProjectId, + ) || 1, + taskKey: 'custom_draft', + title: docTitle, + stage: 'V0.5', + category: 'private', + creatorId: currentUser.id, + creatorName: currentUser.name, + isFixedWorkflow: false, + isTemplateOnly: false, + lifecycleState: 'drafting', + isClaimed: true, + author: currentUser.name, + reviewer: '', + approver: '', + updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), + summary: `根据对话中您的需求“${text.slice(0, 60)}...”即时生成的个人私密草稿。`, + }; + + const sampleMd = `# ${docTitle}\n\n## 1. 产生背景与对话诉求\n根据用户【${currentUser.name}】于 ${new Date().toLocaleTimeString()} 提出的协同要求:“${text}”,参谋小队即时推演生成本专属文档。\n\n> 🔒 [隐私受控标记] 本文档为用户个人私密生成物,仅 ${currentUser.name} 本人可见,不进入项目固定受控审签体系。若推演成熟,可随时点击下方「一键共享给项目组」转为协作参考资料。\n\n## 2. 核心分析与推演结论\n依据系统知识库与工程模型,对关键指标与风险进行分解:\n- 技术可行性评级:A 级(具备清晰工程落地路径)\n- 建议跟进措施:优化元器件批量采购配额,规避长周期交货风险。\n`; + + const parsedAst = markdownToBlockAST(sampleMd, { + docId: newDocId, + title: docTitle, + taskKey: 'custom_draft', + version: 'V0.1', + category: 'private', + creatorId: currentUser.id, + creatorName: currentUser.name, + lifecycleState: 'drafting', + isClaimed: true, + author: currentUser.name, + }); + + setDeliverables((prev) => [newMeta, ...prev]); + setDocCache((prev) => ({ ...prev, [newDocId]: parsedAst })); + setActiveCategory('private'); + setActiveDocId(newDocId); + + setMessages((prev) => [ + ...prev, + { + id: 'ast-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🔒 个人私密文档已生成', + content: `收到您的要求,已为您即时生成专属推演文档《${docTitle}》!\n\n根据系统隐私保护机制,该文档已自动存入【我的私密】库,**仅您本人可见**。右侧工作台已为您自动切至该文档,您可以自由查看与编辑。推演成熟后,可随时点击底部「一键共享给项目组」作为团队协作参考资料。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + return; + } + + // 2. 用户发送驳回意图 (且存在处于 reviewing 的目标交付物) + if (isRejectShortcut) { + const targetReviewDoc = + docAST.lifecycleState === 'reviewing' + ? docAST + : deliverables.find((d) => d.lifecycleState === 'reviewing') + ? (docCache[deliverables.find((d) => d.lifecycleState === 'reviewing')!.docId] || docAST) + : null; + + if (targetReviewDoc && targetReviewDoc.lifecycleState === 'reviewing') { + const matchingBlock = findMatchingBlockForReview(targetReviewDoc); + const card = createRejectConfirmationCard(targetReviewDoc, matchingBlock); + + setMessages((prev) => [ + ...prev, + { + id: 'ast-reject-gate-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🛡️ 审核驳回门禁', + content: `检测到您对《${targetReviewDoc.title}》提出驳回审核意图。驳回将退回编制阶段并解除锁定,同时向编制人下达整改批注。请确认操作:`, + timestamp: new Date().toTimeString().substring(0, 5), + interactiveCard: card, + }, + ]); + return; + } else { + setMessages((prev) => [ + ...prev, + { + id: 'ast-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '⚠️ 状态校验提示', + content: `当前受控文档《${docAST.title}》处于【${docAST.lifecycleState}】阶段,非待审核状态,无需执行驳回。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + return; + } + } + + if (text.includes('修改') || text.includes('更新') || text.includes('调整')) { + if (docAST.isClaimed && currentDocRole === 'author') { + 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('同意')) { + 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, + { + id: 'ast-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '📋 审核通过流转', + content: `已确认审核通过!《${docAST.title}》状态已流转至【待批准 (approving)】,已呈报王总监终审。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + return; + } + if (docAST.lifecycleState === 'approving' && holdsDocDuty(currentUser.id, 'approver', duties)) { + void handleApprove(); + return; + } + } + + // P2:模板填报演示已由真实 task stream 链路替代(见上方 matchedTemplate 分支) + }, 1000); + + return { accepted: true }; + }; +} diff --git a/web/src/hooks/useServerDocumentSync.test.ts b/web/src/hooks/useServerDocumentSync.test.ts new file mode 100644 index 00000000..01beca62 --- /dev/null +++ b/web/src/hooks/useServerDocumentSync.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('server document list sync lives in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useServerDocumentSync.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useServerDocumentSync\(/g) || []).length, 1); + assert.equal(app.includes('获取服务端自由文档列表异常'), false); + assert.equal(app.includes('工作流受控文档同步失败'), false); + assert.equal(hook.includes('listFreeformDocs'), true); + assert.equal(hook.includes('mergeWorkflowDocumentsFromServer'), true); + assert.equal(hook.includes('applyLeaseUpdates'), true); + assert.equal(hook.includes('失败不得调用 merge'), true); +}); diff --git a/web/src/hooks/useServerDocumentSync.ts b/web/src/hooks/useServerDocumentSync.ts new file mode 100644 index 00000000..5de4103e --- /dev/null +++ b/web/src/hooks/useServerDocumentSync.ts @@ -0,0 +1,181 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { listFreeformDocs } from '../api/freeformDoc'; +import { listProjectWorkflowDocuments } from '../api/projectDocuments'; +import { applyLeaseUpdates, sameLease } from '../lib/deliverableLeaseSync'; +import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync'; +import { + controlledHydrateCandidateIds, + planControlledContentInvalidation, + type ControlledInvalidationPlan, +} from '../lib/controlledContentInvalidation'; +import { + createEditorHydrationGate, +} from '../lib/editorHydrationGate'; +import type { LoginResult } from '../types/auth'; +import type { DocumentAST } from '../types/block'; +import type { UserProfile } from '../types/docgov'; +import type { DeliverableItem } from '../types/workbench'; + +type Gate = ReturnType; + +export interface ServerDocumentSyncInput { + session: LoginResult | null; + sessionValidated: boolean | null; + currentProjectId: number; + currentUser: UserProfile; + workflowDocsEpoch: number; + setDeliverables: Dispatch>; + setDocCache: Dispatch>>; + deliverablesRef: MutableRefObject; + activeDocIdRef: MutableRefObject; + editorHydrationGateRef: MutableRefObject; + applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void; +} + +export function useServerDocumentSync(input: ServerDocumentSyncInput): void { + const { + session, + sessionValidated, + currentProjectId, + currentUser, + workflowDocsEpoch, + setDeliverables, + setDocCache, + deliverablesRef, + activeDocIdRef, + editorHydrationGateRef, + applyControlledInvalidationPlan, + } = input; + + useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId) return; + let active = true; + const fetchDocs = async () => { + try { + const [privateSummaries, sharedSummaries] = await Promise.all([ + listFreeformDocs('my_private', currentProjectId).catch(() => []), + listFreeformDocs('project_shared', currentProjectId).catch(() => []), + ]); + if (!active) return; + const allSummaries = [...privateSummaries, ...sharedSummaries]; + if (allSummaries.length === 0) return; + + setDeliverables((prev) => { + let updated = [...prev]; + for (const s of allSummaries) { + const exists = updated.find((d) => d.docId === s.doc_id); + const category = s.space === 'my_private' ? 'private' : 'shared'; + if (!exists) { + updated.unshift({ + docId: s.doc_id, + projectId: s.project_id || currentProjectId, + taskKey: 'freeform_doc', + title: s.title, + stage: 'V0.1', + category, + creatorId: s.owner_user_id || currentUser.id, + creatorName: currentUser.name, + isFixedWorkflow: false, + isTemplateOnly: false, + lifecycleState: 'drafting', + isClaimed: true, + author: currentUser.name, + reviewer: '', + approver: '', + updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16), + governanceMode: 'freeform', + space: s.space, + }); + } + } + return updated; + }); + } catch (e) { + console.warn('获取服务端自由文档列表异常:', e); + } + }; + fetchDocs(); + return () => { + active = false; + }; + }, [session, sessionValidated, currentProjectId, currentUser.id]); + + useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return; + let active = true; + const syncWorkflowDocs = async () => { + try { + // 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。 + const workflowDocs = await listProjectWorkflowDocuments(currentProjectId); + if (!active) return; + setDeliverables((prev) => + mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser), + ); + applyControlledInvalidationPlan( + planControlledContentInvalidation({ + reason: 'list_sync', + candidateIds: controlledHydrateCandidateIds( + deliverablesRef.current, + currentProjectId, + ), + activeDocId: activeDocIdRef.current, + editorOpen: editorHydrationGateRef.current.isEditorOpen(), + }), + ); + } catch (e) { + console.warn('[deliverables] 工作流受控文档同步失败:', e); + } + }; + void syncWorkflowDocs(); + return () => { + active = false; + }; + }, [ + session, + sessionValidated, + currentProjectId, + currentUser.id, + currentUser.name, + workflowDocsEpoch, + applyControlledInvalidationPlan, + ]); + + useEffect(() => { + if (!session || sessionValidated !== true || currentProjectId <= 0) return; + let cancelled = false; + const syncLeases = async () => { + const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []); + if (cancelled || docs.length === 0) return; + const leases = new Map( + docs.map((doc) => [ + doc.doc_id, + doc.lease_holder_type + ? { + holderType: doc.lease_holder_type, + holderId: doc.lease_holder_id, + expiresAt: doc.lease_expires_at, + heartbeatAt: doc.lease_heartbeat_at, + } + : undefined, + ]), + ); + setDeliverables((prev) => applyLeaseUpdates(prev, leases, currentProjectId)); + setDocCache((prev) => { + let changed = false; + const next = { ...prev }; + leases.forEach((editLease, docId) => { + if (!next[docId]) return; + if (sameLease(next[docId].editLease, editLease)) return; + next[docId] = { ...next[docId], editLease }; + changed = true; + }); + return changed ? next : prev; + }); + }; + const timer = window.setInterval(() => void syncLeases(), 5000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [session, sessionValidated, currentProjectId]); +} diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts new file mode 100644 index 00000000..7c4e1e0a --- /dev/null +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('chat and task attempts live in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useTaskRunAttempts.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useTaskRunAttempts\(/g) || []).length, 1); + assert.equal(app.includes('streamChat('), false); + assert.equal(app.includes('createTaskRun('), false); + assert.equal(hook.includes('streamChat'), true); + assert.equal(hook.includes('createTaskRun'), true); + assert.equal(hook.includes('shouldSubscribeExistingRun'), true); + assert.equal(hook.includes('禁止无条件 setRunId(null)'), true); +}); diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts new file mode 100644 index 00000000..c10d337c --- /dev/null +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -0,0 +1,805 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { + autoRetryDelayMs, + fallbackErrorClass, + shouldAutoRetryChat, + shouldAutoRetryTask, +} from '../api/chatRetry'; +import { mapErrorInfo, streamChat, type StreamEvent } from '../api/chatStream'; +import { fetchChatTurnStatus } from '../api/chatTurn'; +import { mapTaskStatusToMessageStatus } from '../api/chatWindow'; +import { listProjectWorkflowDocuments } from '../api/projectDocuments'; +import { createTaskRun, getTaskRun, subscribeTaskRunEvents } from '../api/taskRuns'; +import { buildResumeRequest, buildTaskRequest, streamTask } from '../api/taskStream'; +import { applyPhaseEvent } from '../engine/phaseEventMapper'; +import { resolveTaskTemplateForMessage } from '../engine/templateMatch'; +import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy'; +import { + controlledHydrateCandidateIds, + planControlledContentInvalidation, + type ControlledInvalidationPlan, +} from '../lib/controlledContentInvalidation'; +import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync'; +import { createEditorHydrationGate } from '../lib/editorHydrationGate'; +import { + pollUntilPostTaskServerDoc, + shouldSwitchTemplateSlotToServer, +} from '../lib/postTaskDeliverableSync'; +import { + isRemoteTaskTerminal, + nextAttemptOpts, + resolveReconnectRunId, + shouldSubscribeExistingRun, +} from '../lib/taskRunReconnect'; +import type { DocumentAST } from '../types/block'; +import type { UserProfile, UserRole } from '../types/docgov'; +import type { + AssistantMessageStatus, + ChatErrorInfo, + ChatMessage, + DeliverableCategory, + DeliverableItem, + PhaseEvent, + ProjectSession, + QuotedMessage, + TaskRunContext, +} from '../types/workbench'; +import { useTaskRunStream } from './useTaskRunStream'; +import { useTurnRuntime } from './useTurnRuntime'; + +type Gate = ReturnType; +type TurnRuntime = ReturnType; +type TaskStream = ReturnType; + +export interface TaskRunAttemptsInput { + activeSessionId: string; + activeDocId: string; + currentProjectId: number; + currentUser: UserProfile; + currentDocRole: UserRole; + sessions: ProjectSession[]; + messages: ChatMessage[]; + deliverables: DeliverableItem[]; + visibleDeliverables: DeliverableItem[]; + currentProjectDeliverables: DeliverableItem[]; + deliverablesRef: MutableRefObject; + activeDocIdRef: MutableRefObject; + editorHydrationGateRef: MutableRefObject; + turnRuntime: TurnRuntime; + taskRunStream: TaskStream; + chatWindow: { + replaceMessageServerId: (tempId: string, serverId: number) => void; + refresh: () => Promise; + }; + setMessages: Dispatch>; + setDeliverables: Dispatch>; + setDocCache: Dispatch>>; + setActiveDocId: Dispatch>; + setActiveCategory: Dispatch>; + applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void; +} + +export function useTaskRunAttempts(input: TaskRunAttemptsInput) { + const { + activeSessionId, + activeDocId, + currentProjectId, + currentUser, + currentDocRole, + sessions, + messages, + deliverables, + visibleDeliverables, + currentProjectDeliverables, + deliverablesRef, + activeDocIdRef, + editorHydrationGateRef, + turnRuntime, + taskRunStream, + chatWindow, + setMessages, + setDeliverables, + setDocCache, + setActiveDocId, + setActiveCategory, + applyControlledInvalidationPlan, + } = input; + + const buildNetworkErrorInfo = ( + httpStatus?: number, + opts?: { errorCode?: string; detail?: string }, + ): ChatErrorInfo => { + const code = opts?.errorCode || ''; + const detail = opts?.detail || ''; + if (code === 'DOC_BUSY' || code === 'document_busy') { + return { + userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务', + code: 'DOC_BUSY', + class: 'fatal', + actions: [], + }; + } + if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') { + return { + userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试', + code, + class: 'transient', + actions: ['retry'], + }; + } + if (code === 'DOC_DUTY_FORBIDDEN') { + return { + userMessage: detail || '当前账号不是该文档任务的职责人,无法执行', + code, + class: 'fatal', + actions: ['retry'], + }; + } + if (httpStatus === 401) { + return { + userMessage: '登录已失效,请重新登录后再试', + code: 'ERR_UNAUTHENTICATED', + class: 'fatal', + actions: ['retry'], + }; + } + if (httpStatus === 403) { + if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') { + return { + userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份', + code: code || 'ERR_FORBIDDEN', + class: 'fatal', + actions: ['retry'], + }; + } + return { + userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段', + code: code || 'ERR_FORBIDDEN', + class: 'fatal', + actions: ['retry'], + }; + } + return { + userMessage: '网络连接异常,请检查网络后重试', + code: 'NETWORK_ERROR', + class: fallbackErrorClass({ network: true }), + actions: ['retry'], + }; + }; + + // 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled。 + // 闲聊路径在无 client_turn_id 幂等前禁止自动重放 POST;失败仅展示手动「重试」。 + type StreamAttemptOpts = { + userMsgId?: string; + clientTurnId?: string; + quote?: QuotedMessage; + }; + + const runStreamAttempt = ( + userText: string, + asstId: string, + attemptIndex: number, + opts?: StreamAttemptOpts + ) => { + const quote = opts?.quote; + const controller = new AbortController(); + const streamSessionId = activeSessionId; + if (opts?.clientTurnId) { + turnRuntime.registerTurn({ + clientTurnId: opts.clientTurnId, + sessionId: streamSessionId, + userMsgId: opts.userMsgId || '', + asstId, + status: 'pending', + controller, + startedAt: Date.now(), + }); + } + + const currentProj = sessions.find((s) => s.sessionId === streamSessionId); + let accumulated = ''; + let settled = false; + + const updateAsst = (patch: Partial) => { + setMessages((prev) => prev.map((m) => (m.id === asstId ? { ...m, ...patch } : m))); + }; + + const scheduleRetry = (delayMs: number) => { + setTimeout(() => { + if (controller.signal.aborted) { + updateAsst({ status: 'cancelled', livePhaseLabel: undefined }); + if (opts?.clientTurnId) { + turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'cancelled'); + } + return; + } + runStreamAttempt(userText, asstId, attemptIndex + 1, opts); + }, delayMs); + }; + + const finishTurn = (status: 'completed' | 'failed' | 'cancelled') => { + if (opts?.clientTurnId) { + turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, status); + } + }; + + const failOrRetry = (info: ChatErrorInfo) => { + settled = true; + if (shouldAutoRetryChat(info, attemptIndex)) { + const delayMs = autoRetryDelayMs(attemptIndex); + updateAsst({ + status: 'auto_retrying', + livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`, + }); + scheduleRetry(delayMs); + return; + } + updateAsst({ + status: 'failed', + errorInfo: { + ...info, + userMessage: + attemptIndex > 0 + ? `${info.userMessage}(请确认上一请求是否已在处理后再手动重试)` + : info.userMessage, + }, + livePhaseLabel: undefined, + }); + finishTurn('failed'); + }; + + updateAsst({ + status: 'pending', + livePhaseLabel: attemptIndex > 0 ? '正在手动重试…' : '助手正在处理…', + content: '', + errorInfo: undefined, + }); + if (opts?.clientTurnId) { + turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'pending'); + } + + const bindUserServerId = (serverId: number) => { + if (!opts?.userMsgId) return; + chatWindow.replaceMessageServerId(opts.userMsgId, serverId); + setMessages((prev) => + prev.map((m) => (m.id === opts.userMsgId ? { ...m, serverId } : m)) + ); + }; + + streamChat( + { + session_id: streamSessionId, + user_id: currentUser.id, + project_id: currentProj?.projectId || currentProjectId || 1, + project_name: currentProj?.title || '肌骨超声AI辅助诊断系统', + device_type: currentProj?.deviceType || '肌骨超声', + stage: currentProj?.stage || 'V0.5', + user_name: currentUser.name, + user_role: currentDocRole, + message: userText, + client_turn_id: opts?.clientTurnId, + quote: quote, + }, + { + onEvent: (ev) => { + if (controller.signal.aborted || settled) return; + switch (ev.type) { + case 'turn_ack': { + const userServerId = ev.turn?.user_message_id ?? ev.user_message_id; + if (typeof userServerId === 'number' && userServerId > 0) { + bindUserServerId(userServerId); + } + break; + } + case 'phase': + case 'status': { + const label = ev.phase?.message || ev.status?.message; + updateAsst({ status: 'streaming_status', livePhaseLabel: label || '助手正在处理…' }); + if (opts?.clientTurnId) { + turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming'); + } + break; + } + case 'delta': { + accumulated += ev.delta || ''; + updateAsst({ status: 'streaming_status', content: accumulated }); + if (opts?.clientTurnId) { + turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming'); + } + break; + } + case 'error': { + failOrRetry(mapErrorInfo(ev)); + break; + } + case 'done': { + settled = true; + const userServerId = ev.done?.user_message_id ?? ev.user_message_id; + const serverMsgId = ev.done?.message_id ?? ev.message_id ?? (ev as { messageId?: number }).messageId; + if (typeof userServerId === 'number' && userServerId > 0) { + bindUserServerId(userServerId); + } + if (typeof serverMsgId === 'number' && serverMsgId > 0) { + chatWindow.replaceMessageServerId(asstId, serverMsgId); + } + const exportCap = ev.export_capability || ev.done?.export_capability; + updateAsst({ + status: 'completed', + livePhaseLabel: undefined, + serverId: typeof serverMsgId === 'number' ? serverMsgId : undefined, + exportCapability: exportCap, + }); + finishTurn('completed'); + break; + } + default: + break; + } + }, + }, + controller.signal, + opts?.clientTurnId ? { idempotencyKey: opts.clientTurnId } : undefined + ).catch(async (_err) => { + if (settled) return; + if (controller.signal.aborted) { + updateAsst({ status: 'cancelled', livePhaseLabel: undefined }); + finishTurn('cancelled'); + return; + } + if (opts?.clientTurnId) { + try { + const turnStatus = await fetchChatTurnStatus(streamSessionId, opts.clientTurnId); + if (turnStatus?.status === 'completed' && turnStatus.assistantMessageId) { + settled = true; + if (turnStatus.userMessageId) bindUserServerId(turnStatus.userMessageId); + chatWindow.replaceMessageServerId(asstId, turnStatus.assistantMessageId); + updateAsst({ + status: 'completed', + livePhaseLabel: undefined, + serverId: turnStatus.assistantMessageId, + }); + finishTurn('completed'); + return; + } + if (turnStatus?.status === 'processing') { + updateAsst({ + status: 'streaming_status', + livePhaseLabel: '连接中断,服务端仍在处理…', + }); + if (opts?.clientTurnId) { + turnRuntime.updateTurnStatus(streamSessionId, opts.clientTurnId, 'streaming'); + } + return; + } + } catch { + // fall through to structured failure + } + } + const httpStatus = + typeof _err === 'object' && _err !== null && 'httpStatus' in _err + ? Number((_err as { httpStatus?: number }).httpStatus) + : undefined; + const errMeta = _err as { errorCode?: string; detail?: string; message?: string }; + failOrRetry( + buildNetworkErrorInfo(httpStatus, { + errorCode: errMeta.errorCode, + detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '), + }), + ); + }); + }; + + // P2:长任务尝试模式。'run' 新任务 | 'resume' 续跑失败工作区 | 'restart' 按原意图新工作区 + const runTaskAttempt = ( + task: TaskRunContext, + asstId: string, + mode: 'run' | 'resume' | 'restart', + attemptIndex: number, + opts?: { phaseEvents?: PhaseEvent[]; workDir?: string; reconnectRunId?: string } + ) => { + const sessionId = task.sessionId || activeSessionId; + let retainedRunId = resolveReconnectRunId({ + attemptIndex, + optsReconnectRunId: opts?.reconnectRunId, + }); + taskRunStream.abortSession(sessionId); + // 重连保留显式 runId;禁止无条件 setRunId(null) 导致自动重试丢失 + if (retainedRunId) { + taskRunStream.setRunId(sessionId, retainedRunId); + } else { + taskRunStream.setRunId(sessionId, null); + } + const controller = new AbortController(); + taskRunStream.setController(sessionId, controller); + + let accumulated = ''; + let settled = false; + + const patchAsst = (patch: Partial | ((m: ChatMessage) => Partial)) => { + setMessages((prev) => + prev.map((m) => (m.id === asstId ? { ...m, ...(typeof patch === 'function' ? patch(m) : patch) } : m)) + ); + }; + + const scheduleRetry = (delayMs: number) => { + setTimeout(() => { + if (controller.signal.aborted) { + patchAsst({ status: 'cancelled', livePhaseLabel: undefined }); + taskRunStream.setThinking(sessionId, false); + return; + } + runTaskAttempt(task, asstId, mode, attemptIndex + 1, nextAttemptOpts(opts, retainedRunId)); + }, delayMs); + }; + + const failOrRetry = (info: ChatErrorInfo) => { + settled = true; + if (shouldAutoRetryTask(info, attemptIndex)) { + const delayMs = autoRetryDelayMs(attemptIndex); + patchAsst({ + status: 'auto_retrying', + livePhaseLabel: `连接异常,${Math.round(delayMs / 1000)}s 后自动重试…`, + }); + scheduleRetry(delayMs); + return; + } + patchAsst({ status: 'failed', errorInfo: info, livePhaseLabel: undefined }); + taskRunStream.setThinking(sessionId, false); + }; + + const syncControlledDocsAfterTask = async (taskKey: string, preferredDocId?: string | null) => { + if (!task.projectId || task.projectId <= 0 || !taskKey) return; + const { merged, serverDocId } = await pollUntilPostTaskServerDoc({ + listDocs: () => listProjectWorkflowDocuments(task.projectId!).catch(() => []), + getDeliverables: () => deliverablesRef.current, + merge: (prev, docs) => + mergeWorkflowDocumentsFromServer(prev, docs, task.projectId!, currentUser), + projectId: task.projectId, + taskKey, + preferredDocId, + maxAttempts: 10, + delayMs: 800, + onMerged: (next) => { + deliverablesRef.current = next; + setDeliverables(next); + applyControlledInvalidationPlan( + planControlledContentInvalidation({ + reason: 'post_task', + candidateIds: controlledHydrateCandidateIds(next, task.projectId), + activeDocId: activeDocIdRef.current, + editorOpen: editorHydrationGateRef.current.isEditorOpen(), + }), + ); + }, + }); + if ( + serverDocId && + shouldSwitchTemplateSlotToServer({ + activeDocId, + deliverables: merged, + projectId: task.projectId, + taskKey, + serverDocId, + }) + ) { + setActiveDocId(serverDocId); + setActiveCategory('workflow'); + } + }; + + // 等待态:resume 保留已有 phaseEvents 续接;run/restart 重置正文与步骤条 + patchAsst({ + status: 'pending', + livePhaseLabel: + mode === 'resume' ? '正在继续任务…' : mode === 'restart' ? '正在重新开始任务…' : '任务执行中…', + errorInfo: undefined, + ...(mode === 'resume' || attemptIndex > 0 + ? {} + : { content: '', phaseEvents: [], taskWorkspaceId: undefined }), + }); + taskRunStream.setThinking(sessionId, true); + + const onEvent = (ev: StreamEvent) => { + if (controller.signal.aborted || settled) return; + switch (ev.type) { + case 'phase': { + patchAsst((m) => ({ + status: 'streaming_status', + livePhaseLabel: ev.phase?.message || m.livePhaseLabel, + phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase), + })); + break; + } + case 'status': { + if (ev.status?.message) { + patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message }); + } + break; + } + case 'delta': { + accumulated += ev.delta || ''; + patchAsst({ status: 'streaming_status', content: accumulated }); + break; + } + case 'deliverable': { + const title = ev.deliverable?.title; + if (title) { + accumulated += (accumulated ? '\n\n' : '') + `📄 已生成文档:**${title}**`; + patchAsst({ status: 'streaming_status', content: accumulated }); + } + break; + } + case 'governance': { + const governance = ev.governance; + const docId = governance?.doc_id; + if (docId) { + const leaseReleased = governance.action === 'lease_released'; + const editLease = leaseReleased + ? undefined + : { holderType: 'squad' as const, holderId: taskRunStream.activeTaskRunId || undefined }; + setDeliverables((prev) => + prev.map((doc) => (doc.docId === docId ? { ...doc, editLease } : doc)), + ); + setDocCache((prev) => { + const ast = prev[docId]; + return ast ? { ...prev, [docId]: { ...ast, editLease } } : prev; + }); + } + if (governance?.notification_msg) { + patchAsst({ + status: 'streaming_status', + livePhaseLabel: governance.notification_msg, + }); + } + break; + } + case 'error': { + failOrRetry(mapErrorInfo(ev)); + break; + } + case 'done': { + settled = true; + const finishedRunId = retainedRunId || taskRunStream.activeTaskRunId; + taskRunStream.setRunId(sessionId, null); + taskRunStream.setThinking(sessionId, false); + void (async () => { + let nextStatus: AssistantMessageStatus = 'completed'; + let preferredDocId: string | null = null; + if (finishedRunId) { + const remote = await getTaskRun(finishedRunId).catch(() => null); + if (remote?.status) { + const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant'); + if (mapped) nextStatus = mapped; + } + } + patchAsst({ + status: nextStatus, + livePhaseLabel: undefined, + taskWorkspaceId: ev.done?.workspace_id, + ...(finishedRunId ? { taskRunId: finishedRunId } : {}), + }); + // quality_failed:未过门禁稿不得当作正式受控版本激活/同步切换 + if (nextStatus === 'completed' || nextStatus === 'pending_author_confirmation') { + await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId); + } + await chatWindow.refresh().catch(() => undefined); + })(); + break; + } + default: + break; + } + }; + + const startObserver = async () => { + const body = + mode === 'resume' && opts?.workDir + ? buildResumeRequest(task, opts.workDir) + : buildTaskRequest(task); + + if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') { + // 断线重试:显式 reconnectRunId 保留;禁止 setMessages updater 取 runId + const existingRunId = resolveReconnectRunId({ + attemptIndex, + optsReconnectRunId: retainedRunId || opts?.reconnectRunId, + }); + if (attemptIndex > 0 && existingRunId) { + const remote = await getTaskRun(existingRunId).catch(() => null); + if (remote && shouldSubscribeExistingRun(remote.status)) { + retainedRunId = existingRunId; + taskRunStream.setRunId(sessionId, existingRunId); + patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' }); + await subscribeTaskRunEvents(existingRunId, { onEvent }, controller.signal); + return; + } + if (remote && isRemoteTaskTerminal(remote.status)) { + settled = true; + const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed'; + patchAsst({ + status: mapped, + livePhaseLabel: undefined, + taskWorkspaceId: remote.workspace_id, + taskRunId: existingRunId, + }); + taskRunStream.setThinking(sessionId, false); + if (mapped === 'completed' || mapped === 'pending_author_confirmation') { + await syncControlledDocsAfterTask(task.taskKey || 'intake'); + } + return; + } + } + + const created = await createTaskRun({ + ...body, + session_id: task.sessionId, + project_id: task.projectId, + user_id: task.userId, + client_turn_id: task.clientTurnId, + user_message: task.userMessage, + }); + retainedRunId = created.run_id; + taskRunStream.setRunId(sessionId, created.run_id); + patchAsst({ taskRunId: created.run_id }); + if (isBlockedGovernanceStatus(created.status)) { + settled = true; + patchAsst({ + status: 'blocked_pending_governance', + content: GOVERNANCE_BLOCKED_HINT, + livePhaseLabel: undefined, + taskRunId: created.run_id, + }); + taskRunStream.setRunId(sessionId, null); + taskRunStream.setThinking(sessionId, false); + await chatWindow.refresh().catch(() => undefined); + return; + } + await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal); + return; + } + + await streamTask(body, { onEvent }, controller.signal); + }; + + void startObserver().catch(async (_err) => { + if (settled) return; + if (controller.signal.aborted) { + patchAsst({ status: 'cancelled', livePhaseLabel: undefined }); + taskRunStream.setThinking(sessionId, false); + return; + } + const runId = retainedRunId || taskRunStream.activeTaskRunId || undefined; + if (runId) { + try { + const remote = await getTaskRun(runId); + if (remote.status === 'quality_failed') { + settled = true; + patchAsst({ + status: 'quality_failed', + livePhaseLabel: undefined, + taskWorkspaceId: remote.workspace_id, + taskRunId: runId, + }); + taskRunStream.setThinking(sessionId, false); + return; + } + if (isBlockedGovernanceStatus(remote.status)) { + settled = true; + patchAsst({ + status: 'blocked_pending_governance', + content: GOVERNANCE_BLOCKED_HINT, + livePhaseLabel: undefined, + taskWorkspaceId: remote.workspace_id, + taskRunId: runId, + }); + taskRunStream.setThinking(sessionId, false); + return; + } + if (remote.status === 'completed') { + settled = true; + patchAsst({ + status: 'completed', + livePhaseLabel: undefined, + taskWorkspaceId: remote.workspace_id, + taskRunId: runId, + }); + taskRunStream.setThinking(sessionId, false); + await syncControlledDocsAfterTask(task.taskKey || 'intake'); + return; + } + if (remote.status === 'pending_author_confirmation') { + settled = true; + patchAsst({ + status: 'pending_author_confirmation', + livePhaseLabel: undefined, + taskWorkspaceId: remote.workspace_id, + taskRunId: runId, + }); + taskRunStream.setThinking(sessionId, false); + await syncControlledDocsAfterTask(task.taskKey || 'intake'); + await chatWindow.refresh().catch(() => undefined); + return; + } + if (shouldSubscribeExistingRun(remote.status)) { + // 原 run 仍在跑:重订阅,不 POST 新 run + settled = false; + retainedRunId = runId; + patchAsst({ livePhaseLabel: '正在重连任务…', taskRunId: runId }); + await subscribeTaskRunEvents(runId, { onEvent }, controller.signal); + return; + } + if (remote.status === 'failed' || remote.status === 'cancelled') { + failOrRetry(buildNetworkErrorInfo()); + return; + } + } catch { + // fall through to network retry path + } + } + const httpStatus = + typeof _err === 'object' && _err !== null && 'httpStatus' in _err + ? Number((_err as { httpStatus?: number }).httpStatus) + : undefined; + const errMeta = _err as { errorCode?: string; detail?: string; message?: string }; + failOrRetry( + buildNetworkErrorInfo(httpStatus, { + errorCode: errMeta.errorCode, + detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '), + }), + ); + }); + }; + + // 结构化错误 / 已停止会话上的用户手动恢复动作 (P2:retry / continue / restart 分型恢复) + const handleErrorAction = (messageId: string, action: 'retry' | 'continue' | 'restart') => { + const target = messages.find((m) => m.id === messageId); + if (!target) return; + + // 长任务路径:按 taskRequest 重发 / 续跑工作区 / 按原意图重启 + if (target.taskRequest) { + if (action === 'retry') { + const userText = target.taskRequest.userMessage || ''; + const retryActive = + deliverables.find((d) => d.docId === activeDocId) || + visibleDeliverables.find((d) => d.docId === activeDocId); + const reResolved = resolveTaskTemplateForMessage( + userText, + currentProjectDeliverables, + retryActive, + ); + const retryTask = reResolved + ? { + ...target.taskRequest, + taskKey: reResolved.taskKey, + docId: reResolved.docId || target.taskRequest.docId, + } + : target.taskRequest; + runTaskAttempt(retryTask, messageId, 'run', 0); + } else if (action === 'continue') { + const wsId = target.errorInfo?.workspaceId || target.taskWorkspaceId; + if (!wsId) return; + runTaskAttempt(target.taskRequest, messageId, 'resume', 0, { + phaseEvents: target.phaseEvents, + workDir: wsId, + }); + } else { + if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return; + runTaskAttempt(target.taskRequest, messageId, 'restart', 0); + } + return; + } + + // 闲聊路径(P1 行为不变:仅 retry) + if (action !== 'retry') return; + const sourceMsg = target.sourceUserMessageId + ? messages.find((m) => m.id === target.sourceUserMessageId) + : undefined; + const originalText = sourceMsg?.content?.trim(); + if (!originalText) return; + runStreamAttempt(originalText, messageId, 0, { + userMsgId: sourceMsg?.id, + clientTurnId: target.clientTurnId || sourceMsg?.clientTurnId, + }); + }; + + return { runStreamAttempt, runTaskAttempt, handleErrorAction }; +} 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/hooks/useUnreadMarks.ts b/web/src/hooks/useUnreadMarks.ts index e244c684..b73ae5ce 100644 --- a/web/src/hooks/useUnreadMarks.ts +++ b/web/src/hooks/useUnreadMarks.ts @@ -185,6 +185,8 @@ export function useUnreadMarks( // 0. 本地持久化记录已读 saveLocalReadId(sessionId, targetId); + const marksSnapshot = unreadMarks; + // 1. 乐观更新未读集合 const remainingIds = unreadMessageIds.filter((id) => id !== targetId); setUnreadMessageIds(remainingIds); @@ -217,7 +219,7 @@ export function useUnreadMarks( // 2. 异步上报后端已读位点 try { - await fetch('/api/v1/chat/read', { + const res = await fetch('/api/v1/chat/read', { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ @@ -226,12 +228,20 @@ export function useUnreadMarks( read_message_ids: [targetId], }), }); + if (!res.ok) { + throw new Error(`mark read failed: ${res.status}`); + } } catch { - // 错误时不强行重滚,下次拉取自愈 + // 上报失败回滚乐观更新,避免前后端未读数长期分叉 + setUnreadMessageIds(unreadMessageIds); + setNextMessageId(nextMessageId); + setUnreadTotal(unreadTotal); + setUnreadMarks(marksSnapshot); + setIsAllReadCompleted(false); } return targetId; - }, [sessionId, userId, nextMessageId, unreadMessageIds, unreadTotal]); + }, [sessionId, userId, nextMessageId, unreadMessageIds, unreadTotal, unreadMarks]); // 批量标记已读 (如滚动进入视口阅读或一键已读) const markMessagesRead = useCallback( diff --git a/web/src/hooks/useWorkbenchPersistence.test.ts b/web/src/hooks/useWorkbenchPersistence.test.ts new file mode 100644 index 00000000..a6006c3b --- /dev/null +++ b/web/src/hooks/useWorkbenchPersistence.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('App persists workbench state through one hook and migrates legacy messages once', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useWorkbenchPersistence.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useWorkbenchPersistence\(/g) || []).length, 1); + assert.equal(app.includes('migrateAndClearLegacyMessages'), false); + assert.equal(app.includes('saveSessions('), false); + assert.equal(app.includes('saveProjects('), false); + for (const name of ['saveSessions', 'saveProjects', 'saveDeliverables', 'saveUsers', 'migrateAndClearLegacyMessages']) { + assert.equal(hook.includes(name), true, `hook missing ${name}`); + } + assert.equal((hook.match(/migrateAndClearLegacyMessages/g) || []).length, 2); +}); diff --git a/web/src/hooks/useWorkbenchPersistence.ts b/web/src/hooks/useWorkbenchPersistence.ts new file mode 100644 index 00000000..253a6993 --- /dev/null +++ b/web/src/hooks/useWorkbenchPersistence.ts @@ -0,0 +1,74 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { + migrateAndClearLegacyMessages, + saveActiveSessionId, + saveDeliverables, + saveProjects, + saveSessions, + saveUsers, +} from '../data/storage'; +import { applyRestoredFileRaw, restoreFileRawToDeliverables } from '../data/fileStore'; +import type { UserProfile } from '../types/docgov'; +import type { DeliverableItem, ProjectInfo, ProjectSession } from '../types/workbench'; + +export interface WorkbenchPersistenceInput { + sessions: ProjectSession[]; + activeSessionId: string; + allUsers: UserProfile[]; + deliverables: DeliverableItem[]; + deliverablesRef: MutableRefObject; + setDeliverables: Dispatch>; + projects: ProjectInfo[]; +} + +/** + * 工作台本地缓存的写入与一次性迁移。 + * 必须在对应 useState 初始化之后调用;demo seed 清理仍留在渲染前,不能放进 effect。 + */ +export function useWorkbenchPersistence(input: WorkbenchPersistenceInput): void { + const { + sessions, + activeSessionId, + allUsers, + deliverables, + deliverablesRef, + setDeliverables, + projects, + } = input; + + useEffect(() => { + saveSessions(sessions); + }, [sessions]); + + useEffect(() => { + saveActiveSessionId(activeSessionId); + }, [activeSessionId]); + + useEffect(() => { + saveUsers(allUsers); + }, [allUsers]); + + useEffect(() => { + saveDeliverables(deliverables); + }, [deliverables]); + + useEffect(() => { + saveProjects(projects); + }, [projects]); + + useEffect(() => { + let cancelled = false; + const snapshot = deliverablesRef.current; + restoreFileRawToDeliverables(snapshot).then((restored) => { + if (cancelled || restored === snapshot) return; + setDeliverables((prev) => applyRestoredFileRaw(prev, restored)); + }); + return () => { + cancelled = true; + }; + }, [deliverablesRef, setDeliverables]); + + useEffect(() => { + void migrateAndClearLegacyMessages(); + }, []); +} 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/initialLoadMetrics.test.ts b/web/src/lib/initialLoadMetrics.test.ts new file mode 100644 index 00000000..e4b24f25 --- /dev/null +++ b/web/src/lib/initialLoadMetrics.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { countInitRequests, percentile, redactResourceUrl, summarizeResources } from './initialLoadMetrics'; + +test('redactResourceUrl drops query and origin', () => { + assert.equal( + redactResourceUrl('http://127.0.0.1:8080/api/v1/chat/sessions?project_id=1&token=secret'), + '/api/v1/chat/sessions', + ); + assert.equal(redactResourceUrl('/assets/index-abc.js'), '/assets/index-abc.js'); +}); + +test('summarizeResources flags deferred heavy chunks', () => { + const summary = summarizeResources([ + { path: '/assets/index-abc.js', initiator: 'script', durationMs: 12, transferBytes: 100, decodedBytes: 400 }, + { path: '/assets/katex-xyz.js', initiator: 'script', durationMs: 4, transferBytes: 50, decodedBytes: 200 }, + ]); + assert.equal(summary.jsCount, 2); + assert.equal(summary.transferBytes, 150); + assert.deepEqual(summary.heavyChunks, ['/assets/katex-xyz.js']); +}); + +test('countInitRequests separates member fanout from session lists', () => { + const counts = countInitRequests([ + { method: 'GET', path: '/api/v1/projects/1/members' }, + { method: 'GET', path: '/api/v1/projects/2/members' }, + { method: 'POST', path: '/api/v1/projects/2/members' }, + { method: 'GET', path: '/api/v1/chat/sessions' }, + { method: 'GET', path: '/api/v1/chat/sessions' }, + ]); + assert.equal(counts.memberGetCount, 2); + assert.equal(counts.distinctMemberProjects, 2); + assert.equal(counts.sessionListGetCount, 2); +}); + +test('percentile uses nearest-rank and stays null on empty input', () => { + assert.equal(percentile([], 75), null); + assert.equal(percentile([10, 20, 30, 40], 75), 30); +}); diff --git a/web/src/lib/initialLoadMetrics.ts b/web/src/lib/initialLoadMetrics.ts new file mode 100644 index 00000000..3a482909 --- /dev/null +++ b/web/src/lib/initialLoadMetrics.ts @@ -0,0 +1,63 @@ +/** 首次加载证据的纯函数。URL 查询串可能带会话参数,证据里只留路径。 */ + +export interface ResourceSample { + path: string; + initiator: string; + durationMs: number; + transferBytes: number; + decodedBytes: number; +} + +export interface RequestSample { + method: string; + path: string; +} + +const HEAVY_CHUNK = /katex|mermaid|docx|xlsx|AdminConsole|BlockEditor|FilePreview/i; + +export function redactResourceUrl(raw: string): string { + const noQuery = raw.split('?')[0] ?? raw; + try { + const parsed = new URL(noQuery); + return parsed.pathname || '/'; + } catch { + return noQuery.replace(/^https?:\/\/[^/]+/i, '') || '/'; + } +} + +export function percentile(values: number[], p: number): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); + return sorted[rank] ?? null; +} + +export function summarizeResources(resources: ResourceSample[]) { + const transferBytes = resources.reduce((sum, row) => sum + (row.transferBytes || 0), 0); + const js = resources.filter((row) => row.path.endsWith('.js')); + const heavy = resources.filter((row) => HEAVY_CHUNK.test(row.path)).map((row) => row.path); + return { + requestCount: resources.length, + transferBytes, + jsTransferBytes: js.reduce((sum, row) => sum + (row.transferBytes || 0), 0), + jsCount: js.length, + heavyChunks: [...new Set(heavy)], + }; +} + +export function countInitRequests(requests: RequestSample[]) { + const members = requests.filter( + (row) => row.method === 'GET' && /^\/api\/v1\/projects\/\d+\/members$/.test(row.path), + ); + const sessions = requests.filter( + (row) => row.method === 'GET' && row.path === '/api/v1/chat/sessions', + ); + const projectIds = new Set( + members.map((row) => row.path.split('/')[4]).filter((id): id is string => Boolean(id)), + ); + return { + memberGetCount: members.length, + distinctMemberProjects: projectIds.size, + sessionListGetCount: sessions.length, + }; +} diff --git a/web/src/lib/projectSettingsDuties.test.ts b/web/src/lib/projectSettingsDuties.test.ts index 7e6287c7..b902a82d 100644 --- a/web/src/lib/projectSettingsDuties.test.ts +++ b/web/src/lib/projectSettingsDuties.test.ts @@ -7,7 +7,12 @@ import { clientDutyIssues, locateDutyIssue, needsThreeMembersForDuties, + resolveClientDocTaskDuty, + toggleAuthorOnly, toggleHumanApproval, + stageStartsCollapsed, + applyStageHumanApproval, + stageApprovalKeys, } from './projectSettingsDuties'; function plan(tasks: WorkflowPlan['stages'][number]['doc_tasks']): WorkflowPlan { @@ -81,4 +86,108 @@ 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 }); + }); + + it('folds later rdev stages and batches human approval keys', () => { + assert.equal(stageStartsCollapsed(2, 0), false); + assert.equal(stageStartsCollapsed(12, 0), false); + assert.equal(stageStartsCollapsed(12, 1), true); + const tasks = [ + { task_key: 'rd_proposal', enabled: true, approval_configurable: true, duties: { author_user_id: 1, reviewer_user_id: 2, approver_user_id: 3 } }, + { task_key: 'rd_design_plan', enabled: true, approval_configurable: false, duties: {} }, + { task_key: 'rd_sw_plan', enabled: false, approval_configurable: true, duties: {} }, + ]; + assert.deepEqual(stageApprovalKeys(tasks, true), ['rd_proposal']); + const cleared = applyStageHumanApproval(tasks, false, true); + assert.equal(cleared[0].duty_policy, ''); + assert.deepEqual(cleared[0].duties, {}); + assert.equal(cleared[1].approval_configurable, false); + assert.equal(cleared[2].enabled, false); + }); }); diff --git a/web/src/lib/projectSettingsDuties.ts b/web/src/lib/projectSettingsDuties.ts index b7eb24c1..a6c08abd 100644 --- a/web/src/lib/projectSettingsDuties.ts +++ b/web/src/lib/projectSettingsDuties.ts @@ -12,17 +12,84 @@ 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 }; } +// 研发流程一个阶段常有十几份文档。第一阶段保持展开,后面任务较多的阶段先折叠。 +export function stageStartsCollapsed(taskCount: number, stageIndex: number): boolean { + return stageIndex > 0 && taskCount > 4; +} + +export function applyStageHumanApproval( + tasks: DocTaskPlan[], + managed: boolean, + planning: boolean, +): DocTaskPlan[] { + return tasks.map((task) => { + if (!task.enabled || !canToggleHumanApproval(task, planning)) return task; + return toggleHumanApproval(task, managed); + }); +} + +export function stageApprovalKeys(tasks: DocTaskPlan[], planning: boolean): string[] { + return tasks + .filter((task) => task.enabled && canToggleHumanApproval(task, planning)) + .map((task) => task.task_key); +} + +/** 切换兼任模式:开启时只保留编制人并清空审核/批准;关闭时清除 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 +102,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 +163,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 +172,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 +190,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/lib/workflowPrerequisite.test.ts b/web/src/lib/workflowPrerequisite.test.ts new file mode 100644 index 00000000..1e4b8f21 --- /dev/null +++ b/web/src/lib/workflowPrerequisite.test.ts @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { RDEV_PREREQUISITE_MESSAGE, workflowPrerequisiteMessage } from './workflowPrerequisite'; + +test('rdev selection explains the CDO prerequisite', () => { + assert.equal(workflowPrerequisiteMessage('rdev'), RDEV_PREREQUISITE_MESSAGE); + assert.match(RDEV_PREREQUISITE_MESSAGE, /CDO/); + assert.equal(workflowPrerequisiteMessage(' main '), null); + assert.equal(workflowPrerequisiteMessage('product_manager'), null); +}); diff --git a/web/src/lib/workflowPrerequisite.ts b/web/src/lib/workflowPrerequisite.ts new file mode 100644 index 00000000..b9cb71bb --- /dev/null +++ b/web/src/lib/workflowPrerequisite.ts @@ -0,0 +1,7 @@ +export const RDEV_PREREQUISITE_MESSAGE = + '请先完成商务对接流程(CDO 报价批准)后再启动研发设计控制流程'; + +export function workflowPrerequisiteMessage(workflowKey: string): string | null { + if (workflowKey.trim() === 'rdev') return RDEV_PREREQUISITE_MESSAGE; + return null; +} diff --git a/web/src/loginShellImports.test.ts b/web/src/loginShellImports.test.ts new file mode 100644 index 00000000..ab0fdd75 --- /dev/null +++ b/web/src/loginShellImports.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +function staticImports(rel: string): string[] { + const text = readFileSync(new URL(rel, import.meta.url), 'utf8'); + return text + .split('\n') + .filter((line) => /^\s*import\s/.test(line) && !line.includes('import(')); +} + +test('login shell does not statically import export, preview, admin, or chart engines', () => { + const app = staticImports('./App.tsx').join('\n'); + for (const banned of [ + 'docxRenderer', + 'xlsxRenderer', + 'downloadDocx', + 'FilePreviewPanel', + 'AdminConsoleView', + 'ChatExportModal', + 'BlockEditorModal', + "from 'docx'", + "from 'mermaid'", + "from 'xlsx'", + ]) { + assert.equal(app.includes(banned), false, `App.tsx static import still mentions ${banned}`); + } + const appText = readFileSync(new URL('./App.tsx', import.meta.url), 'utf8'); + assert.match(appText, /import\('\.\/engine\/docxRenderer'\)/); + assert.match(appText, /import\('\.\/engine\/xlsxRenderer'\)/); + assert.match(appText, /import\('\.\/features\/replyExport\/downloadDocx'\)/); + + const blocks = staticImports('./components/doc/BlockRenderer.tsx').join('\n'); + assert.equal(blocks.includes('MermaidBlockView'), false); + assert.equal(blocks.includes('MathBlockView'), false); + + const markdownDownload = readFileSync(new URL('./features/replyExport/download.ts', import.meta.url), 'utf8'); + assert.equal(markdownDownload.includes("from 'docx'"), false); + + const reached = entryFiles(); + for (const file of reached) { + const text = readFileSync(file, 'utf8').replace(/import\s*\(/g, 'dynamic('); + assert.equal(/from\s+['"]katex['"]/.test(text), false, `${file} statically imports katex`); + assert.equal(/import\s+['"]katex\//.test(text), false, `${file} statically imports katex css`); + } +}); + +function entryFiles(): string[] { + const start = fileURLToPath(new URL('./main.tsx', import.meta.url)); + const seen = new Set(); + const queue = [start]; + while (queue.length > 0) { + const current = queue.pop(); + if (!current || seen.has(current)) continue; + seen.add(current); + const text = readFileSync(current, 'utf8').replace(/import\s*\(/g, 'dynamic('); + const specifiers = [ + ...text.matchAll(/from\s+['"](\.[^'"]+)['"]/g), + ...text.matchAll(/^\s*import\s+['"](\.[^'"]+)['"]/gm), + ].map((match) => match[1]); + for (const spec of specifiers) { + const resolved = resolveSource(current, spec); + if (resolved) queue.push(resolved); + } + } + return [...seen]; +} + +function resolveSource(fromFile: string, spec: string): string | null { + const target = path.resolve(path.dirname(fromFile), spec); + const candidates = [target, `${target}.ts`, `${target}.tsx`, path.join(target, 'index.ts'), path.join(target, 'index.tsx')]; + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + try { + readFileSync(candidate); + return candidate; + } catch { + // directory or unreadable + } + } + return null; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 35c809cc..d973d091 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,6 @@ import ReactDOM from 'react-dom/client'; import App from './App'; import { ErrorBoundary } from './components/ErrorBoundary'; -import 'katex/dist/katex.min.css'; import './styles/index.css'; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/web/src/styles/fonts.css b/web/src/styles/fonts.css new file mode 100644 index 00000000..e0762ba2 --- /dev/null +++ b/web/src/styles/fonts.css @@ -0,0 +1,43 @@ +/* Self-hosted Inter + JetBrains Mono (variable wght axis, OFL-1.1). + * unicode-range mirrors Google Fonts latin / latin-ext subsets so CJK + * pages do not download latin-ext. */ + +/* Inter latin */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/inter-v20-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* Inter latin-ext */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/inter-v20-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* JetBrains Mono latin */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 100 800; + font-display: swap; + src: url('/fonts/jetbrains-mono-v24-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* JetBrains Mono latin-ext */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 100 800; + font-display: swap; + src: url('/fonts/jetbrains-mono-v24-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} diff --git a/web/src/styles/index.css b/web/src/styles/index.css index bae4c489..3109751b 100644 --- a/web/src/styles/index.css +++ b/web/src/styles/index.css @@ -1,3 +1,4 @@ +@import './fonts.css'; @import './tokens.css'; * { diff --git a/web/src/styles/noExternalFonts.test.ts b/web/src/styles/noExternalFonts.test.ts new file mode 100644 index 00000000..0c0a957d --- /dev/null +++ b/web/src/styles/noExternalFonts.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +describe('no external Google Fonts on login shell', () => { + it('index.html has no fonts.googleapis / fonts.gstatic', () => { + const html = readFileSync(join(root, 'index.html'), 'utf8'); + assert.equal(html.includes('fonts.googleapis.com'), false); + assert.equal(html.includes('fonts.gstatic.com'), false); + assert.match(html, /rel="preload"[^>]*href="\/fonts\/inter-v20-latin\.woff2"/s); + }); + + it('styles reference self-hosted fonts.css', () => { + const css = readFileSync(join(root, 'src/styles/index.css'), 'utf8'); + assert.match(css, /@import\s+'\.\/fonts\.css'/); + const fonts = readFileSync(join(root, 'src/styles/fonts.css'), 'utf8'); + assert.match(fonts, /\/fonts\/inter-v20-latin\.woff2/); + assert.equal(fonts.includes('fonts.googleapis.com'), false); + }); +}); 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/systemAdmin.ts b/web/src/types/systemAdmin.ts new file mode 100644 index 00000000..5c2272ae --- /dev/null +++ b/web/src/types/systemAdmin.ts @@ -0,0 +1,54 @@ +/** + * 系统管理台(/api/v1/admin/system/*)契约类型。 + * 后端并行开发中,字段以此契约为准;全部端点需 admin Bearer token。 + */ + +export type SystemConfigValue = string | number | boolean; + +export interface SystemOverview { + status: string; + env: string; + agentBackend: string; + databaseReady: boolean; + schemaVersion: string; + taskStoreReady: boolean; + kbOnline: boolean; + uptimeSeconds: number; +} + +export interface SystemKbInfo { + online: boolean; + host: string; + port: number; + database: string; + user: string; + embeddingModel: string; + rerankerEnabled: boolean; + sslMode: string; +} + +export type SystemFeatureSource = 'env' | 'db' | 'default'; + +export interface SystemFeature { + key: string; + label: string; + group: string; + value: SystemConfigValue; + source: SystemFeatureSource; + requiresRestart: boolean; + description: string; +} + +export interface SystemSetting { + key: string; + label: string; + group: string; + type: string; + value: SystemConfigValue; + default: SystemConfigValue; + writable: boolean; + requiresRestart: boolean; + description: string; + updatedAt: string; + updatedBy: string; +} 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;