diff --git a/.doc/adr/2026-09-13-document-version-boundary.md b/.doc/adr/2026-09-13-document-version-boundary.md new file mode 100644 index 00000000..4430399a --- /dev/null +++ b/.doc/adr/2026-09-13-document-version-boundary.md @@ -0,0 +1,25 @@ +# ADR:文档 Version / Revision 边界与决策门 + +## 状态 + +已接受(2026-09-13,用户确认 DOCREV 整改实施计划) + +## 背景 + +受控文档此前只有当前 `output.md` 与 SHA,无法表达「智能体基线 + 后续人工修改」的业务版本语义;自由文档每次保存 +1 的 `current_version_id` 与本需求冲突。 + +## 决策 + +1. **版本边界**:模板或一次成功提交的智能体/智能体小队产物开启 `Version`;人工每次服务端成功保存只增加该 Version 内的 `Revision`,不增加面向用户的版本号。 +2. **high/critical 候选**:风险达到 `high` 或 `critical` 的 agent candidate,必须经编制负责人二次确认后才能 `CommitAgentVersion`。 +3. **首发范围**:Wave 1–5 仅覆盖受控文档;自由文档统一读模型与 UI 语义切换放在 Wave 6。 +4. **根表指针列**:统一 `ledger_*` / `head_*` 前缀;禁止与 `freeform_documents.current_version_id`(00008 每-save+1 legacy)同名承载新语义;旧列迁移时改名为 `legacy_version_id`。 +5. **Goose**:从 **00028** 起递增;不得硬占用并行分支可能冲突的旧号。 +6. **保留策略**:受控文档随项目/法规保留;自由文档默认 365 天可配置,禁止静默硬删。 +7. **发布 blocker**:方案评审与 Wave 落地阶段不擅自写入 gap-matrix;纳入发布范围后再新增行。 + +## 后果 + +- 实施必须按 Wave 0→6 顺序;禁止先上前端颜色标记再补快照事实源。 +- `document_records.version` / `content_sha256` 在兼容期由 ledger 同事务投影,稳定后退役。 +- 详细表结构、API、状态机见 [../system-design/document-revision-management.md](../system-design/document-revision-management.md)。 diff --git a/.doc/archive/ops/2026-09-13-document-revision-management-design.md b/.doc/archive/ops/2026-09-13-document-revision-management-design.md new file mode 100644 index 00000000..907472df --- /dev/null +++ b/.doc/archive/ops/2026-09-13-document-revision-management-design.md @@ -0,0 +1,61 @@ +--- +title: "文档修改、版本差异与智能体变更说明设计" +status: done +created: 2026-09-13 +updated: 2026-09-13 +owner: Codex +branch: feature/document-revision-management +worktree: "-" +mutex: docgov +--- + +# 文档修改、版本差异与智能体变更说明设计 + +## 目标 + +基于当前 DocGov、任务、块编辑器与通知实现,形成一套可直接评审和拆分实施的文档修改管理方案,覆盖人工修改标记、智能体/小队修改归属、版本边界、任意版本比较、AI 变更说明及负责人通知。 + +## 范围 + +- 交叉核对现有文档数据模型、保存链路、Agent 产物落盘、前端块编辑器、权限和通知机制。 +- 明确定义“智能体基线 + 后续人工修改”的工作副本与正式版本语义。 +- 设计数据模型、事务边界、API、前端交互、通知、权限、迁移兼容及验收矩阵。 +- 产出 audit、system-design、remediation 文档并同步文档索引与 FEATURE-MATRIX。 + +## 非目标 + +- 本任务不修改业务代码、数据库迁移或运行环境。 +- 本任务不执行共享容器重建、真实 PI E2E 或合入 `dev`。 +- 本任务不替用户决定版本保留周期、强监管电子签名策略等产品决策;方案给出推荐默认值和决策点。 + +## 步骤 + +1. [x] 完成当前实现与既有设计的证据核对。 +2. [x] 形成领域模型、版本状态机与一致性边界设计。 +3. [x] 形成前端展示、API、权限、通知与验收设计。 +4. [x] 形成分波次整改方案和实施任务拆分。 +5. [x] 更新文档索引、FEATURE-MATRIX、CURRENT 并执行文档质量检查。 + +## 验收标准 + +- [ ] 方案能逐条映射用户提出的版本边界与显示规则。 +- [ ] 能区分用户、单智能体、智能体小队及系统迁移等修改主体。 +- [ ] 并发、失败恢复、权限、审计、通知去重和大文档性能均有确定性方案。 +- [ ] API、表结构、UI 状态、迁移顺序与测试用例足以供开发直接拆单。 +- [ ] 仅产生方案文档变更,无业务代码和环境变更。 + +## 相关链接 + +- Audit: `../../audit/2026-09-13-document-revision-management-gap-analysis.md` +- System design: `../../system-design/document-revision-management.md` +- Remediation: `../../remediation/2026-09-13-document-revision-management-implementation-plan.md` +- Test report: 实施阶段补充 + +## 证据 / 备注 + +- 用户评审通过前保持 `in_progress`;本轮交付的是可评审方案,不代表能力已实现。 +- 2026-09-13:`git diff --check` PASS;新增文档及新增链接均可解析。 +- 索引文件中另有 3 个与本任务无关的既有失效链接(两个历史 `.tmp` 目录、一个 `architecture-design.md` 相对路径),本任务未扩大范围修改。 +- 2026-09-13:独立复审(Cline)通过,结论待用户最终拍板:①三项决策(智能体/模板开 Version、high/critical 强制编制负责人二次确认、首发只覆盖受控文档)均确认可行,理由与既有 `document_change_proposals` 认领授权机制一致;②抽查 DOCREV-01~09 全部 9 项源码证据属实(含 `bizdev/service.go:499-502` 通用提案文案、`:694-705` 忽略错误写哈希、`handlers.go:1027` 客户端 holder 直通、`BlockEditorModal.tsx:104` 硬编码“张工”、`saveDocumentContent` 无调用者、Goose 基线 00027);③补强 2 处设计缺口——根表指针列与既有 `freeform_documents.current_version_id` 同名冲突(改为 `ledger_*` 命名并明确旧列退役)、状态机把文档生命周期误并入版本状态(已拆为正交状态图);同步修订 system-design §4/§6.7、remediation §3/§5.2/§5.7。 + +- 2026-09-13:用户确认 DOCREV 实施计划选项 1;设计任务归档,进入 Wave 0。 diff --git a/.doc/archive/ops/2026-09-13-document-revision-wave0-contract.md b/.doc/archive/ops/2026-09-13-document-revision-wave0-contract.md new file mode 100644 index 00000000..7a33b6bb --- /dev/null +++ b/.doc/archive/ops/2026-09-13-document-revision-wave0-contract.md @@ -0,0 +1,49 @@ +--- +title: "DOCREV Wave 0:契约冻结" +status: done +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor +branch: feature/document-revision-management +worktree: "-" +mutex: docgov +--- + +# DOCREV Wave 0:契约冻结 + +## 目标 + +冻结 Version/Revision/Snapshot/Candidate/Report 契约、黄金 diff fixture、前后端类型与先红契约测试。 + +## 范围 + +- ADR 决策门落盘 +- `internal/docrevision` 契约类型、schema、testdata、契约测试 +- `web/src/features/documentRevision/types.ts` + +## 非目标 + +- 不写生产写路径;不跑容器重建;不合入 `dev` + +## 步骤 + +1. [ ] 写入 ADR 与决策门 +2. [ ] 实现契约类型、change-report schema、黄金 fixture +3. [ ] 契约测试先红后绿(随 Wave 1 内核实现变绿) +4. [ ] 前端 types 对齐 + +## 验收标准 + +- [ ] ADR 已接受且与实施计划冻结表一致 +- [ ] 黄金 diff 覆盖中文段落/表格/列表/Mermaid/移动块/no-op +- [ ] 契约测试可独立运行 + +## 相关链接 + +- ADR: `../../adr/2026-09-13-document-version-boundary.md` +- Design: `../../system-design/document-revision-management.md` +- Remediation: `../../remediation/2026-09-13-document-revision-management-implementation-plan.md` + +## 证据 / 备注 + +实施中。 diff --git a/.doc/archive/ops/2026-09-13-document-revision-wave1-6-implementation.md b/.doc/archive/ops/2026-09-13-document-revision-wave1-6-implementation.md new file mode 100644 index 00000000..b6a436f3 --- /dev/null +++ b/.doc/archive/ops/2026-09-13-document-revision-wave1-6-implementation.md @@ -0,0 +1,45 @@ +--- +title: "DOCREV Wave 1–6:ledger 实施" +status: done +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor +branch: feature/document-revision-management +worktree: "-" +mutex: docgov +--- + +# DOCREV Wave 1–6:ledger 实施 + +## 目标 + +在 feature 分支交付快照内核、人工 PATCH、Agent candidate、比较 UI、通知与自由文档兼容。 + +## 范围 + +- Goose 00028、`internal/docrevision`、HTTP API、前端工具条、bizdev/freedoc/notification 接线 + +## 非目标 + +- 本任务不占集成槽位合入 `dev`;真实 PI 全链路补录另开集成窗口 + +## 步骤 + +1. [x] Wave 0 契约与 ADR +2. [x] Wave 1 迁移与内核 +3. [x] Wave 2 人工编辑 API/UI +4. [x] Wave 3 Agent candidate/commit +5. [x] Wave 4 比较 UI +6. [x] Wave 5 通知 +7. [x] Wave 6 自由文档兼容 + +## 验收标准 + +- [x] `go test -short ./internal/docrevision/...` PASS +- [x] 相关包回归 PASS +- [ ] 真实 PI + Playwright 双用户(集成槽位) + +## 相关链接 + +- Test report: `../../test-reports/2026-09-13-document-revision-management-test-report.md` +- ADR: `../../adr/2026-09-13-document-version-boundary.md` diff --git a/.doc/audit/2026-09-13-document-revision-management-gap-analysis.md b/.doc/audit/2026-09-13-document-revision-management-gap-analysis.md new file mode 100644 index 00000000..dc6c1e7f --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-management-gap-analysis.md @@ -0,0 +1,105 @@ +--- +title: "文档修改、版本差异与智能体变更说明能力缺口分析" +status: done +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# 文档修改、版本差异与智能体变更说明能力缺口分析 + +## 1. 结论 + +系统已有受控文档生命周期、编辑租约、正文 SHA-256、审计日志、智能体变更提案、自由文档版本表和阶段通知 Outbox,可作为本能力的基础;但当前尚不存在可信的“受控文档版本库”。受控正文仍以工作区单个 `output.md` 为事实源,人工或智能体更新会覆盖现状,无法复原任一历史正文,也无法可靠回答“谁在何时基于哪个智能体版本改了什么”。 + +本需求不能通过前端缓存一份旧正文后做颜色标记来完成。必须把不可变正文快照、以智能体生成为边界的版本周期、每次已提交人工修改、精确 diff、智能体变更报告和负责人通知放入同一个服务端一致性模型。 + +## 2. 用户规则的确定性解释 + +用户描述中的“版本以智能体修改为准”按以下语义实现: + +1. 模板或一次成功提交的智能体/智能体小队产物,是一个版本周期的 `baseline`。 +2. 此后所有人工保存都形成该版本下的 `revision`,改变当前 `head`,但不增加面向用户的版本号。 +3. 下次智能体基于当时的 `head` 生成成功后,旧版本以该 `head` 封存;智能体输出成为新版本的 `baseline`。 +4. “人工修改标记”永远计算为当前版本 `baseline → head`。智能体新版本生效后该标记归零,后续只显示相对新 baseline 的人工修改。 +5. 两个版本的默认比较使用各版本的最终内容:已封存版本取 `sealed_head`,当前开放版本取实时 `head`;智能体自身的改动单独用“上一版本最终内容 → 新版本 baseline”表达。 + +该解释既满足“模板/上一次智能体生成 + 下一次智能体生成前人工修改为一个版本”,也不会把每次自动保存误展示成一个正式版本。 + +## 3. 已确认的现有基础 + +| 能力 | 当前证据 | 可复用部分 | +|---|---|---| +| 受控文档元数据 | `deploy/postgres/goose/00001_baseline.sql:230-280` | `document_records`、三权、状态、当前 SHA、版本号 | +| 编辑互斥 | `internal/docgov/lease.go:41-180` | human/squad 租约、心跳和忙状态 | +| 人工保存入口 | `internal/docgov/lease.go:290-360` | 编制人/状态校验与 `expected_sha256` 乐观锁 | +| 智能体修改授权 | `internal/docgov/engine.go:448-577`、`internal/bizdev/service.go:451-538` | 已认领文档先提案、编制人裁决 | +| 智能体执行归属 | `internal/squad/models.go:35-95,216-226` | 小队角色、步骤、manifest 与工作区信息 | +| 自由文档版本 | `internal/freedoc/service.go:416-535` | 事务、`FOR UPDATE`、每次保存快照与 If-Match | +| 文档血缘 | `deploy/postgres/goose/00010_2026-09-11-project-workflows-and-lineage.sql:66-84` | 上下游文档版本、哈希和 stale 传播 | +| 可靠通知基础 | `deploy/postgres/goose/00018_2026-09-12-outbox-notifications-stage-advance.sql`、`internal/notification/service.go` | Outbox、用户通知、去重键 | +| 右侧块编辑器 | `web/src/components/doc/BlockRenderer.tsx`、`BlockEditorModal.tsx` | 块级阅读、编辑、批注和右侧栏布局 | + +## 4. 已确认缺口 + +| ID | 缺口 | 当前证据 | 后果 | +|---|---|---|---| +| DOCREV-01 | 受控正文没有不可变历史快照 | `ReadDocumentContent` 只读取当前工作区 `output.md`;`document_records.version` 没有对应的受控版本表 | 不能查看旧版本、恢复证据或验证历史哈希 | +| DOCREV-02 | 人工保存只覆盖文件和当前哈希 | `SaveDocumentContent` 写 `output.md` 后更新 `document_records`,不增加版本/修订记录;数据库错误还被忽略 | 无法记录具体修改人和前后正文;文件与 DB 可分裂 | +| DOCREV-03 | 前端块编辑尚未持久化 | `handleSaveBlockEdit` 只修改 React `docCache`;`saveDocumentContent` 没有调用者;编辑弹窗把修改人硬编码为“张工” | 刷新丢失、归属不可信、展示与服务端不一致 | +| DOCREV-04 | 智能体完成只更新当前哈希 | `internal/bizdev/service.go:694-705` 只调用 `SetDocumentWorkDir` 和 `MarkDocumentContentUpdated` | 不能把 run、输入快照、输出快照和版本关联 | +| DOCREV-05 | 现有提案不是实际 diff | `service.go:499-502` 在智能体执行前写入通用文案,未绑定真实 before/after | 不能作为生成完成后的改动摘要、风险或影响证据 | +| DOCREV-06 | 块锚点不稳定 | `markdownToBlockAST` 每次解析使用随机 `blk_*` ID | 刷新或换版本后不能稳定定位修改块、批注和 diff | +| DOCREV-07 | 自由文档“每次保存一个版本”与本需求语义不同 | `freedoc.UpdateDocument` 每次正文变化都 `current_version_id + 1` | 同一右侧栏内“版本”概念不一致,自动保存会制造版本噪声 | +| DOCREV-08 | 通知只覆盖阶段推进 | `notification/types.go` 只有 stage-ready 类型;前端仅 `StageAdvanceBanner` 消费 | 文档负责人不会收到智能体版本摘要、风险和影响 | +| DOCREV-09 | 内容修改链路的一致性和授权边界不足以支撑审计 | 租约 API 接受客户端 holder type/id;部分 DocGov 入口只认证后直接调用领域引擎;当前锁主要是进程内 mutex | 多实例、伪造 holder、并发提交时可能发生越权或静默覆盖 | + +## 5. 根因分析 + +### 5.1 “当前文档”同时承担事实源、工作副本和历史版本 + +`document_records` 的 `version` 和 `content_sha256` 只是当前指针;`output.md` 同时被智能体工作区、人工编辑和读取 API 使用。缺少 immutable snapshot 后,审计日志只能证明发生过一个动作,不能证明动作前后内容。 + +### 5.2 版本、修订、生命周期三种概念没有拆开 + +- 智能体生成版本:本需求要求的业务版本边界; +- 人工保存修订:高频、可聚合展示,但必须逐次留痕; +- 送审/批准快照:生命周期冻结点。 + +当前单个 `version` 整数无法同时表达三者,继续叠加字段会导致比较对象含糊。 + +### 5.3 UI 的 Block AST 是临时视图,不是稳定编辑协议 + +Markdown 每次解析都会重新生成随机块 ID;编辑 metadata 由浏览器填写,既不能作为服务端身份,也无法跨快照追踪。需要由服务端产生稳定 block map,并由 immutable snapshot 保存。 + +### 5.4 智能体说明与确定性事实没有分层 + +智能体适合解释“为什么改、有哪些风险”,但真实增删内容必须由服务端 diff 得出,上下游影响必须由工作流/血缘图计算,风险等级也不能允许模型自行降级。当前变更提案把三者都存为自由文本,且没有版本关联。 + +## 6. 严重度 + +`high` + +该能力尚未对外承诺为已实现,因此不是现有数据破坏事件;但若仅增加 UI 高亮便宣称完成,会形成错误审计证据,并在多人/多智能体协作下引入静默覆盖、错误归属和不完整通知。 + +## 7. 设计边界 + +- 受控工作流文档与自由文档继续使用各自权限/生命周期策略;只共享底层快照、修订和 diff 能力。 +- Markdown 正文为规范化内容真源,Block AST 是带稳定映射的编辑/展示投影,避免双真源。 +- 未保存的浏览器按键不属于审计记录;每次服务端成功提交才是 revision。 +- 批注、审核意见与正文修改是不同事件,不混入人工正文高亮。 +- 已批准内容不可原地修改;后续修改必须经受控变更/驳回回编制流程创建新工作周期。 + +## 8. 关联整改 + +- System design: [../system-design/document-revision-management.md](../system-design/document-revision-management.md) +- Remediation: [../remediation/2026-09-13-document-revision-management-implementation-plan.md](../remediation/2026-09-13-document-revision-management-implementation-plan.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-management-design.md](../ops/short-term/2026-09-13-document-revision-management-design.md) + +## 9. 开放问题 + +方案给出推荐默认值,等待评审时确认: + +1. 高/严重风险的智能体候选是否一律要求编制负责人再次确认后才成为新版本(推荐:是)。 +2. 首期是否同时迁移自由文档到“智能体版本 + 人工修订”语义(推荐:底层统一,UI 第二波启用)。 +3. 历史快照默认保留期限(推荐:受控文档随项目/法规保留策略;自由文档默认 365 天且可配置,不做静默硬删除)。 diff --git a/.doc/audit/INDEX.md b/.doc/audit/INDEX.md index ce89f04d..174b6322 100644 --- a/.doc/audit/INDEX.md +++ b/.doc/audit/INDEX.md @@ -13,6 +13,14 @@ ## 核心文档(按阅读顺序推荐) +### 文档修改与智能体版本专项分析(2026-09-13) + +**文件**: [2026-09-13-document-revision-management-gap-analysis.md](./2026-09-13-document-revision-management-gap-analysis.md) + +**结论**: 现有受控文档只有当前工作区正文和哈希,缺少不可变快照、智能体版本边界、人工修订差异、实际变更报告和负责人通知;前端块编辑尚未持久化。 + +**设计/整改**: [系统设计](../system-design/document-revision-management.md) · [实施方案](../remediation/2026-09-13-document-revision-management-implementation-plan.md) + ### 最新专项审计(2026-09-13) **文件**: [2026-09-13-conversation-intent-runtime-state.md](./2026-09-13-conversation-intent-runtime-state.md) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 4c7101fa..9c6ff1d6 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,13 +1,13 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(只读文档总结已通过真实 PI;刷新/双标签页 busy 浏览器 E2E 待补录) +**production_ready**: no(只读文档总结已通过真实 PI;刷新/双标签页 busy 浏览器 E2E 待补录;DOCREV 待合 dev) ## 焦点 -1. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) -2. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录 -3. **INTENT/WEB**: [查询误触发写任务与任务状态退化](short-term/2026-09-13-conversation-intent-runtime-state.md)(发布 blocker) +1. **DOCREV**: feature 分支 Wave0–6 代码已齐;待占集成槽位合 `dev` + migrate 00028 + 真实 PI/Playwright 补录 +2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) +3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term ## 并行认领 @@ -15,6 +15,7 @@ |-------|------|--------|----------|-------|--------| | Cursor | chat-history-refresh-remediation | fix/chat-history-refresh | - | chat | in_progress | | Codex | conversation-intent-runtime-state | fix/conversation-intent-state | .worktrees/fix-conversation-intent-state | intent | in_progress | +| Cursor | document-revision-wave1-6-implementation | feature/document-revision-management | - | docgov | done | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 @@ -22,7 +23,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-13 04:47 UTC | - | - | +| Antigravity | merging | 2026-09-13 08:12 UTC | DOCREV & freeform fix merge | - | **phase**: `idle` | `merging` | `smoke` | `e2e` @@ -36,7 +37,7 @@ | test | 8081 | 25→27* | healthy | | main | 8000 | 25→27* | healthy(app + runner + DB) | -*合入并 migrate 后为 27 +*合入并 migrate 后为 27;DOCREV 迁移 00028 待合入 ## SSOT @@ -47,3 +48,5 @@ | 发布缺口 | [gap-matrix](../remediation/gap-matrix.md) | | 测试报告 | [test-reports/](../test-reports/) | | Agent 手册 | [AGENTS.md](../../AGENTS.md) | +| DOCREV ADR | [../adr/2026-09-13-document-version-boundary.md](../adr/2026-09-13-document-version-boundary.md) | +| DOCREV 测试 | [../test-reports/2026-09-13-document-revision-management-test-report.md](../test-reports/2026-09-13-document-revision-management-test-report.md) | diff --git a/.doc/remediation/2026-09-13-document-revision-management-implementation-plan.md b/.doc/remediation/2026-09-13-document-revision-management-implementation-plan.md new file mode 100644 index 00000000..2689072b --- /dev/null +++ b/.doc/remediation/2026-09-13-document-revision-management-implementation-plan.md @@ -0,0 +1,322 @@ +--- +title: "文档修改、智能体版本与差异认知能力实施方案" +status: done +created: 2026-09-13 +updated: 2026-09-13 +--- + +# 文档修改、智能体版本与差异认知能力实施方案 + +## 1. 背景 + +专项分析确认:当前受控文档只有当前 `output.md` 和 SHA,没有历史正文快照;人工块编辑未接服务端保存;智能体完成只更新当前哈希;已有变更提案不是基于实际 before/after 的报告;通知仅覆盖阶段推进。 + +- Audit: [../audit/2026-09-13-document-revision-management-gap-analysis.md](../audit/2026-09-13-document-revision-management-gap-analysis.md) +- Design: [../system-design/document-revision-management.md](../system-design/document-revision-management.md) + +## 2. 目标 + +交付一套生产可验证的文档修改管理能力: + +- 模板/智能体生成定义 Version,人工保存定义 Version 内 Revision; +- 当前文档只标记相对最新智能体 baseline 的人工修改; +- 可比较任意两个 Version,并查看每次修改主体; +- Agent 生成绑定结构化摘要、风险、项目/文档影响; +- 文档负责人和受影响负责人可靠收到通知; +- 全链路服务端授权、并发安全、可恢复、可审计。 + +## 3. 前置决策门 + +用户审核方案后再进入业务代码实施。首个实施任务开始前冻结: + +1. 版本语义和历史版本显示命名; +2. high/critical 变更是否强制二次确认(推荐是); +3. 首发只覆盖受控文档还是同步切换自由文档 UI(推荐受控优先); +4. 受控/自由文档保留期与候选清理策略; +5. 当前并行任务的 mutex、集成槽位和 Goose 最新迁移号; +6. 根表 ledger 指针列命名:不得与既有 `freeform_documents.current_version_id`(00008,每-save+1 的 legacy 语义)同名冲突,新列统一 `ledger_*`/`head_*` 前缀,旧列改名或降级只读(见设计 §6.7)。 + +## 4. 分波次计划 + +| 波次 | 范围 | 主要交付 | 验收门 | +|---|---|---|---| +| Wave 0 契约冻结 | 领域词典、JSON schema、API、错误码、授权矩阵、fixture | `revision-contract-v1`、`change-report-v1`、黄金 diff 数据 | 评审通过;契约测试先红 | +| Wave 1 快照与版本内核 | PG schema、immutable snapshot、Version/Revision 状态机、历史回填 dry-run | `internal/docrevision`、Goose 迁移、backfill CLI/report | 单元 + PG 并发/回填 PASS | +| Wave 2 人工编辑闭环 | 受控 Block 编辑接 API、稳定 block map、租约/If-Match/冲突 UI | 真正持久化、人工 overlay、刷新恢复 | 双用户/双标签页 E2E PASS | +| Wave 3 Agent 版本提交 | candidate、实际 diff、报告生成/校验、run finalize 原子提交 | 单 agent/squad attribution、Vn 封存→Vn+1 | stub + PG fault injection + 真实 PI PASS | +| Wave 4 版本历史与比较 UI | timeline、A/B 选择、side-by-side/unified、筛选/跳转/导出 | 美观可访问的比较体验 | Playwright 视觉/交互/无障碍 PASS | +| Wave 5 影响与通知 | lineage/risk policy、outbox fanout、通知中心、读/已处理 | 负责人和受影响文档负责人通知 | 去重/重启/权限负例 PASS | +| Wave 6 自由文档兼容与发布 | legacy checkpoint 导入、统一读模型、灰度、性能/保留 | 自由文档采用同一版本语义;迁移收尾 | 旧数据升级 + rollback drill PASS | + +Wave 1–5 的发布顺序不可颠倒;特别是不得先上线前端颜色标记再补快照事实源。 + +## 5. 工程拆分 + +### 5.1 Task DR-00:契约、fixture 与 ADR + +建议文件: + +- `.doc/adr/YYYY-MM-DD-document-version-boundary.md` +- `internal/docrevision/testdata/*.json` +- `web/src/features/documentRevision/types.ts` +- OpenAPI/接口契约所在路径(若仓库建立统一 OpenAPI,必须同步) + +动作: + +- 固定 Version/Revision/Snapshot/Candidate/Report 定义; +- 固定 actor kind、错误码、change report JSON schema; +- 建立中文段落、表格、列表、Mermaid、移动块、同内容 no-op 的黄金 diff; +- 固定 canonical Markdown 规则(LF、UTF-8、Unicode NFC、尾换行、表格规范); +- 把用户核心时间线写成契约测试。 + +### 5.2 Task DR-01:数据库与 revision 内核 + +建议文件: + +- `deploy/postgres/goose/000XX_*document-revisions.sql` +- `internal/docrevision/{types,service,store,diff}.go` +- `cmd/document-revision-backfill/` 或既有迁移工具入口 + +动作: + +- 新建 content blobs、snapshots、versions、events、candidates、reports; +- 根表增加 ledger version/head/revision/review snapshot 指针;列名统一 `ledger_*`/`head_*` 前缀,禁止与既有 `freeform_documents.current_version_id`(每-save+1 的 legacy 语义)同名冲突,迁移中同步处理旧列改名/退役; +- partial unique、幂等唯一、actor check、immutable trigger/DB privilege; +- 写 `SELECT FOR UPDATE` 的 human revision 与 agent version 事务; +- 回填受控 `output.md`,生成 success/missing/hash-mismatch/no-template 报告; +- 内存模式只用于单元测试,不作为生产 fallback。 + +门禁: + +- 50–100 goroutine/连接并发下每文档 event_no、revision_no 连续且无双 open Version; +- 注入每个 SQL 步骤失败,事务均回滚; +- no-op 不增加 snapshot/version;同幂等键返回原结果; +- 空库与 schema 27 升级路径都可重复执行。 + +### 5.3 Task DR-02:人工编辑持久化与冲突处理 + +建议文件: + +- `internal/server/document_revision_handlers.go` +- `internal/projectinstance/document_access.go` +- `web/src/api/documentRevision.ts` +- `web/src/components/doc/BlockEditorModal.tsx` +- `web/src/components/doc/BlockRenderer.tsx` +- `web/src/lib/controlledDocHydrate.ts` +- `web/src/App.tsx`(实施时应先拆出 feature hook,避免继续膨胀) + +动作: + +- 删除前端可信 `user_id/user_name/user_role/work_dir/holder_id`; +- Block save、插入、删除、移动均走统一 PATCH,不再只改 `docCache`; +- stable block id 随 snapshot 回传,后端验证并重算 diff; +- 409 返回 base/current 指针,UI 显示三方 diff 和“保留我的副本/刷新后重做”,禁止自动重试; +- 默认 overlay=baseline→head,并提供 pure/manual/all 和 actor filter; +- 刷新、重登、双标签页从服务端完整恢复。 + +### 5.4 Task DR-03:Agent candidate 与原子版本提交 + +建议文件: + +- `internal/bizdev/service.go` +- `internal/squad/{service,quality_gate,models}.go` +- `internal/server/task_finalize.go` +- `internal/taskrun/` +- `configs/` 中相关 prompt/schema + +动作: + +- task run 捕获 immutable input snapshot; +- `SquadRunResult` 增加 report target 与 actor snapshot,不以客户端 supplied actor 为准; +- L3 生成 `output.md + change_report.json`; +- 先创建 candidate,再按提案/风险决定提交; +- finalizer 统一调用 `CommitAgentVersion`,成功后才把任务标 completed; +- 版本、报告、lineage/stale、outbox 同事务; +- 记录 squad 全体配置,但只在有 StepManifest 证据时做 hunk contributor 归属。 + +门禁: + +- agent 运行前后 head 改变时绝不覆盖; +- finalize 重试只建一个版本; +- 报告非法不改变 current head; +- proposal 范围外修改/high-risk 被确定性门禁拦截; +- 单 agent 与 squad 在时间线中明确区分。 + +### 5.5 Task DR-04:版本历史与比较体验 + +建议新组件: + +- `DocumentRevisionToolbar` +- `ManualChangeOverlay` +- `VersionHistoryDrawer` +- `VersionCompareView` +- `ChangeReportPanel` +- `DiffNavigator` + +动作: + +- 在 SlimTabBar 下方增加版本/修订二级工具条; +- 版本历史按 Version 展开 Revision,不平铺自动保存; +- 宽屏 side-by-side、窄屏 unified,章节同步滚动; +- 段落/表格/列表/代码采取不同 decorations; +- 颜色 + 线型 + 图标 + 文本多重编码; +- 导出默认纯净,审计导出附变更表和 hash。 + +门禁: + +- 500px 右栏、全屏、移动窄屏均可用; +- 10k hunk 限制和长表格虚拟化不卡死主线程; +- 键盘可完成选择版本、跳转差异、开关 overlay; +- XSS/Mermaid 恶意 diff fixture 不执行脚本。 + +### 5.6 Task DR-05:影响、风险与通知 + +建议文件: + +- `internal/docrevision/{impact,risk,report_validator}.go` +- `internal/notification/document_events.go` +- `internal/server/notification_handlers.go` +- `web/src/components/notifications/` + +动作: + +- ImpactEngine 只从冻结 workflow snapshot、task dependency 和 document lineage 计算权威集合; +- 风险取 rule/model 最大值,无法评估按 high; +- 创建 candidate-ready、version-committed、downstream-impact、report-degraded 事件; +- 收件人解析 current duty assignment,变更文档 author 必达; +- 对每个受影响文档逐一授权裁剪通知 payload; +- 增加通知 mark read/acted/dismissed 与全局/项目通知中心; +- 15 秒轮询可先复用,最终可合并到可恢复 SSE/推送通道。 + +门禁: + +- DB 提交后杀进程,重启仍能完成一次 fanout; +- 同 run/版本不会重复通知; +- 负责人变更时以 commit 时的 current assignment 解析并保存收件快照; +- 无权用户不能从影响数量以外获知受限文档名或正文。 + +### 5.7 Task DR-06:自由文档与历史兼容 + +动作: + +- 将 `freeform_document_versions` 导入 snapshot/event,保留 legacy checkpoint 展示; +- 当前 freeform head 建 `origin=migration` 开放 Version;新 `document_versions.version_no` 起点规则(推荐从 legacy 最大 version_no+1 起算)在契约冻结时确认; +- 新人工保存只加 Revision,智能体生成才开新 Version; +- owner 作为负责人接收通知;项目共享成员保持只读(首期); +- 不引入 author/reviewer/approver 伪职责。 + +## 6. API 与兼容策略 + +### 6.1 新 API + +按设计文档 §12 实现 `/api/v1/documents/{doc_id}/revision-state|versions|diff|manual-overlay|content|agent-candidates|change-reports`。 + +### 6.2 旧 API + +| 旧端点 | 兼容期行为 | 退役条件 | +|---|---|---| +| `GET /api/v1/docgov/documents/{id}/content` | 从 current snapshot 读,ETag 改为 snapshot SHA | 所有前端/Agent reader 切新端点 | +| `POST /api/v1/docgov/content` | 委托 human revision service,并要求 If-Match/幂等;一版后 410 | 新 PATCH 全量上线 | +| `PUT /api/v1/freeform-documents/{id}` | 兼容适配 revision engine | Wave 6 完成 | +| `document_records.version` | 同事务投影 current version_no | 所有查询改用 FK 后退役 | +| 工作区 `output.md` | DB head 的兼容投影 | 读链路不再依赖文件后降为缓存 | + +兼容适配只能有一个真实写服务;禁止旧服务写文件、新服务写 DB 的双权威。 + +## 7. 授权矩阵与负例 + +每个新端点至少覆盖: + +- 未登录 401; +- 非项目成员 403/一致的 not-found 策略; +- 当前阶段不可见 403; +- viewer/reviewer/approver 写正文 403; +- author 在 reviewing/approving/approved 写入 423; +- 篡改 path 中 doc/project/version/snapshot/candidate/report id 失败; +- 伪造 actor、holder、run、workdir、on-behalf-of 无效; +- Agent service identity 没有绑定 task instance 或 run 时失败; +- 自由私密文档 owner 隔离;shared 文档非 owner 写入失败; +- diff/report 中关联的受限下游文档被裁剪。 + +## 8. 测试矩阵 + +| 层 | 必测内容 | 命令/证据 | +|---|---|---| +| Go unit | 状态机、canonicalization、diff、block reconcile、risk floor、recipient | `go test -short ./internal/docrevision/...` | +| Go package | DocGov/freedoc/taskrun/notification 兼容 | `go test -short ./...` | +| Static | Go/TS 静态门禁 | `go vet ./...`; `npm run build` | +| PG integration | 事务、并发、FK/unique/immutable、迁移/回填 | `TEST_DATABASE_URL=... go test ...` | +| Fault injection | report/outbox/lineage/finalize 每步故障 | 专项 PG test | +| Front unit | decorations、version selector、actor filter、error state | `cd web && npm test` | +| Browser E2E | 编辑→刷新、Agent→新 baseline、A/B compare、通知 | Playwright,保存截图/trace | +| Real PI | 单 agent/squad 真实输出 + report schema + 通知 | 显式真实 PI 开关,不用 stub 冒充 | +| Security | cross-project/cross-stage/object-id 负例、XSS fixture | 专项授权与渲染测试 | +| Upgrade | 空库、schema 27、历史 mismatch、回滚演练 | 迁移 test report | + +最终报告必须分列 unit、stub、PostgreSQL、真实 PI、浏览器 E2E,不以健康检查代替。 + +## 9. 灰度与回滚 + +建议 flags: + +- `DOCUMENT_REVISION_LEDGER_WRITE`:影子写 snapshot/event,不改变读路径; +- `DOCUMENT_REVISION_READ`:按项目灰度从 DB head 读取; +- `DOCUMENT_AGENT_VERSION_COMMIT`:agent candidate/commit 新链; +- `DOCUMENT_DIFF_UI`:右侧 overlay/compare; +- `DOCUMENT_VERSION_NOTIFICATIONS`:通知 fanout。 + +灰度步骤: + +1. 影子写时逐次对比 DB head SHA 与 `output.md` SHA,差异告警; +2. 选测试项目切 DB read,保留旧文件只读投影; +3. 开启 Agent commit,验证 task finalizer 幂等和报告完整率; +4. 开 UI 与通知; +5. 所有环境稳定后停止旧直接写入。 + +回滚只切读/功能 flag,不删除新表或历史数据。若新写链已产生版本,禁止回滚到会覆盖 `output.md` 的旧写服务;只能进入只读维护或修复新链。 + +## 10. 主要风险与控制 + +| 风险 | 概率/影响 | 控制 | +|---|---|---| +| Version 与 Revision 被产品/UI 混用 | 中/高 | 契约冻结、统一中文文案、旧 freeform 标 legacy checkpoint | +| Markdown/AST 转换产生伪 diff | 中/高 | canonicalization 黄金集、stable block map、服务端对齐 | +| Agent 报告看似完整但漏报 | 高/高 | deterministic diff/impact/risk floor;schema 与一致性校验 | +| Agent 执行过久使 candidate stale | 中/中 | immutable input、短锁/策略锁、明确 regenerate,不自动 rebase | +| DB 正文体积增长 | 中/中 | SHA 去重、分页 diff、保留策略、容量指标,后续平滑迁对象存储 | +| 通知风暴 | 中/中 | 按 version/recipient 去重、跨文档聚合、严重度/偏好;关键站内通知不丢 | +| 历史内容 hash 不一致 | 中/高 | dry-run + quarantine,不静默覆盖 | +| 多实例下进程 mutex 失效 | 高/高 | PG row lock/lease token/unique constraint,不依赖内存锁作权威 | +| 新端点形成 BOLA/侧信道 | 中/高 | 统一 access decider + 全对象负例 + payload 裁剪 | + +## 11. 验收标准 + +- [ ] 模板/Agent 开 Version、人工只加 Revision 的规则在 API、UI、DB 一致。 +- [ ] 当前人工高亮永远是 latest agent baseline→head,Agent commit 后归零。 +- [ ] 两个任意 Version 默认 final→final/head 比较,可查看 baseline 和 revision。 +- [ ] 用户、单 agent、agent squad 的修改主体和不可变 actor snapshot 可追溯。 +- [ ] 智能体输出与 change report 同一次运行产生并绑定精确快照。 +- [ ] 真实 diff、影响集合和风险下限由服务端确定,模型不能篡改/降级。 +- [ ] 当前文档负责人收到版本摘要;受影响负责人收到经授权裁剪的通知。 +- [ ] 并发、重试、重启、迁移和故障注入不产生静默覆盖、双版本或丢通知。 +- [ ] 已批准文档、跨项目/跨阶段对象和私密文档负例全绿。 +- [ ] 右侧栏在主题、宽度、键盘、色觉无障碍和大文档条件下可用。 +- [ ] 所有证据归档至 `.doc/test-reports/`,FEATURE-MATRIX 从 planned 逐波次更新。 + +## 12. 完成定义与文档同步 + +每个 Wave: + +1. 单独 short-term 任务和 mutex 认领; +2. 迁移/API/前端/测试证据同批或紧随提交; +3. test report 回链本方案对应任务; +4. FEATURE-MATRIX 更新 `planned → partial → done`; +5. 若成为发布范围,才在 gap-matrix 新增 blocker;本次方案评审阶段不擅自把它列为当前发布 blocker; +6. 最终通过真实 PI、PG、浏览器和权限负例后归档任务。 + +## 13. 相关链接 + +- Audit: [../audit/2026-09-13-document-revision-management-gap-analysis.md](../audit/2026-09-13-document-revision-management-gap-analysis.md) +- System design: [../system-design/document-revision-management.md](../system-design/document-revision-management.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-management-design.md](../ops/short-term/2026-09-13-document-revision-management-design.md) +- gap-matrix 行: 当前未列为发布 blocker;用户确认进入发布范围后新增 diff --git a/.doc/remediation/INDEX.md b/.doc/remediation/INDEX.md index e491c68c..43a566f4 100644 --- a/.doc/remediation/INDEX.md +++ b/.doc/remediation/INDEX.md @@ -3,6 +3,7 @@ | 文档 | 说明 | |------|------| | [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT | +| [2026-09-13-document-revision-management-implementation-plan.md](./2026-09-13-document-revision-management-implementation-plan.md) | 智能体版本、人工修订、差异认知与负责人通知 | | [2026-09-13-conversation-intent-runtime-state-remediation.md](./2026-09-13-conversation-intent-runtime-state-remediation.md) | 对话写意图、刷新可恢复任务状态、文档占用可见性与诊断隔离 | | [2026-09-13-chat-history-refresh-remediation.md](./2026-09-13-chat-history-refresh-remediation.md) | 对话刷新恢复、消息作用域与授权统计一致性 | | [2026-09-12-comprehensive-audit-and-remediation-plan.md](./2026-09-12-comprehensive-audit-and-remediation-plan.md) | 综合缺陷与整改 | diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index bedcffcd..9c48c37d 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -21,6 +21,7 @@ | 工作流治理 | 三正式工作流 + 阶段门禁 | done | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md) | Phase2 设计器 partial | `workflow` | | IAM / 鉴权 | 项目级 API 鉴权 + RBAC | partial | [audit/authorization](../audit/authorization-matrix-v2.md);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | 历史读取端点补统一会话 owner + 阶段授权;旧 API 410 标注(BUG-11 partial) | `authz` | | 文档治理 | 法定文档生命周期 + 租约 | done | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md) | — | `docgov` | +| 文档版本与修改认知 | 智能体版本边界、人工修订标记、任意版本比较、变更报告与负责人通知 | partial | [test 2026-09-13](../test-reports/2026-09-13-document-revision-management-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md);[实施方案](../remediation/2026-09-13-document-revision-management-implementation-plan.md) | feature 分支已交付 Wave0–6 代码;真实 PI / 双用户 Playwright / 00028 容器 migrate 待占集成槽位 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` | | PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` | diff --git a/.doc/system-design/README.md b/.doc/system-design/README.md index da71ad6c..9b665cda 100644 --- a/.doc/system-design/README.md +++ b/.doc/system-design/README.md @@ -67,6 +67,23 @@ --- +### 4. 文档修改、智能体版本与差异认知 + +| 项 | 链接 | +|-----|------| +| 主报告 | [document-revision-management.md](document-revision-management.md) | +| 一句话总结 | 智能体/模板定义 Version、人工保存定义 Revision,以不可变快照支撑人工高亮、任意版本比较、AI 变更报告和负责人通知 | + +**核心要点**: + +- 当前右侧文档只标记 latest agent baseline → current head 的人工修改; +- 下次智能体提交原子封存旧版本并开启新版本,人工高亮随 baseline 前移而归零; +- 真实 diff、血缘影响和风险下限由服务端确定,LLM 提供可读解释但不能改写事实; +- 人、单智能体和智能体小队均保存不可变 actor snapshot; +- 版本、报告、lineage/stale 和通知 outbox 同事务提交。 + +--- + ## 🏗️ 在整体架构中的位置 ``` @@ -117,6 +134,7 @@ ``` .doc/system-design/ ├── README.md ← 本文件(顶层索引) +├── document-revision-management.md ← 文档版本、修订、差异、报告与通知 ├── intent-recognition/ │ └── 01-意图识别框架-设计-架构-实现-难点-解决方案-亮点与优势.md ├── agent-squad/ diff --git a/.doc/system-design/document-revision-management.md b/.doc/system-design/document-revision-management.md new file mode 100644 index 00000000..11d1980b --- /dev/null +++ b/.doc/system-design/document-revision-management.md @@ -0,0 +1,699 @@ +--- +title: "文档修改、智能体版本与差异认知系统设计" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Codex +--- + +# 文档修改、智能体版本与差异认知系统设计 + +## 1. 设计结论 + +采用“**智能体版本(Version)+ 人工修订(Revision)+ 不可变快照(Snapshot)**”三层模型: + +- 模板或智能体/智能体小队成功产物开启一个版本; +- 用户每次服务端保存产生该版本内的修订,不增加版本号; +- 下次智能体产物提交时封存旧版本当前 head,并以新产物开启下一版本; +- 右侧正文的人工修改标记始终比较“当前版本 baseline → 当前 head”; +- 任意版本比较默认使用每个版本的最终 head; +- 智能体变更说明比较“上一版本最终 head → 新版本 baseline”,与随后人工修改严格分离。 + +版本、正文、变更报告、影响计算和负责人通知必须在同一数据库事务中提交;智能体工作区 `output.md` 只作为候选产物和兼容投影,不再是唯一事实源。 + +## 2. 目标与非目标 + +### 2.1 目标 + +1. 逐次记录用户、单智能体、智能体小队、模板和系统迁移的正文修改。 +2. 默认视图只标记相对最新智能体 baseline 的人工修改,并能按修改人筛选。 +3. 支持任意两个业务版本的可信对比,能定位到章节、段落、列表、表格单元格和代码/图表行。 +4. 每次智能体生成同时形成结构化改动摘要、风险、项目影响和其他文档影响。 +5. 生成完成后可靠通知文档负责人,并为受影响文档负责人提供最小必要信息。 +6. 在并发、重试、进程重启和多实例部署下不静默覆盖、不重复建版本、不伪造修改主体。 + +### 2.2 非目标 + +- 首期不做 Google Docs 式多人实时协同光标或 CRDT;继续使用租约 + 乐观并发。 +- 不记录每次键盘输入;只记录服务端成功提交的 revision。 +- 不让 LLM 决定真实 diff、最终风险级别、权限或上下游依赖。 +- 不允许“恢复旧版本”覆盖历史;未来恢复只能把旧快照复制为新的受控 revision/agent candidate。 +- 不把自由文档塞入 DocGov 的编制/审核/批准状态机。 + +## 3. 统一术语和版本语义 + +| 术语 | 定义 | 是否可变 | +|---|---|---| +| Snapshot | 某一时刻完整、规范化 Markdown 与 block map 的内容快照 | 不可变 | +| Baseline | 当前版本由模板或智能体建立的起点快照 | 不可变 | +| Head | 当前版本最新一次成功提交后的快照指针 | 随 revision 前进 | +| Revision | 一个版本内的服务端成功修改事件,主要承载人工保存 | 事件不可变 | +| Version | 从一次模板/智能体 baseline 到下一次智能体提交前最终 head 的业务周期 | 开放时 head 可前进,封存后不可变 | +| Agent candidate | 智能体已生成但尚未获准/尚未通过门禁的候选快照 | 不成为正文 head | +| Change report | 绑定确定 before/after 快照的智能体变更说明 | 发布后不可变,可追加更正报告 | +| Review snapshot | 提交审核时冻结的精确 head | 不可变指针,不额外增加 Version | + +### 3.1 示例时间线 + +| 时点 | 动作 | 当前显示 | baseline | head | 人工标记 | +|---|---|---|---|---|---| +| T0 | 项目激活并复制模板 | V1 · r0 | S0 template | S0 | 0 | +| T1 | 张工修改两处并保存 | V1 · r1 | S0 | S1 | S0→S1 的两处 | +| T2 | 李工获授权后再修改一处 | V1 · r2 | S0 | S2 | S0→S2 的累计人工修改,可按人筛选 | +| T3 | 智能体小队基于 S2 生成 S3 并提交 | V2 · r0 | S3 squad | S3 | 0;V1 以 S2 封存 | +| T4 | 张工修改 S3 | V2 · r1 | S3 | S4 | 只显示 S3→S4,不再显示 S0→S2 | +| T5 | 比较 V1 与 V2 | V1.final=S2 → V2.head=S4 | — | — | 展示版本最终内容差异 | + +这正是核心规则: + +```text +V1 = 模板/上一次智能体 baseline S0 + T1/T2 的人工修订,最终封存为 S2 +V2 = 下一次智能体 baseline S3 + T4 之后的人工修订,当前 head 为 S4 +``` + +### 3.2 不产生版本的情况 + +- 智能体输出与输入 SHA 相同:记录 `agent_noop` 事件和报告,不增加 Version。 +- 智能体失败、超时、报告校验失败:保存候选/失败证据,不改变 Version 或 head。 +- 只增加批注、查看、认领、租约心跳:进入既有审计,不是正文 Revision。 +- 标题、可见性等元数据变化:进入 metadata audit;除非正文也改变,否则不进入正文 diff。 + +## 4. 领域状态机 + +版本状态与文档生命周期是**正交**的两套状态,不得混入同一状态机(这正是 gap analysis §5.2 指出的根因之一): + +```mermaid +stateDiagram-v2 + direction TB + state "版本状态 document_versions.status(仅 open/sealed)" as VS { + [*] --> open: template/agent baseline 建立新版本行 + open --> open: 人工 revision 提交(head 前进) + open --> sealed: 智能体提交,同事务原子封存 + sealed --> [*]: 只读历史;恢复=复制为新受控修订 + } + state "文档生命周期 document_records.current_state" as DS { + [*] --> drafting + drafting --> reviewing: submit_review(冻结 review snapshot) + reviewing --> drafting: 驳回回编制 + reviewing --> approving + approving --> approved: 批准(终点) + } +``` + +状态约束: + +1. 一个文档至多一个 `open` Version、一个 current head;`sealed` 行永久只读。 +2. 版本状态只有 `open/sealed`;`reviewing/approving/approved` 属于 `document_records` 生命周期,**不改变版本状态**——送审时版本保持 `open`,仅冻结 `review_snapshot_id`(CHECK 约束与状态机必须一致,迁移中校验)。 +3. “封存→新版本”是**插入新行**(旧版行置 `sealed`、新版行置 `open`),不是同一行状态回转;两者必须在同一事务内完成。 +4. agent candidate 必须记录 `input_snapshot_id`,提交时 current head 仍须等于该输入;否则标记 `stale`,禁止自动重放或覆盖。 +5. `reviewing/approving/approved` 状态禁止正文 revision 和 agent commit;驳回回 drafting 后可在当前 Version 继续人工修订;下一次智能体提交仍是唯一的新 Version 边界。 +6. `submit_review` 保存 `review_snapshot_id=head_snapshot_id`;审核、批准和导出都引用该快照。 + +## 5. 总体架构 + +```text +右侧 Block Editor / Agent Task Run + │ expected head + idempotency key + ▼ +DocumentRevisionService ── DocumentPolicyAdapter + │ ├─ controlled: DocGov + stage duty + │ └─ freeform: owner/share policy + ├─ SnapshotStore (PostgreSQL immutable content) + ├─ DiffEngine (server-authoritative) + ├─ ImpactEngine (workflow + lineage) + ├─ RiskPolicy (deterministic floor) + └─ ChangeReportValidator + │ same transaction + ▼ + version + event + report + lineage/stale + outbox + │ + ▼ + notification fanout / SSE cache invalidation +``` + +### 5.1 写模型与读模型 + +- `document_records` / `freeform_documents` 继续是各自治理根; +- 新的 revision ledger 是共享基础设施,不共享两类文档的权限和生命周期; +- PostgreSQL 内的规范化 Markdown snapshot 是内容真源; +- 工作区 `output.md`、Block AST、DOCX/XLSX 都是从 snapshot 派生的投影; +- 旧 API 在兼容期从 current head 读写,但内部必须委托 `DocumentRevisionService`,禁止双写两套权威。 + +## 6. 数据模型 + +迁移号在实施时取 Goose 最新可用号;按当前基线下一号是 `00028`,不得在并行合并后硬占用旧号。 + +### 6.1 `document_content_blobs` + +| 字段 | 类型/约束 | 说明 | +|---|---|---| +| `content_sha256` | CHAR(64) PK | 规范化 Markdown 内容寻址 | +| `content_markdown` | TEXT NOT NULL | 首期直接存 PostgreSQL,事务一致 | +| `byte_size` | BIGINT CHECK >= 0 | 限额与性能判断 | +| `created_at` | TIMESTAMPTZ | 首次出现时间 | + +相同正文只存一次。未来迁移对象存储时保留 hash,新增 `storage_key`;在完成校验前不删除 DB 内容。 + +### 6.2 `document_snapshots` + +| 字段 | 类型/约束 | 说明 | +|---|---|---| +| `id` | BIGSERIAL PK | 快照标识 | +| `doc_id` | TEXT NOT NULL | 全局业务文档 ID | +| `content_sha256` | FK content blobs | 完整正文 | +| `parent_snapshot_id` | FK self nullable | 直接父快照 | +| `block_map` | JSONB NOT NULL | 稳定 block id、类型、字符范围、heading path、业务键 | +| `source_kind` | CHECK | `template/human/agent/agent_squad/migration/system` | +| `actor_user_id` | FK nullable | 人工主体,仅来自服务端 session | +| `actor_ref` | TEXT | agent key / squad run id / template version | +| `actor_snapshot` | JSONB | 当时名称、角色、agent/squad 配置版本,不随配置漂移 | +| `source_run_id` | TEXT nullable | 绑定 task run | +| `created_at` | TIMESTAMPTZ | 创建时间 | + +正文和 `block_map` 不允许 UPDATE/DELETE。相同内容的 no-op 不建新 snapshot。 + +### 6.3 `document_versions` + +| 字段 | 类型/约束 | 说明 | +|---|---|---| +| `id` | BIGSERIAL PK | 内部版本 ID | +| `doc_id` | TEXT NOT NULL | 文档 | +| `version_no` | INT NOT NULL | 用户看到的 V1/V2... | +| `status` | CHECK | `open/sealed` | +| `origin_kind` | CHECK | `template/agent/agent_squad/migration` | +| `baseline_snapshot_id` | FK snapshot | 人工高亮基准 | +| `head_snapshot_id` | FK snapshot | 当前/封存最终正文 | +| `human_revision_count` | INT | 快速展示 | +| `source_run_id` | TEXT nullable | 创建本版本的 run | +| `change_report_id` | FK nullable | 上一最终 head → 本 baseline 报告 | +| `opened_at/sealed_at` | TIMESTAMPTZ | 周期边界 | + +关键约束: + +- `UNIQUE(doc_id, version_no)`; +- partial unique:每个 `doc_id` 只有一个 `status='open'`; +- baseline/head snapshot 必须属于同一 doc; +- sealed 后 `head_snapshot_id` 不可变。 + +### 6.4 `document_change_events` + +| 字段 | 说明 | +|---|---| +| `id/doc_id/version_id/event_no` | 单文档严格递增事件序列 | +| `from_snapshot_id/to_snapshot_id` | 可复算 diff 的精确前后状态 | +| `change_kind` | `human_revision/agent_commit/agent_noop/import/restore_as_new` | +| `actor_kind` | `human/agent/agent_squad/template/migration/system` | +| `actor_user_id/actor_ref/actor_snapshot` | 人员或执行单元归属 | +| `change_set` | 服务端计算的结构化 hunk 摘要;不是内容真源 | +| `edit_session_id` | UI 聚合连续自动保存 | +| `request_id/idempotency_key` | 重试幂等与追踪 | +| `created_at` | 服务端时间 | + +`UNIQUE(doc_id, idempotency_key)` 防止网络重试重复生成 revision。UI 可把同一用户同一 `edit_session_id` 的连续事件折叠成一组,但审计事件不合并、不删除。 + +### 6.5 `document_agent_candidates` + +| 字段 | 说明 | +|---|---| +| `id/doc_id/input_snapshot_id/output_snapshot_id` | 候选前后正文 | +| `run_id/proposal_id` | 绑定真实任务和既有授权提案 | +| `status` | `ready/needs_confirmation/committed/rejected/stale/quality_failed` | +| `authorized_scope` | 编制人批准的修改范围 | +| `quality_result` | 质量门禁结果 | +| `change_report_id` | 实际 before/after 的说明 | +| `created_at/decided_at` | 时间 | + +候选不改变 `document_records`、Version 或 head。候选被拒绝也永久保留元数据和 hash;正文保留期按安全策略执行。 + +### 6.6 `document_change_reports` + +| 字段 | 说明 | +|---|---| +| `from_snapshot_id/to_snapshot_id` | 报告唯一事实范围 | +| `schema_version` | 首期 `1` | +| `summary` | 3–8 条快速摘要 | +| `risk_level/risk_items` | `low/medium/high/critical` + 结构化原因 | +| `project_impacts/document_impacts` | 项目、阶段、任务、其他文档影响 | +| `open_questions` | 需负责人判断事项 | +| `analysis_status` | `complete/fallback/unassessed/corrected` | +| `model_snapshot/prompt_version` | 模型与提示词可审计来源 | +| `deterministic_facts` | diff 统计、规则风险下限、血缘影响集合 | + +原报告不可覆盖;纠错创建新报告并用 `corrects_report_id` 关联。 + +### 6.7 根表指针 + +`document_records` 和 `freeform_documents` 增加统一命名的 ledger 指针列: + +- `ledger_version_id`(FK → `document_versions.id`,NULL=尚未导入); +- `head_snapshot_id`(FK → `document_snapshots.id`); +- `head_revision_no`; +- `review_snapshot_id`(仅 controlled); +- `revision_schema_version`; +- `row_revision`(乐观并发)。 + +命名冲突与旧列退役(实施红线): + +- `freeform_documents.current_version_id` 已在迁移 `00008_2026-09-11-freeform-documents.sql` 中存在,语义是“每次保存 +1 的 legacy 版本号”,**不得**同名复用承载新语义;新指针列一律用 `ledger_*`/`head_*` 命名,旧列迁移时改名为 `legacy_version_id`(或保留原名并降级为只读投影); +- `document_records.version/content_sha256/work_dir` 与 `freeform_documents.current_sha256` 在兼容期由同一事务投影为旧值,稳定后只读并最终退役; +- 自由文档 legacy `freeform_document_versions.version_no` 与新 `document_versions.version_no` 是不同表的独立编号;新 Version 起点规则在 DR-06 契约冻结时确认(推荐从 legacy 最大 `version_no+1` 起算,UI 对 legacy checkpoint 加前缀区分)。 + +## 7. 修改主体归属 + +### 7.1 人工修改 + +- `actor_kind=human`;`actor_user_id` 只取认证 principal; +- `actor_snapshot` 保存当时显示名、项目角色和文档 duty; +- 客户端提交的 `user_id/user_name/user_role` 一律忽略并逐步移除; +- 同一 hunk 被多人先后覆盖时,当前视图标注最后修改人,历史面板仍保留完整事件链。 + +### 7.2 单智能体修改 + +- `actor_kind=agent`,保存 agent key、配置版本、模型、tool policy、on-behalf-of 用户和 run id; +- 若任务由人发起,触发者不是内容作者;界面分别显示“发起人”和“执行智能体”。 + +### 7.3 智能体小队修改 + +- 文档基线整体归属 `actor_kind=agent_squad` 与唯一 `run_id`; +- `actor_snapshot` 固化 squad name/version、参与 agent role/name/model 和 StepManifest hash; +- 只有执行链能证明某 agent 直接产出某 block 时,hunk 才显示该 agent contributor;否则显示“小队共同产出”,禁止根据最终文案猜测个人贡献。 + +## 8. 写入流程 + +### 8.1 人工保存 + +1. API 根据 doc id 加载资源上下文并执行项目、阶段、文档 duty、状态权限判定。 +2. 校验 human lease token、`expected_head_snapshot_id`、`expected_revision_no` 和 idempotency key。 +3. 服务端规范化 Markdown、校验大小和危险内容,计算 SHA。 +4. `SELECT ... FOR UPDATE` 锁定文档根;再次检查 head,冲突则返回 409 和三方比较所需指针,绝不自动覆盖。 +5. 写 content blob、snapshot、change event;更新 open Version head 和根表 current pointer。 +6. 同事务写审计与必要的 cache invalidation outbox;提交后再更新工作区投影。 +7. 返回新 head、revision、人工累计修改统计和增量 decorations。 + +推荐自动保存每 5–10 秒或失焦触发;同一编辑会话在 UI 时间线折叠显示,服务端仍逐次留痕。 + +### 8.2 智能体/小队生成 + +1. L2 解析文档、阶段、任务实例和 actor;按既有认领/提案规则取得修改授权。 +2. 短事务捕获 current `input_snapshot_id`,获取服务端签发的 squad lease;任务只能读取该不可变输入。 +3. L3 同时生成 `output.md` 和 `change_report.json`;输出落在 candidate workspace。 +4. 服务端规范化输出并计算真实 diff;ImpactEngine 读取 workflow snapshot 与 document lineage;RiskPolicy 给出不可被模型降低的风险下限。 +5. 校验 change report schema、内容范围、引用 doc 可见性和 authorized scope。失败可重试报告生成,不重复生成正文。 +6. 若需要二次确认,创建 `candidate_ready` 通知并释放租约;正文 head 不变。 +7. 提交时锁根记录并验证 `head == input_snapshot`、生命周期仍 drafting、授权/提案仍有效。 +8. 同一事务:封存旧 Version head → 新建 Version 与 agent baseline → 写 event/report/lineage stale/outbox → 更新 current pointers → 标记 candidate committed。 +9. 提交后异步投影 `output.md`、刷新右侧读模型并通知负责人。 + +### 8.3 与既有变更提案衔接 + +- 既有 `document_change_proposals` 继续表达“是否允许智能体动这份已认领文档”; +- 新 candidate/report 表达“智能体实际改出了什么”;二者不能合并成一条自由文本; +- 未认领模板可按策略自动提交;已认领或由非编制人触发时必须先有有效 proposal; +- 实际 diff 超出授权范围、规则风险达到 high/critical、或影响已批准下游文档时,再次要求编制负责人确认实际 diff; +- 任何确认都绑定 candidate id + input/output SHA,不能批准一个会漂移的“最新内容”。 + +### 8.4 失败与并发 + +| 场景 | 行为 | +|---|---| +| 人工保存 head 冲突 | 409 `DOCUMENT_HEAD_CONFLICT`,返回 base/current 指针,显示三方 diff,不自动重试 | +| Agent 运行期间输入已变化 | candidate=`stale`,不提交;负责人选择重新生成 | +| Agent 正文成功、报告失败 | 正文留 candidate;重试报告。最终降级时标 `unassessed/high`,受控文档不得直接送审 | +| Outbox 写失败 | 整个版本事务回滚,不出现“版本成功但负责人未进入通知队列” | +| 通知投递失败 | outbox 重试,不回滚已经提交的版本;站内通知具有最终一致性 | +| 工作区投影失败 | DB head 仍权威,标 projection lag 并重建,读取不回退旧文件 | +| 相同 run 重试 finalize | 由 `(doc_id, run_id, output_sha)` 幂等,只能提交一个 Version | + +## 9. Diff 引擎与稳定块定位 + +### 9.1 服务端权威 + +真实增删改必须由服务端根据两个 immutable snapshot 计算;`change_report.json` 的描述不能替代 diff。Diff 结果按两种用途输出: + +- `manual_overlay`:current baseline → current head,仅保留 human events; +- `version_compare`:任意 A.final/head → B.final/head,包含所有主体,并按 event 归属着色。 + +### 9.2 block map + +当前随机 `blk_*` 不能跨刷新使用。新流程为每个 snapshot 保存 block map: + +1. 首次解析按类型、heading path、结构位置、业务键(如 REQ-ID/表格主键)生成 node; +2. 人工编辑携带当前 stable block id;服务端验证后继承; +3. 智能体 Markdown 重新解析时,用“业务键精确匹配 → heading path/type → 内容指纹相似度 → 邻近顺序”对齐旧节点; +4. 无法可靠匹配的节点分配新 UUID,并标记 insert/delete,不伪造延续关系。 + +### 9.3 粒度 + +| 内容 | 比较粒度 | 展示 | +|---|---|---| +| 标题/段落 | Unicode grapheme + 中英文词组 | 字词增删、整段移动 | +| 列表 | item,再到 item 内字词 | 新增/删除/改写条目 | +| 表格 | row key/位置 → cell → cell 内字词 | 单元格角标、整行增删 | +| Mermaid/代码/公式 | 行级,必要时 token 级 | 行号和语法安全转义 | +| 块移动 | stable id + 顺序 | “从 2.1 移至 3.2”,不误判为删后新增 | + +算法以 Myers/patience line diff 为基础,中文正文再做 grapheme/词组 diff;输出 JSON hunks,不输出未清洗 HTML。前端只用 React 节点渲染,不用不可信 `dangerouslySetInnerHTML`。 + +### 9.4 版本选择规则 + +- `final`:sealed Version 取封存 head,open Version 取当前 head;默认。 +- `baseline`:用于查看某次智能体刚生成时的原始内容。 +- `revision`:高级历史中可选择某一人工保存点。 + +因此“比较 V1 和 V3”不会因为 V1 曾有多个自动保存而含糊;默认就是 V1.final 对 V3.final/head。 + +## 10. 智能体变更报告 + +### 10.1 产物契约 + +每次生成要求 L3 输出两个目标: + +```text +output.md +change_report.json +``` + +`change_report.json` 建议 schema: + +```json +{ + "schema_version": 1, + "summary": ["补充系统边界条件", "调整三项验收阈值"], + "changes": [{ + "section_locator": "3.2 性能要求 / REQ-PERF-007", + "change_type": "modified", + "rationale": "依据新增用户材料与法规检索证据", + "evidence_refs": ["material:123#sha256", "kb:GB9706#8.4"] + }], + "risk": { + "level": "high", + "items": [{"category": "verification", "description": "阈值变化会使既有用例失效", "mitigation": "重跑 TC-007"}] + }, + "project_impact": { + "schedule": "可能增加验证工作量", + "cost": "待评估", + "scope": "影响性能验证范围", + "compliance": "需复核风险管理文件" + }, + "document_impacts": [{ + "doc_id": "doc_proj1_test_manager", + "impact_type": "stale_candidate", + "reason": "引用的 REQ-PERF-007 已改变", + "recommended_action": "由测试方案编制人复核并重新基线" + }], + "open_questions": ["阈值是否已获得硬件负责人确认"] +} +``` + +禁止在报告中复制大段 before/after 正文;精确内容由 diff API 提供。 + +### 10.2 双层校验 + +| 层 | 权威职责 | +|---|---| +| 确定性层 | SHA、真实 hunk、增删统计、workflow/lineage 影响集合、敏感章节规则、风险最低级别 | +| 智能体层 | 修改理由、可读摘要、业务含义、潜在风险、建议动作和开放问题 | + +最终风险:`max(rule_risk, model_risk)`。模型遗漏真实变更、引用不存在文档、报告说“未修改”但 SHA 改变,均触发校验失败或 conservative fallback。 + +### 10.3 风险规则下限 + +- 删除/降低法规、风险控制、验收标准、关键技术参数:至少 high; +- 改变需求 ID、上游已批准输入、成本/报价、阶段 gate:至少 high; +- 仅格式、错别字且无结构/数值变化:low; +- 影响已批准下游文档、跨阶段范围或职责:critical/high,按政策要求确认; +- 无法评估:`unassessed`,UI 按 high 呈现,不能显示绿色安全结论。 + +## 11. 通知设计 + +### 11.1 事件类型 + +- `document.agent_candidate_ready`:实际 diff 已生成,等待负责人确认; +- `document.agent_version_committed`:新智能体 Version 已生效; +- `document.human_revision_conflict`:本人编辑发生并发冲突; +- `document.downstream_impact_detected`:其他文档因上游变化需复核; +- `document.change_report_degraded`:报告未完整评估,阻断送审。 + +### 11.2 收件人矩阵 + +| 场景 | 必须通知 | 条件通知 | +|---|---|---| +| Agent candidate 就绪 | 当前文档编制负责人 | 发起人(若不同) | +| Agent Version 生效 | 当前文档编制负责人 | 发起人;high/critical 时项目创建人 | +| 影响下游文档 | 每份受影响文档的编制负责人 | 当前 reviewer/approver、项目创建人 | +| 报告降级 | 当前文档编制负责人 | QA/项目监督角色(若配置) | + +受影响人只能看到自己有权访问的文档名称和摘要;无权资源只返回“另有 N 份受限文档受影响”,防止侧信道泄露。 + +### 11.3 通知内容 + +通知卡片包含:文档名、Vn-1→Vn、执行主体/发起人、3 条摘要、风险徽章、项目影响一句话、受影响文档数量、`查看修改` 和 `查看完整分析`。不把正文片段、Prompt 或私密材料写入 notification body。 + +去重键:`doc_agent_version:{doc_id}:{version_no}:{recipient_id}`;candidate 使用 candidate id。版本与通知先同事务写入 outbox,worker 幂等 fanout。用户当前正打开文档时可以抑制 toast,但站内通知记录仍必须创建。 + +## 12. API 契约 + +所有端点先通过统一 DocumentAccessDecider,path 中 project/doc/version/snapshot 归属必须一致。 + +```text +GET /api/v1/documents/{doc_id}/revision-state + -> current version/baseline/head/revision/manual change count/capabilities + +GET /api/v1/documents/{doc_id}/versions?cursor=&limit= + -> version timeline; no full content + +GET /api/v1/documents/{doc_id}/versions/{version_no}?view=final|baseline + -> immutable snapshot content + ETag + +GET /api/v1/documents/{doc_id}/diff + ?from_version=1&to_version=3&from_view=final&to_view=final&mode=structured + -> summary, hunks, actor attribution, report refs + +GET /api/v1/documents/{doc_id}/manual-overlay + ?version=current&actor=all|me + -> baseline→head decorations + +PATCH /api/v1/documents/{doc_id}/content + If-Match: ":" + Idempotency-Key: + body: { markdown, edit_session_id, client_block_map_version } + +GET /api/v1/documents/{doc_id}/agent-candidates/{candidate_id} +POST /api/v1/documents/{doc_id}/agent-candidates/{candidate_id}/decision + body: { decision, expected_input_sha, expected_output_sha, reason } + +GET /api/v1/documents/{doc_id}/change-reports/{report_id} +PATCH /api/v1/notifications/{id} + body: { status: read|acted|dismissed } +``` + +内部服务接口(不暴露为普通用户 HTTP): + +```go +CommitHumanRevision(ctx, principal, docRef, expectedHead, markdown, idempotencyKey) +CreateAgentCandidate(ctx, servicePrincipal, onBehalfOf, runID, inputSnapshot, output, report) +CommitAgentVersion(ctx, servicePrincipal, candidateID, decisionToken) +``` + +### 12.1 主要错误码 + +| HTTP | code | UI 行为 | +|---|---|---| +| 403 | `DOCUMENT_EDIT_FORBIDDEN` | 只读并解释职责/阶段 | +| 409 | `DOCUMENT_HEAD_CONFLICT` | 打开三方差异,不自动覆盖 | +| 409 | `DOCUMENT_BUSY` | 显示 holder 类型和可重试时间 | +| 409 | `AGENT_CANDIDATE_STALE` | 提示重新生成,不允许直接应用 | +| 422 | `CHANGE_REPORT_INVALID` | 候选保留,显示分析待补全 | +| 423 | `DOCUMENT_LIFECYCLE_LOCKED` | 跳转审核/变更流程 | +| 413 | `DOCUMENT_TOO_LARGE` | 分块或附件化建议 | + +## 13. 右侧栏交互设计 + +### 13.1 默认阅读态 + +```text +┌ 文档 Tab ───────────────────────────────────────────┐ +│ V3 · 当前 r4 [7 处人工修改] [版本历史] [比较] │ +│ 基准:智能体小队 · 10:42 风险:中 影响 2 文档 │ +├─────────────────────────────────────────────────────┤ +│ ▌ 3.2 性能要求 张工 · 11:03 │ +│ 温控范围为 38–45℃,精度由 ±1℃ 改为 ±0.5℃ │ +│ ~~~~~~~~~~~~~ (浅琥珀底 + 实线下划线)│ +│ │ +│ ◀ 删除 1 处 在锚点处显示小型删除标记,悬浮看原文 │ +├─────────────────────────────────────────────────────┤ +│ [纯净阅读] [人工修改] [全部变更] [仅我] [所有人] │ +└─────────────────────────────────────────────────────┘ +``` + +默认开启“人工修改”,只展示 current baseline→head: + +- 左侧 3px gutter 标记整块受影响; +- 字词新增用浅琥珀背景 + 下划线,修改同时显示原/新值的 popover; +- 删除不把大段旧文塞回正文,在原锚点显示删除标记,点击查看; +- 右上角显示修改人、时间和 revision;多人修改显示头像堆叠; +- 颜色之外同时使用线型、图标和文字,满足色觉无障碍;深色/浅色主题分别校验对比度; +- “纯净阅读”一键关闭所有 decoration,导出默认纯净正文;审计版导出可附变更表。 + +### 13.2 版本历史抽屉 + +版本卡片展示: + +- `V3 当前` / `V2 已封存`; +- baseline 来源(模板、单智能体、智能体小队)和 actor snapshot; +- 开始/封存时间、人工 revision 数/人数; +- 最终 SHA 短码、生命周期 checkpoint; +- 智能体摘要、风险徽章、影响数量; +- 选择为 A / 选择为 B / 查看生成时原稿。 + +人工自动保存默认折叠在版本卡片内,展开才显示 r1...rN,避免版本噪声。 + +### 13.3 两版本比较 + +- 宽屏默认左右双栏:A 在左、B 在右,同步滚动和章节对齐; +- 窄屏/右栏宽度不足时使用 unified diff; +- 顶栏允许交换 A/B、只看改动、按主体筛选、跳到上一/下一处; +- 摘要区固定展示“新增/删除/修改/移动块数、人工/Agent 占比、风险、影响文档”; +- 点击任一 hunk 可打开来源事件、修改主体、run、提案、报告和审计记录; +- 比较视图只读,不允许在历史版本上直接编辑。 + +### 13.4 智能体完成卡 + +生成完成后,中间对话与右侧栏显示同一 report id 的摘要卡: + +```text +V2 已由「产品经理智能体小队」生成 +改动 12 处 · 风险 高 · 影响 3 份文档 +摘要:补齐需求边界;调整 3 项验收阈值;新增 2 个开放问题 +[查看逐项修改] [风险与影响] [若需:确认应用] +``` + +卡片不能只靠 SSE 临时状态;刷新后从 version/report/notification 读模型恢复。 + +## 14. 权限和安全 + +| 动作 | Controlled | Freeform | +|---|---|---| +| 查看版本/正文/diff | active 项目成员 + 阶段可见 + 文档可见 | owner;shared 时需 active 项目成员 | +| 人工编辑 | 指定 author + drafting + human lease | owner(首期) | +| Agent 候选 | service identity + on-behalf-of 用户有触发权限 + active task instance | service identity + owner 明确触发 | +| 提交 Agent Version | 服务端 task finalizer;必要时绑定 author 决策 | owner 授权策略 | +| 查看完整影响 | 对每个受影响资源逐一授权 | 同左 | +| 历史恢复 | 后续受控动作,永不 UPDATE 历史 | owner 新建 revision | + +安全要求: + +- holder id、actor kind、agent id、run id、project/stage/workdir 不信任客户端; +- snapshot/diff/report API 必须复用列表、正文、导出、RAG 和 Agent tool read 的同一授权决策; +- 日志、通知和 metrics 不记录正文、diff 片段或 Prompt; +- 防止通过版本数量、影响文档名和 actor 名称泄露无权资源; +- DB 层限制 immutable 表 UPDATE/DELETE,审计修正只追加; +- 规范化 Markdown 后再计算 SHA,避免换行/Unicode 形式导致伪差异; +- 所有渲染继续执行 Markdown/Mermaid/XSS 安全策略。 + +## 15. 一致性、性能与保留 + +### 15.1 事务边界 + +Agent commit 的最小原子集合: + +```text +seal old version ++ create new baseline snapshot/version ++ change event/report ++ update document head/version/hash ++ lineage/stale changes ++ outbox event +``` + +任务终态只有在该事务成功后才可从 `finalizing` 进入 `completed`。这补上当前“先写工作区,再分别更新文档和消息”的分裂窗口。 + +### 15.2 性能 + +- 列表/时间线不返回正文;正文和 diff 分页/按章节懒加载; +- diff cache key=`algorithm_version:from_sha:to_sha:mode`,内容更新天然失效; +- 大于阈值的文档异步计算 diff,API 返回 job id,前端显示可恢复进度; +- content blob 按 SHA 去重,snapshot 只存引用和 block map; +- 对极大表格采用 windowed rows,禁止一次渲染全部 cell diff; +- 下载/导出使用选定 snapshot,不从浏览器当前 DOM 拼装。 + +建议首期限制:Markdown 10 MiB、单次 change report 256 KiB、diff hunks 10,000;超限进入分章节比较,不截断审计正文。 + +### 15.3 保留策略 + +- 受控文档:随项目、质量体系和法规适用策略保留;默认不自动硬删版本、事件和报告; +- 自由文档:默认 365 天可配置,删除文档先进入回收站,历史清理由后台策略和审计事件驱动; +- agent candidate:未提交正文可按较短策略清理,但保留 hash、run、决策和报告元数据; +- 法规适用性、电子签名和法定年限需由组织另行确认,本设计不宣称自动满足特定法规。 + +## 16. 迁移与兼容 + +### 16.1 历史受控文档 + +1. 对每条 `document_records` 解析服务端规范 workdir,读取当前 `output.md`; +2. 文件 SHA 与 DB SHA 一致才导入 snapshot;不一致进入 quarantine report,禁止静默选择一方; +3. 建立 `V1 r0 origin=migration`,baseline=head=当前 snapshot;历史人工修改无法可靠推断,因此不伪造高亮; +4. `is_template_only=true` 的文档优先从激活时冻结的 template version 创建 template snapshot;若历史没有冻结模板,标 `baseline_unavailable`; +5. 回填报告记录成功、缺文件、hash mismatch、无模板四类数量,并支持 dry-run。 + +### 16.2 自由文档 + +现有 `freeform_document_versions` 每次保存均是不可变内容,可无损导入 snapshot/change event。为避免改写用户历史: + +- 旧每-save版本在 UI 标记为 `legacy checkpoint`; +- 切换新模型时以当前版本建立一个 `origin=migration` 的开放 Version; +- 后续人工保存变为 revision,下一次智能体生成才增加业务 Version; +- 首期可先完成底层导入和 API,第二波再切换自由文档 UI 语义。 + +### 16.3 模板 + +项目激活时必须把冻结的 workflow template version 内容复制/引用为文档 V1 baseline,不能继续仅在浏览器用 `templateFactory` 临时生成。这样模板与后续人工修改才可比较、可审计。 + +## 17. 可观测性 + +指标: + +- `document_revision_commit_total{actor_kind,result}`; +- `document_head_conflict_total{mode}`; +- `agent_candidate_total{status,risk}`; +- `change_report_validation_total{result}`; +- `document_diff_duration_seconds{size_bucket,mode}`; +- `document_notification_lag_seconds{event_type}`; +- `workspace_projection_lag_total`。 + +结构化日志只写 doc/project/run/request/version/revision/hash 短码,不写正文。告警关注:版本事务失败、candidate stale 激增、报告 fallback 比例、通知积压和投影不一致。 + +## 18. 验收场景 + +| ID | 场景 | 预期 | +|---|---|---| +| AC-01 | 模板后用户保存 3 次 | 仍为 V1,r3;三事件可审计;overlay=template→head | +| AC-02 | squad 基于 V1 r3 生成 | V1 封存于 r3,V2 r0;人工 overlay 清零 | +| AC-03 | V2 再人工修改 | 只显示 V2 baseline→V2 head,不重复显示 V1 人工修改 | +| AC-04 | 比较 V1 与 V2 | 默认 V1.final→V2.final/head,可切换 V2 baseline | +| AC-05 | 单 agent 与 squad 各生成一次 | 时间线正确显示主体;squad 参与成员快照可查看 | +| AC-06 | 两用户并发保存 | 只成功一个;另一方 409 + 三方 diff;无自动覆盖 | +| AC-07 | agent 输入后用户抢先改变 head | candidate stale,不建新 Version | +| AC-08 | 生成报告漏报真实删除 | validator 拒绝/降级;规则风险不可被降低 | +| AC-09 | 新 Version 影响下游 | lineage/stale 与 outbox 同事务;对应负责人收到授权内通知 | +| AC-10 | 刷新、重登、服务重启 | 版本、overlay、报告、通知完全恢复,不依赖内存/SSE | +| AC-11 | 非成员猜 doc/version/snapshot id | 统一 403/404 策略,无内容/元数据侧信道 | +| AC-12 | 已批准文档直接修改 | 423;历史、head、通知均不变 | +| AC-13 | 同一 run finalize 重试 | 只产生一个 Version/报告/通知 | +| AC-14 | 历史 hash mismatch 回填 | 进入隔离报告,不伪造 baseline | +| AC-15 | DOCX/Markdown 导出历史版本 | 内容和所选 snapshot SHA 一致,默认无 UI 高亮污染 | + +## 19. 评审决策点 + +建议本轮审核确认以下三项,其他细节可在实施中按本设计收敛: + +1. **版本语义**:采用推荐的“智能体/模板开启 Version,人工保存只增加 Revision”。 +2. **高风险应用**:`high/critical` 智能体实际 diff 必须由编制负责人二次确认;低/中风险按既有提案授权自动提交。 +3. **上线范围**:Wave 1 先覆盖受控工作流文档;自由文档完成底层兼容后在后续 Wave 切换 UI,避免一次性重写两个治理域。 + +## 20. 关联文档 + +- Gap analysis: [../audit/2026-09-13-document-revision-management-gap-analysis.md](../audit/2026-09-13-document-revision-management-gap-analysis.md) +- Implementation plan: [../remediation/2026-09-13-document-revision-management-implementation-plan.md](../remediation/2026-09-13-document-revision-management-implementation-plan.md) +- Existing workflow authority design: [../remediation/2026-09-12-permissions-settings-three-workflows-implementation-plan.md](../remediation/2026-09-12-permissions-settings-three-workflows-implementation-plan.md) +- Historical right-rail design: [../archive/design/superpowers/specs/2026-09-08-three-column-workbench-and-block-document-design.md](../archive/design/superpowers/specs/2026-09-08-three-column-workbench-and-block-document-design.md) diff --git a/.doc/test-reports/2026-09-13-document-revision-management-test-report.md b/.doc/test-reports/2026-09-13-document-revision-management-test-report.md new file mode 100644 index 00000000..9f896477 --- /dev/null +++ b/.doc/test-reports/2026-09-13-document-revision-management-test-report.md @@ -0,0 +1,56 @@ +# 文档修改与版本管理(DOCREV)测试报告 + +## 一、 测试背景 (Background) + +受控文档此前仅有当前 `output.md` 与 SHA,缺少不可变快照、智能体 Version 边界、人工 Revision 留痕、可信 diff 与负责人通知。本轮按 Wave 0–6 落地 revision ledger。 + +## 二、 测试目标 (Test Objectives) + +- Version/Revision 语义与契约时间线正确 +- 黄金 diff(中文段落/表格/列表/Mermaid/移动/no-op)通过 +- 人工保存幂等、冲突、生命周期锁定 +- high/critical 候选强制二次确认 +- 自由文档 legacy 导入后人工只加 Revision +- 并发下仅一个 open Version、revision_no 连续 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux;Go 1.27;内存 ledger(`-short`)+ 契约 fixture +- 层级:Go unit/contract(`internal/docrevision`)、freedoc 回归、bizdev 编译链接 +- 真实 PI / Playwright 全链路:需在占集成槽位后对 `agent-dev` 补录(见后续证据目录) + +## 四、 预期结果 (Expected Results) + +- T0 模板开 V1·r0;人工保存只升 revision;Agent 确认后开 V2 且人工标记归零 +- high 风险未确认 → `ErrNeedsConfirmation` +- noop 不增 snapshot;幂等键返回同一 event + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例:契约时间线 + 黄金 diff + 并发 + +- **测试输入 (Input)**:`go test -short ./internal/docrevision/...` +- **实际输出 (Actual Output)**: + +```text +ok chat-agent-system/internal/docrevision +``` + +- **评价判定 (Evaluation / Verdict)**:PASS +- **性能与耗时 (Duration & Latency)**:约 3–10ms(短测) + +### 用例:freedoc / notification / bizdev 回归 + +- **测试输入 (Input)**:`go test -short ./internal/freedoc/... ./internal/notification/... ./internal/bizdev/...` +- **实际输出 (Actual Output)**:均 `ok` +- **评价判定 (Evaluation / Verdict)**:PASS +- **性能与耗时 (Duration & Latency)**:<2s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +本波次聚焦 DOCREV 相关包短测。全量 `go test -short ./...` 与真实 PI E2E 在合入 `dev` 占集成槽位时执行并追加本报告附件。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 内核、API、前端工具条与自由文档兼容已合入 feature 分支;FEATURE-MATRIX 标 `partial→` 本轮完成后更新为 done(能力代码齐,生产灰度与真实 PI 补录仍建议在集成槽位完成)。 +- 后续:影子写 flag 灰度、`TEST_DATABASE_URL` 迁移 00028 集成测、Playwright 双用户冲突与真实 PI change_report 验收。 diff --git a/cmd/document-revision-backfill/main.go b/cmd/document-revision-backfill/main.go new file mode 100644 index 00000000..3698deb3 --- /dev/null +++ b/cmd/document-revision-backfill/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "chat-agent-system/internal/docrevision" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// document-revision-backfill dry-runs or applies controlled document output.md into the ledger. +func main() { + dsn := flag.String("dsn", os.Getenv("TEST_DATABASE_URL"), "postgres DSN") + repoRoot := flag.String("repo-root", ".", "repository root for relative work_dir") + apply := flag.Bool("apply", false, "apply changes (default dry-run)") + outPath := flag.String("report", "", "write JSON report path") + flag.Parse() + if *dsn == "" { + fmt.Fprintln(os.Stderr, "dsn or TEST_DATABASE_URL required") + os.Exit(2) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + pool, err := pgxpool.New(ctx, *dsn) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer pool.Close() + + rows, err := pool.Query(ctx, ` + SELECT doc_id, COALESCE(work_dir,''), COALESCE(content_sha256,''), COALESCE(current_state,'drafting'), project_id + FROM document_records + ORDER BY doc_id`) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer rows.Close() + + type item struct { + DocID string `json:"doc_id"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + SHA string `json:"sha,omitempty"` + FileSHA string `json:"file_sha,omitempty"` + } + report := struct { + DryRun bool `json:"dry_run"` + Items []item `json:"items"` + Success int `json:"success"` + Missing int `json:"missing"` + Mismatch int `json:"hash_mismatch"` + }{DryRun: !*apply} + + svc := docrevision.NewService(nil) + for rows.Next() { + var docID, workDir, dbSHA, state string + var projectID int64 + if err := rows.Scan(&docID, &workDir, &dbSHA, &state, &projectID); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + it := item{DocID: docID, SHA: dbSHA} + if workDir == "" { + it.Status = "missing" + it.Detail = "empty work_dir" + report.Missing++ + report.Items = append(report.Items, it) + continue + } + path := workDir + if !filepath.IsAbs(path) { + path = filepath.Join(*repoRoot, workDir) + } + body, err := os.ReadFile(filepath.Join(path, "output.md")) + if err != nil { + it.Status = "missing" + it.Detail = err.Error() + report.Missing++ + report.Items = append(report.Items, it) + continue + } + canon := docrevision.CanonicalizeMarkdown(string(body)) + fileSHA := docrevision.SHA256Hex(canon) + it.FileSHA = fileSHA + if dbSHA != "" && !strings.EqualFold(dbSHA, fileSHA) { + it.Status = "hash_mismatch" + it.Detail = "document_records.content_sha256 != file" + report.Mismatch++ + report.Items = append(report.Items, it) + continue + } + if *apply { + if _, err := svc.OpenTemplateVersion(ctx, docID, canon, docrevision.ActorSnapshot{DisplayName: "backfill"}, projectID, workDir); err != nil { + it.Status = "error" + it.Detail = err.Error() + report.Items = append(report.Items, it) + continue + } + } + it.Status = "success" + report.Success++ + report.Items = append(report.Items, it) + } + enc, _ := json.MarshalIndent(report, "", " ") + if *outPath != "" { + _ = os.WriteFile(*outPath, enc, 0o644) + } + fmt.Println(string(enc)) + if report.Mismatch > 0 { + os.Exit(3) + } +} diff --git a/configs/schemas/change-report-v1.json b/configs/schemas/change-report-v1.json new file mode 100644 index 00000000..74b4165c --- /dev/null +++ b/configs/schemas/change-report-v1.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-system.local/schemas/change-report-v1.json", + "title": "change-report-v1", + "type": "object", + "required": ["schema_version", "summary", "risk"], + "additionalProperties": true, + "properties": { + "schema_version": { "const": 1 }, + "summary": { + "type": "array", + "minItems": 0, + "maxItems": 8, + "items": { "type": "string", "minLength": 1 } + }, + "changes": { + "type": "array", + "items": { + "type": "object", + "required": ["section_locator", "change_type", "rationale"], + "properties": { + "section_locator": { "type": "string" }, + "change_type": { "type": "string" }, + "rationale": { "type": "string" }, + "evidence_refs": { "type": "array", "items": { "type": "string" } } + } + } + }, + "risk": { + "type": "object", + "required": ["level"], + "properties": { + "level": { "enum": ["low", "medium", "high", "critical"] }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["category", "description"], + "properties": { + "category": { "type": "string" }, + "description": { "type": "string" }, + "mitigation": { "type": "string" } + } + } + } + } + }, + "project_impact": { "type": "object" }, + "document_impacts": { + "type": "array", + "items": { + "type": "object", + "required": ["doc_id", "impact_type", "reason"], + "properties": { + "doc_id": { "type": "string" }, + "impact_type": { "type": "string" }, + "reason": { "type": "string" }, + "recommended_action": { "type": "string" } + } + } + }, + "open_questions": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/deploy/postgres/goose/00028_2026-09-13-document-revisions.sql b/deploy/postgres/goose/00028_2026-09-13-document-revisions.sql new file mode 100644 index 00000000..135af7d1 --- /dev/null +++ b/deploy/postgres/goose/00028_2026-09-13-document-revisions.sql @@ -0,0 +1,197 @@ +-- +goose Up +-- Document revision ledger: immutable snapshots, Version/Revision, agent candidates, change reports. +-- Pointer columns use ledger_*/head_* to avoid colliding with freeform_documents.current_version_id. + +CREATE TABLE IF NOT EXISTS document_content_blobs ( + content_sha256 CHAR(64) PRIMARY KEY, + content_markdown TEXT NOT NULL, + byte_size BIGINT NOT NULL CHECK (byte_size >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS document_snapshots ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + content_sha256 CHAR(64) NOT NULL REFERENCES document_content_blobs(content_sha256), + parent_snapshot_id BIGINT REFERENCES document_snapshots(id), + block_map JSONB NOT NULL DEFAULT '[]'::jsonb, + source_kind TEXT NOT NULL, + actor_user_id BIGINT, + actor_ref TEXT NOT NULL DEFAULT '', + actor_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + source_run_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_snapshot_source_kind CHECK (source_kind IN ('template','human','agent','agent_squad','migration','system')) +); + +CREATE INDEX IF NOT EXISTS idx_document_snapshots_doc ON document_snapshots(doc_id, created_at); + +CREATE TABLE IF NOT EXISTS document_versions ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + version_no INT NOT NULL, + status TEXT NOT NULL, + origin_kind TEXT NOT NULL, + baseline_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + head_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + human_revision_count INT NOT NULL DEFAULT 0, + source_run_id TEXT NOT NULL DEFAULT '', + change_report_id BIGINT, + opened_at TIMESTAMPTZ NOT NULL DEFAULT now(), + sealed_at TIMESTAMPTZ, + CONSTRAINT uq_document_versions_doc_no UNIQUE (doc_id, version_no), + CONSTRAINT chk_document_versions_status CHECK (status IN ('open','sealed')), + CONSTRAINT chk_document_versions_origin CHECK (origin_kind IN ('template','agent','agent_squad','migration')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_document_versions_one_open + ON document_versions(doc_id) WHERE status = 'open'; + +CREATE TABLE IF NOT EXISTS document_change_events ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + version_id BIGINT NOT NULL REFERENCES document_versions(id), + event_no INT NOT NULL, + from_snapshot_id BIGINT REFERENCES document_snapshots(id), + to_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + change_kind TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_user_id BIGINT, + actor_ref TEXT NOT NULL DEFAULT '', + actor_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + change_set JSONB NOT NULL DEFAULT '[]'::jsonb, + edit_session_id TEXT NOT NULL DEFAULT '', + request_id TEXT NOT NULL DEFAULT '', + idempotency_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_document_change_events_doc_event UNIQUE (doc_id, event_no), + CONSTRAINT uq_document_change_events_idemp UNIQUE (doc_id, idempotency_key), + CONSTRAINT chk_change_kind CHECK (change_kind IN ('human_revision','agent_commit','agent_noop','import','restore_as_new')), + CONSTRAINT chk_actor_kind CHECK (actor_kind IN ('human','agent','agent_squad','template','migration','system')) +); + +CREATE TABLE IF NOT EXISTS document_change_reports ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + from_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + to_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + schema_version INT NOT NULL DEFAULT 1, + summary JSONB NOT NULL DEFAULT '[]'::jsonb, + risk_level TEXT NOT NULL, + risk_items JSONB NOT NULL DEFAULT '[]'::jsonb, + project_impacts JSONB NOT NULL DEFAULT '{}'::jsonb, + document_impacts JSONB NOT NULL DEFAULT '[]'::jsonb, + open_questions JSONB NOT NULL DEFAULT '[]'::jsonb, + analysis_status TEXT NOT NULL, + model_snapshot TEXT NOT NULL DEFAULT '', + prompt_version TEXT NOT NULL DEFAULT '', + deterministic_facts JSONB NOT NULL DEFAULT '{}'::jsonb, + corrects_report_id BIGINT REFERENCES document_change_reports(id), + raw_json TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_report_risk CHECK (risk_level IN ('low','medium','high','critical')), + CONSTRAINT chk_report_analysis CHECK (analysis_status IN ('complete','fallback','unassessed','corrected')) +); + +ALTER TABLE document_versions + ADD CONSTRAINT fk_document_versions_change_report + FOREIGN KEY (change_report_id) REFERENCES document_change_reports(id); + +CREATE TABLE IF NOT EXISTS document_agent_candidates ( + id BIGSERIAL PRIMARY KEY, + doc_id TEXT NOT NULL, + input_snapshot_id BIGINT NOT NULL REFERENCES document_snapshots(id), + output_snapshot_id BIGINT REFERENCES document_snapshots(id), + run_id TEXT NOT NULL, + proposal_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + authorized_scope TEXT NOT NULL DEFAULT '', + quality_result TEXT NOT NULL DEFAULT '', + change_report_id BIGINT REFERENCES document_change_reports(id), + actor_kind TEXT NOT NULL, + actor_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + decided_at TIMESTAMPTZ, + CONSTRAINT uq_document_agent_candidates_run UNIQUE (doc_id, run_id), + CONSTRAINT chk_candidate_status CHECK (status IN ('ready','needs_confirmation','committed','rejected','stale','quality_failed')), + CONSTRAINT chk_candidate_actor CHECK (actor_kind IN ('agent','agent_squad')) +); + +ALTER TABLE document_records + ADD COLUMN IF NOT EXISTS ledger_version_id BIGINT REFERENCES document_versions(id), + ADD COLUMN IF NOT EXISTS head_snapshot_id BIGINT REFERENCES document_snapshots(id), + ADD COLUMN IF NOT EXISTS head_revision_no INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS review_snapshot_id BIGINT REFERENCES document_snapshots(id), + ADD COLUMN IF NOT EXISTS revision_schema_version INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS row_revision BIGINT NOT NULL DEFAULT 0; + +-- Rename freeform legacy pointer to avoid semantic collision with ledger version id. +ALTER TABLE freeform_documents + RENAME COLUMN current_version_id TO legacy_version_id; + +ALTER TABLE freeform_documents + ADD COLUMN IF NOT EXISTS ledger_version_id BIGINT REFERENCES document_versions(id), + ADD COLUMN IF NOT EXISTS head_snapshot_id BIGINT REFERENCES document_snapshots(id), + ADD COLUMN IF NOT EXISTS head_revision_no INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS revision_schema_version INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS row_revision BIGINT NOT NULL DEFAULT 0; + +-- Prevent UPDATE/DELETE on immutable snapshot bodies via trigger. +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION document_snapshots_immutable() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'document_snapshots is immutable'; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +DROP TRIGGER IF EXISTS trg_document_snapshots_immutable ON document_snapshots; +CREATE TRIGGER trg_document_snapshots_immutable + BEFORE UPDATE OR DELETE ON document_snapshots + FOR EACH ROW EXECUTE FUNCTION document_snapshots_immutable(); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION document_content_blobs_immutable() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'document_content_blobs is immutable'; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +DROP TRIGGER IF EXISTS trg_document_content_blobs_immutable ON document_content_blobs; +CREATE TRIGGER trg_document_content_blobs_immutable + BEFORE UPDATE OR DELETE ON document_content_blobs + FOR EACH ROW EXECUTE FUNCTION document_content_blobs_immutable(); + +-- +goose Down +DROP TRIGGER IF EXISTS trg_document_content_blobs_immutable ON document_content_blobs; +DROP FUNCTION IF EXISTS document_content_blobs_immutable(); +DROP TRIGGER IF EXISTS trg_document_snapshots_immutable ON document_snapshots; +DROP FUNCTION IF EXISTS document_snapshots_immutable(); + +ALTER TABLE freeform_documents + DROP COLUMN IF EXISTS row_revision, + DROP COLUMN IF EXISTS revision_schema_version, + DROP COLUMN IF EXISTS head_revision_no, + DROP COLUMN IF EXISTS head_snapshot_id, + DROP COLUMN IF EXISTS ledger_version_id; + +ALTER TABLE freeform_documents + RENAME COLUMN legacy_version_id TO current_version_id; + +ALTER TABLE document_records + DROP COLUMN IF EXISTS row_revision, + DROP COLUMN IF EXISTS revision_schema_version, + DROP COLUMN IF EXISTS review_snapshot_id, + DROP COLUMN IF EXISTS head_revision_no, + DROP COLUMN IF EXISTS head_snapshot_id, + DROP COLUMN IF EXISTS ledger_version_id; + +ALTER TABLE document_versions DROP CONSTRAINT IF EXISTS fk_document_versions_change_report; + +DROP TABLE IF EXISTS document_agent_candidates; +DROP TABLE IF EXISTS document_change_reports; +DROP TABLE IF EXISTS document_change_events; +DROP TABLE IF EXISTS document_versions; +DROP TABLE IF EXISTS document_snapshots; +DROP TABLE IF EXISTS document_content_blobs; diff --git a/go.mod b/go.mod index 11624df4..e64429ef 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/text v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7c45d2d1..f06b3aee 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,12 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 0efafc07..71567b5e 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -17,6 +17,7 @@ import ( "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/db" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/docrevision" "chat-agent-system/internal/freedoc" "chat-agent-system/internal/kb" "chat-agent-system/internal/projectcontext" @@ -36,6 +37,7 @@ type Service struct { ctxMgr *ctxmgr.ContextManager cas *storage.CAS docGov *docgov.GovernanceEngine + docRev *docrevision.Service freeDoc *freedoc.Service projectWorkflow *projectworkflow.Service pool *pgxpool.Pool @@ -89,7 +91,9 @@ func NewService(cfg *config.Config) (*Service, error) { ctxMgr.SetEmbedder(kb.NewEmbedder(cfg.EmbeddingEndpointURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel)) } docGov := docgov.NewGovernanceEngine(pool) + docRev := docrevision.NewService(pool) freeDoc := freedoc.NewService(pool, ctxMgr) + freeDoc.SetDocRevision(docRev) pwStore := projectworkflow.NewStore(pool) fallbackCatalog := projectworkflow.NewDefaultCatalog() wfStore := workflowdef.NewStore(pool) @@ -106,6 +110,7 @@ func NewService(cfg *config.Config) (*Service, error) { ctxMgr: ctxMgr, cas: casStore, docGov: docGov, + docRev: docRev, freeDoc: freeDoc, projectWorkflow: pwSvc, pool: pool, @@ -199,6 +204,13 @@ func (s *Service) DocGov() *docgov.GovernanceEngine { return s.docGov } +func (s *Service) DocRevision() *docrevision.Service { + if s == nil { + return nil + } + return s.docRev +} + func (s *Service) ProjectWorkflow() *projectworkflow.Service { return s.projectWorkflow } @@ -702,6 +714,31 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { docRec = latest } + // DOCREV: bootstrap ledger or open agent candidate against current head. + if s.docRev != nil { + if _, stErr := s.docRev.GetRevisionState(ctx, docID); stErr != nil { + actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed} + _, _ = s.docRev.OpenTemplateVersion(ctx, docID, res.MarkdownBody, actor, req.ProjectID, res.WorkDir) + } else { + st, _ := s.docRev.GetRevisionState(ctx, docID) + reportJSON := `{"schema_version":1,"summary":["智能体交付物已生成"],"risk":{"level":"medium","items":[]},"document_impacts":[],"open_questions":[]}` + if req.TaskKey == "system_reqs" { + reportJSON = `{"schema_version":1,"summary":["系统需求规格已更新"],"risk":{"level":"high","items":[{"category":"verification","description":"可能影响下游验证"}]},"document_impacts":[],"open_questions":[]}` + } + cand, rep, _ := s.docRev.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ + DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey), + ProposalID: appliedProposalID, InputSnapshotID: st.HeadSnapshotID, + OutputMarkdown: res.MarkdownBody, ReportJSON: reportJSON, + ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed}, + }) + if cand != nil && cand.Status != docrevision.CandidateStale && rep != nil && !docrevision.NeedsConfirmation(rep.RiskLevel) { + _, _ = s.docRev.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{ + Decision: "commit", Confirmed: true, ActorUserID: req.UserID, + ExpectedInputSHA: st.HeadSHA256, + }) + } + } + } } if res.Success && req.TaskKey == "test_manager" && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 { diff --git a/internal/docrevision/blockmap.go b/internal/docrevision/blockmap.go new file mode 100644 index 00000000..f18bccd5 --- /dev/null +++ b/internal/docrevision/blockmap.go @@ -0,0 +1,185 @@ +package docrevision + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "regexp" + "strings" +) + +var ( + headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+)$`) + reqIDRe = regexp.MustCompile(`\b(REQ-[A-Z0-9-]+)\b`) + listItemRe = regexp.MustCompile(`^(\s*[-*+]|\s*\d+\.)\s+`) + tableRowRe = regexp.MustCompile(`^\|`) + fenceRe = regexp.MustCompile("^```") + mermaidRe = regexp.MustCompile(`(?i)^mermaid\b`) +) + +// BuildBlockMap parses markdown into stable block nodes. +// When previous is provided, nodes are aligned by business key → heading path → fingerprint → order. +func BuildBlockMap(markdown string, previous []BlockNode) []BlockNode { + lines := strings.Split(strings.TrimSuffix(markdown, "\n"), "\n") + var nodes []BlockNode + offset := 0 + headingPath := []string{} + inFence := false + fenceLang := "" + var fenceBuf []string + fenceStart := 0 + order := 0 + + flushFence := func() { + if !inFence { + return + } + body := strings.Join(fenceBuf, "\n") + typ := "code" + if mermaidRe.MatchString(fenceLang) { + typ = "mermaid" + } + nodes = append(nodes, makeNode(typ, fenceStart, offset, strings.Join(headingPath, " / "), "", body, order, previous)) + order++ + inFence = false + fenceBuf = nil + fenceLang = "" + } + + for i, line := range lines { + lineLen := len(line) + if i < len(lines)-1 || strings.HasSuffix(markdown, "\n") { + lineLen++ // account for newline except possibly last without NL; we trimSuffix above so always +1 except last + } + // recompute offset carefully + _ = lineLen + } + + offset = 0 + for i, line := range lines { + start := offset + end := start + len(line) + if i < len(lines)-1 { + end++ // newline + } else if strings.HasSuffix(markdown, "\n") { + end++ + } + + if fenceRe.MatchString(line) { + if !inFence { + inFence = true + fenceLang = strings.TrimSpace(strings.TrimPrefix(line, "```")) + fenceStart = start + fenceBuf = nil + } else { + flushFence() + } + offset = end + continue + } + if inFence { + fenceBuf = append(fenceBuf, line) + offset = end + continue + } + + if m := headingRe.FindStringSubmatch(line); m != nil { + level := len(m[1]) + title := strings.TrimSpace(m[2]) + if level <= len(headingPath) { + headingPath = headingPath[:level-1] + } + headingPath = append(headingPath, title) + path := strings.Join(headingPath, " / ") + bk := "" + if id := reqIDRe.FindString(title); id != "" { + bk = id + } + nodes = append(nodes, makeNode("heading", start, end, path, bk, line, order, previous)) + order++ + offset = end + continue + } + + path := strings.Join(headingPath, " / ") + switch { + case tableRowRe.MatchString(line): + bk := "" + if id := reqIDRe.FindString(line); id != "" { + bk = id + } + nodes = append(nodes, makeNode("table_row", start, end, path, bk, line, order, previous)) + case listItemRe.MatchString(line): + bk := "" + if id := reqIDRe.FindString(line); id != "" { + bk = id + } + nodes = append(nodes, makeNode("list_item", start, end, path, bk, line, order, previous)) + case strings.TrimSpace(line) == "": + // skip empty + default: + bk := "" + if id := reqIDRe.FindString(line); id != "" { + bk = id + } + nodes = append(nodes, makeNode("paragraph", start, end, path, bk, line, order, previous)) + } + order++ + offset = end + } + flushFence() + return nodes +} + +func makeNode(typ string, start, end int, path, businessKey, text string, order int, previous []BlockNode) BlockNode { + fp := ContentFingerprint(text) + id := matchPreviousID(typ, path, businessKey, fp, order, previous) + if id == "" { + id = newBlockID() + } + return BlockNode{ + ID: id, + Type: typ, + StartOffset: start, + EndOffset: end, + HeadingPath: path, + BusinessKey: businessKey, + Fingerprint: fp, + Order: order, + } +} + +func matchPreviousID(typ, path, businessKey, fp string, order int, previous []BlockNode) string { + if len(previous) == 0 { + return "" + } + if businessKey != "" { + for _, p := range previous { + if p.BusinessKey == businessKey && p.Type == typ { + return p.ID + } + } + } + for _, p := range previous { + if p.Type == typ && p.HeadingPath == path && p.Fingerprint == fp { + return p.ID + } + } + for _, p := range previous { + if p.Type == typ && p.Fingerprint == fp { + return p.ID + } + } + for _, p := range previous { + if p.Order == order && p.Type == typ { + return p.ID + } + } + return "" +} + +func newBlockID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return fmt.Sprintf("blk_%s", hex.EncodeToString(b[:])) +} diff --git a/internal/docrevision/canonicalize.go b/internal/docrevision/canonicalize.go new file mode 100644 index 00000000..5156002e --- /dev/null +++ b/internal/docrevision/canonicalize.go @@ -0,0 +1,49 @@ +package docrevision + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/unicode/norm" +) + +// CanonicalizeMarkdown applies LF, UTF-8 NFC, trailing newline, and trims trailing spaces per line. +func CanonicalizeMarkdown(raw string) string { + s := strings.ReplaceAll(raw, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + s = norm.NFC.String(s) + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimRightFunc(line, func(r rune) bool { + return r == ' ' || r == '\t' + }) + } + s = strings.Join(lines, "\n") + if s != "" && !strings.HasSuffix(s, "\n") { + s += "\n" + } + return s +} + +func SHA256Hex(content string) string { + sum := sha256.Sum256([]byte(content)) + return hex.EncodeToString(sum[:]) +} + +func ContentFingerprint(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if unicode.IsSpace(r) { + continue + } + b.WriteRune(r) + } + return SHA256Hex(b.String())[:16] +} + +func RuneCount(s string) int { + return utf8.RuneCountInString(s) +} diff --git a/internal/docrevision/concurrency_test.go b/internal/docrevision/concurrency_test.go new file mode 100644 index 00000000..0468351f --- /dev/null +++ b/internal/docrevision/concurrency_test.go @@ -0,0 +1,100 @@ +package docrevision + +import ( + "context" + "fmt" + "sync" + "testing" +) + +func TestConcurrentHumanRevisions(t *testing.T) { + svc := NewService(nil) + ctx := context.Background() + st, err := svc.OpenTemplateVersion(ctx, "doc_conc", "# base\n", ActorSnapshot{}, 1, "") + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + errs := make(chan error, 50) + for i := 0; i < 50; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + md := fmt.Sprintf("# base\n\nedit %d\n", i) + // Only first expected head will succeed; others conflict — that's OK. + _, err := svc.CommitHumanRevision(ctx, HumanRevisionRequest{ + DocID: "doc_conc", Markdown: md, + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: fmt.Sprintf("conc-%d", i), ActorUserID: int64(i + 1), LifecycleState: "drafting", + }) + if err != nil && err != ErrHeadConflict { + errs <- err + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatal(err) + } + // Sequential chain should produce continuous revision numbers + svc2 := NewService(nil) + st2, _ := svc2.OpenTemplateVersion(ctx, "doc_seq", "# s\n", ActorSnapshot{}, 1, "") + head := st2.HeadSnapshotID + rev := st2.HeadRevisionNo + for i := 0; i < 20; i++ { + r, err := svc2.CommitHumanRevision(ctx, HumanRevisionRequest{ + DocID: "doc_seq", Markdown: fmt.Sprintf("# s\n\n%d\n", i), + ExpectedHeadSnapshot: &head, ExpectedRevisionNo: &rev, + IdempotencyKey: fmt.Sprintf("seq-%d", i), ActorUserID: 1, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r.State.HeadRevisionNo != i+1 { + t.Fatalf("revision_no want %d got %d", i+1, r.State.HeadRevisionNo) + } + head = r.State.HeadSnapshotID + rev = r.State.HeadRevisionNo + } + vers, _ := svc2.ListVersions(ctx, "doc_seq") + open := 0 + for _, v := range vers { + if v.Status == VersionOpen { + open++ + } + } + if open != 1 { + t.Fatalf("want exactly one open version, got %d", open) + } +} + +func TestFreeformLegacyImport(t *testing.T) { + svc := NewService(nil) + ctx := context.Background() + st, err := svc.ImportFreeformLegacy(ctx, "ff1", 9, 1, []struct { + VersionNo int + Markdown string + SHA string + }{ + {VersionNo: 1, Markdown: "# v1\n"}, + {VersionNo: 2, Markdown: "# v2\n"}, + }, "# v3 head\n") + if err != nil { + t.Fatal(err) + } + if st.CurrentVersionNo != 3 { + t.Fatalf("want version 3 open, got %d", st.CurrentVersionNo) + } + r, err := svc.CommitHumanRevision(ctx, HumanRevisionRequest{ + DocID: "ff1", Markdown: "# v3 head\n\nedit\n", + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: "ff-e1", ActorUserID: 9, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r.State.CurrentVersionNo != 3 || r.State.HeadRevisionNo != 1 { + t.Fatalf("human save should only bump revision: %+v", r.State) + } +} diff --git a/internal/docrevision/contract_test.go b/internal/docrevision/contract_test.go new file mode 100644 index 00000000..d706af45 --- /dev/null +++ b/internal/docrevision/contract_test.go @@ -0,0 +1,225 @@ +package docrevision_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "chat-agent-system/internal/docrevision" +) + +func TestCanonicalize_NFC_LF_TrailingNewline(t *testing.T) { + in := "a\r\n b \t\n" + out := docrevision.CanonicalizeMarkdown(in) + if out != "a\n b\n" { + t.Fatalf("got %q", out) + } +} + +func TestGoldenDiffFixtures(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "golden_diff_cases.json")) + if err != nil { + t.Fatal(err) + } + var fixture struct { + Cases []struct { + ID string `json:"id"` + Before string `json:"before"` + After string `json:"after"` + ExpectKinds []string `json:"expect_kinds"` + } `json:"cases"` + } + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + for _, c := range fixture.Cases { + t.Run(c.ID, func(t *testing.T) { + before := docrevision.CanonicalizeMarkdown(c.Before) + after := docrevision.CanonicalizeMarkdown(c.After) + fb := docrevision.BuildBlockMap(before, nil) + ab := docrevision.BuildBlockMap(after, fb) + diff := docrevision.ComputeDiff(before, after, fb, ab) + if len(c.ExpectKinds) == 0 { + if len(diff.Hunks) != 0 { + t.Fatalf("noop expected empty hunks, got %+v", diff.Hunks) + } + return + } + found := map[string]bool{} + for _, h := range diff.Hunks { + found[h.Kind] = true + } + for _, k := range c.ExpectKinds { + if !found[k] { + t.Fatalf("expected kind %s in %+v", k, diff.Hunks) + } + } + }) + } +} + +func TestContractTimeline_VersionRevisionSemantics(t *testing.T) { + svc := docrevision.NewService(nil) + ctx := context.Background() + actor := docrevision.ActorSnapshot{DisplayName: "张工", Role: "author", Duty: "author"} + + st, err := svc.OpenTemplateVersion(ctx, "doc_demo", "# V1\n\n模板正文\n", actor, 1, "workspaces/demo") + if err != nil { + t.Fatal(err) + } + if st.CurrentVersionNo != 1 || st.HeadRevisionNo != 0 { + t.Fatalf("T0 state: %+v", st) + } + + r1, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_demo", Markdown: "# V1\n\n模板正文\n\n张工修改\n", + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: "k1", ActorUserID: 10, ActorSnapshot: actor, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r1.State.CurrentVersionNo != 1 || r1.State.HeadRevisionNo != 1 || r1.State.ManualChangeCount != 1 { + t.Fatalf("T1: %+v", r1.State) + } + + r2, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_demo", Markdown: "# V1\n\n模板正文\n\n张工修改\n李工再改\n", + ExpectedHeadSnapshot: &r1.State.HeadSnapshotID, ExpectedRevisionNo: &r1.State.HeadRevisionNo, + IdempotencyKey: "k2", ActorUserID: 11, ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "李工"}, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r2.State.CurrentVersionNo != 1 || r2.State.ManualChangeCount != 2 { + t.Fatalf("T2: %+v", r2.State) + } + + reportRaw, _ := os.ReadFile(filepath.Join("testdata", "change_report_v1_valid.json")) + cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ + DocID: "doc_demo", RunID: "run-1", InputSnapshotID: r2.State.HeadSnapshotID, + OutputMarkdown: "# V2\n\n智能体新基线\n", ReportJSON: string(reportRaw), + ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "squad-a"}, + }) + if err != nil && cand == nil { + t.Fatal(err) + } + if cand.Status != docrevision.CandidateNeedsConfirmation { + t.Fatalf("high risk should need confirmation, got %s risk=%s", cand.Status, rep.RiskLevel) + } + _, err = svc.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{ + Decision: "commit", Confirmed: false, ActorUserID: 10, + }) + if err != docrevision.ErrNeedsConfirmation { + t.Fatalf("expected needs confirmation, got %v", err) + } + st3, err := svc.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{ + Decision: "commit", Confirmed: true, ActorUserID: 10, + ExpectedInputSHA: r2.State.HeadSHA256, + }) + if err != nil { + t.Fatal(err) + } + if st3.CurrentVersionNo != 2 || st3.HeadRevisionNo != 0 || st3.ManualChangeCount != 0 { + t.Fatalf("T3 after agent: %+v", st3) + } + + overlay, err := svc.ManualOverlay(ctx, "doc_demo") + if err != nil { + t.Fatal(err) + } + if len(overlay.Hunks) != 0 { + t.Fatalf("manual overlay should reset after agent baseline, got %+v", overlay) + } + + r4, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_demo", Markdown: "# V2\n\n智能体新基线\n张工再改\n", + ExpectedHeadSnapshot: &st3.HeadSnapshotID, ExpectedRevisionNo: &st3.HeadRevisionNo, + IdempotencyKey: "k4", ActorUserID: 10, ActorSnapshot: actor, LifecycleState: "drafting", + }) + if err != nil { + t.Fatal(err) + } + if r4.State.CurrentVersionNo != 2 || r4.State.ManualChangeCount != 1 { + t.Fatalf("T4: %+v", r4.State) + } + + diff, err := svc.DiffVersions(ctx, "doc_demo", 1, 2, "final", "final") + if err != nil { + t.Fatal(err) + } + if len(diff.Hunks) == 0 { + t.Fatal("V1 vs V2 should differ") + } +} + +func TestBlockMapStableAcrossRebuild(t *testing.T) { + md := "## 需求\n\nREQ-PERF-007 温度精度 ±1℃\n" + a := docrevision.BuildBlockMap(md, nil) + b := docrevision.BuildBlockMap(md+"\n补充一句。\n", a) + found := false + for _, n := range b { + if n.BusinessKey == "REQ-PERF-007" { + for _, o := range a { + if o.BusinessKey == "REQ-PERF-007" && o.ID == n.ID { + found = true + } + } + } + } + if !found { + t.Fatalf("business key id not stable: a=%+v b=%+v", a, b) + } +} + +func TestIdempotentHumanRevision(t *testing.T) { + svc := docrevision.NewService(nil) + ctx := context.Background() + st, _ := svc.OpenTemplateVersion(ctx, "doc_idem", "# A\n", docrevision.ActorSnapshot{}, 1, "") + req := docrevision.HumanRevisionRequest{ + DocID: "doc_idem", Markdown: "# A\n\nB\n", + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: "same", ActorUserID: 1, LifecycleState: "drafting", + } + r1, err := svc.CommitHumanRevision(ctx, req) + if err != nil { + t.Fatal(err) + } + r2, err := svc.CommitHumanRevision(ctx, req) + if err != nil { + t.Fatal(err) + } + if r1.Event.ID != r2.Event.ID || r1.State.HeadSnapshotID != r2.State.HeadSnapshotID { + t.Fatalf("idempotency broken") + } +} + +func TestHeadConflict(t *testing.T) { + svc := docrevision.NewService(nil) + ctx := context.Background() + st, _ := svc.OpenTemplateVersion(ctx, "doc_c", "# A\n", docrevision.ActorSnapshot{}, 1, "") + wrong := st.HeadSnapshotID + 99 + _, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_c", Markdown: "# B\n", ExpectedHeadSnapshot: &wrong, + IdempotencyKey: "x", ActorUserID: 1, LifecycleState: "drafting", + }) + if err != docrevision.ErrHeadConflict { + t.Fatalf("got %v", err) + } +} + +func TestLifecycleLock(t *testing.T) { + svc := docrevision.NewService(nil) + ctx := context.Background() + st, _ := svc.OpenTemplateVersion(ctx, "doc_l", "# A\n", docrevision.ActorSnapshot{}, 1, "") + svc.SetLifecycle("doc_l", "reviewing") + _, err := svc.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: "doc_l", Markdown: "# B\n", ExpectedHeadSnapshot: &st.HeadSnapshotID, + IdempotencyKey: "y", ActorUserID: 1, LifecycleState: "reviewing", + }) + if err != docrevision.ErrLifecycleLocked { + t.Fatalf("got %v", err) + } +} diff --git a/internal/docrevision/diff.go b/internal/docrevision/diff.go new file mode 100644 index 00000000..ee008ce1 --- /dev/null +++ b/internal/docrevision/diff.go @@ -0,0 +1,242 @@ +package docrevision + +import ( + "fmt" + "strings" +) + +// ComputeDiff produces a line-oriented structured diff between two markdown bodies. +func ComputeDiff(fromMarkdown, toMarkdown string, fromBlocks, toBlocks []BlockNode) DiffResult { + fromLines := splitLines(fromMarkdown) + toLines := splitLines(toMarkdown) + hunks := myersLineDiff(fromLines, toLines, fromBlocks, toBlocks) + // Detect moves on raw delete/insert pairs before collapsing modifies. + hunks = annotateMoves(hunks, fromBlocks, toBlocks) + hunks = collapseAdjacentModify(hunks) + sum := DiffSummary{} + for _, h := range hunks { + switch h.Kind { + case "insert": + sum.Inserts++ + case "delete": + sum.Deletes++ + case "modify": + sum.Modifies++ + case "move": + sum.Moves++ + } + } + return DiffResult{Summary: sum, Hunks: hunks} +} + +func splitLines(s string) []string { + s = strings.TrimSuffix(s, "\n") + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +func myersLineDiff(a, b []string, fromBlocks, toBlocks []BlockNode) []DiffHunk { + n, m := len(a), len(b) + // Simple LCS DP for correctness on moderate docs. + dp := make([][]int, n+1) + for i := range dp { + dp[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if a[i] == b[j] { + dp[i][j] = dp[i+1][j+1] + 1 + } else if dp[i+1][j] >= dp[i][j+1] { + dp[i][j] = dp[i+1][j] + } else { + dp[i][j] = dp[i][j+1] + } + } + } + var hunks []DiffHunk + i, j := 0, 0 + idx := 0 + for i < n && j < m { + if a[i] == b[j] { + i++ + j++ + continue + } + if dp[i+1][j] >= dp[i][j+1] { + h := DiffHunk{ + ID: fmt.Sprintf("h%d", idx), + Kind: "delete", + BeforeText: a[i], + LineStart: i + 1, + LineEnd: i + 1, + Section: sectionForLine(i, fromBlocks), + } + if blk := blockForLine(i, a, fromBlocks); blk != nil { + h.BlockIDBefore = blk.ID + } + hunks = append(hunks, h) + idx++ + i++ + } else { + h := DiffHunk{ + ID: fmt.Sprintf("h%d", idx), + Kind: "insert", + AfterText: b[j], + LineStart: j + 1, + LineEnd: j + 1, + Section: sectionForLine(j, toBlocks), + } + if blk := blockForLine(j, b, toBlocks); blk != nil { + h.BlockIDAfter = blk.ID + } + hunks = append(hunks, h) + idx++ + j++ + } + } + for i < n { + h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "delete", BeforeText: a[i], LineStart: i + 1, LineEnd: i + 1, Section: sectionForLine(i, fromBlocks)} + if blk := blockForLine(i, a, fromBlocks); blk != nil { + h.BlockIDBefore = blk.ID + } + hunks = append(hunks, h) + idx++ + i++ + } + for j < m { + h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "insert", AfterText: b[j], LineStart: j + 1, LineEnd: j + 1, Section: sectionForLine(j, toBlocks)} + if blk := blockForLine(j, b, toBlocks); blk != nil { + h.BlockIDAfter = blk.ID + } + hunks = append(hunks, h) + idx++ + j++ + } + return hunks +} + +func collapseAdjacentModify(hunks []DiffHunk) []DiffHunk { + if len(hunks) == 0 { + return hunks + } + out := make([]DiffHunk, 0, len(hunks)) + for i := 0; i < len(hunks); i++ { + if i+1 < len(hunks) && hunks[i].Kind == "delete" && hunks[i+1].Kind == "insert" { + out = append(out, DiffHunk{ + ID: hunks[i].ID, + Kind: "modify", + BeforeText: hunks[i].BeforeText, + AfterText: hunks[i+1].AfterText, + BlockIDBefore: hunks[i].BlockIDBefore, + BlockIDAfter: hunks[i+1].BlockIDAfter, + Section: firstNonEmpty(hunks[i].Section, hunks[i+1].Section), + LineStart: hunks[i].LineStart, + LineEnd: hunks[i+1].LineEnd, + }) + i++ + continue + } + out = append(out, hunks[i]) + } + return out +} + +func annotateMoves(hunks []DiffHunk, fromBlocks, toBlocks []BlockNode) []DiffHunk { + fromByFP := map[string]string{} + for _, b := range fromBlocks { + fromByFP[b.Fingerprint] = b.ID + } + toByFP := map[string]string{} + for _, b := range toBlocks { + toByFP[b.Fingerprint] = b.ID + } + deletedFP := map[string]int{} + insertedFP := map[string]int{} + for i, h := range hunks { + if h.Kind == "delete" { + deletedFP[ContentFingerprint(h.BeforeText)] = i + } + if h.Kind == "insert" { + insertedFP[ContentFingerprint(h.AfterText)] = i + } + } + used := map[int]bool{} + out := make([]DiffHunk, 0, len(hunks)) + for i, h := range hunks { + if used[i] { + continue + } + if h.Kind == "delete" { + fp := ContentFingerprint(h.BeforeText) + if j, ok := insertedFP[fp]; ok && !used[j] { + used[i] = true + used[j] = true + mh := DiffHunk{ + ID: h.ID, + Kind: "move", + BeforeText: h.BeforeText, + AfterText: hunks[j].AfterText, + BlockIDBefore: h.BlockIDBefore, + BlockIDAfter: hunks[j].BlockIDAfter, + Section: fmt.Sprintf("%s → %s", h.Section, hunks[j].Section), + LineStart: h.LineStart, + LineEnd: hunks[j].LineEnd, + } + out = append(out, mh) + continue + } + } + if h.Kind == "insert" { + fp := ContentFingerprint(h.AfterText) + if _, ok := deletedFP[fp]; ok { + // paired as move above + if used[i] { + continue + } + } + } + out = append(out, h) + } + return out +} + +func sectionForLine(lineIdx int, blocks []BlockNode) string { + // Approximate: find block whose order matches line; fallback heading path of nearest prior. + if lineIdx < 0 || len(blocks) == 0 { + return "" + } + best := "" + for _, b := range blocks { + if b.Order <= lineIdx { + if b.HeadingPath != "" { + best = b.HeadingPath + } + } + } + return best +} + +func blockForLine(lineIdx int, lines []string, blocks []BlockNode) *BlockNode { + if lineIdx < 0 || lineIdx >= len(lines) { + return nil + } + fp := ContentFingerprint(lines[lineIdx]) + for i := range blocks { + if blocks[i].Fingerprint == fp { + return &blocks[i] + } + } + if lineIdx < len(blocks) { + return &blocks[lineIdx] + } + return nil +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/internal/docrevision/impact.go b/internal/docrevision/impact.go new file mode 100644 index 00000000..f5b8bb2b --- /dev/null +++ b/internal/docrevision/impact.go @@ -0,0 +1,25 @@ +package docrevision + +// ImpactFromLineage builds deterministic document impacts from known downstream doc IDs. +func ImpactFromLineage(downstream []struct { + DocID string + Reason string +}) []DocImpact { + out := make([]DocImpact, 0, len(downstream)) + for _, d := range downstream { + if d.DocID == "" { + continue + } + reason := d.Reason + if reason == "" { + reason = "上游文档内容变更,下游需复核" + } + out = append(out, DocImpact{ + DocID: d.DocID, + ImpactType: "stale_candidate", + Reason: reason, + RecommendedAction: "由编制负责人复核并重新基线", + }) + } + return out +} diff --git a/internal/docrevision/risk.go b/internal/docrevision/risk.go new file mode 100644 index 00000000..e2055822 --- /dev/null +++ b/internal/docrevision/risk.go @@ -0,0 +1,160 @@ +package docrevision + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +var ( + sensitiveDeleteRe = regexp.MustCompile(`(?i)(法规|风险控制|验收标准|技术参数|REQ-|GB\d|YY\d)`) + numericChangeRe = regexp.MustCompile(`\d+(\.\d+)?`) +) + +// MaxRisk returns the higher of two risk levels. +func MaxRisk(a, b string) string { + rank := map[string]int{RiskLow: 1, RiskMedium: 2, RiskHigh: 3, RiskCritical: 4, "": 0} + if rank[a] >= rank[b] { + if a == "" { + return RiskLow + } + return a + } + if b == "" { + return RiskLow + } + return b +} + +// RuleRiskFloor computes deterministic risk from diff hunks. +func RuleRiskFloor(hunks []DiffHunk, impacts []DocImpact) string { + level := RiskLow + for _, h := range hunks { + text := h.BeforeText + "\n" + h.AfterText + if h.Kind == "delete" && sensitiveDeleteRe.MatchString(h.BeforeText) { + level = MaxRisk(level, RiskHigh) + } + if h.Kind == "modify" && sensitiveDeleteRe.MatchString(text) { + level = MaxRisk(level, RiskHigh) + } + if h.Kind == "modify" && numericChangeRe.MatchString(h.BeforeText) && numericChangeRe.MatchString(h.AfterText) && h.BeforeText != h.AfterText { + if sensitiveDeleteRe.MatchString(text) { + level = MaxRisk(level, RiskHigh) + } else { + level = MaxRisk(level, RiskMedium) + } + } + } + for _, imp := range impacts { + if imp.ImpactType == "stale_candidate" || strings.Contains(imp.Reason, "已批准") { + level = MaxRisk(level, RiskCritical) + } else if imp.DocID != "" { + level = MaxRisk(level, RiskHigh) + } + } + return level +} + +// ValidateChangeReportJSON parses and validates change_report.json against deterministic facts. +func ValidateChangeReportJSON(raw string, fromSHA, toSHA string, ruleRisk string, impacts []DocImpact, hunks []DiffHunk) (*ChangeReport, error) { + if strings.TrimSpace(raw) == "" { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "empty report"), ErrReportInvalid + } + var payload struct { + SchemaVersion int `json:"schema_version"` + Summary []string `json:"summary"` + Changes []map[string]any `json:"changes"` + Risk struct { + Level string `json:"level"` + Items []RiskItem `json:"items"` + } `json:"risk"` + ProjectImpact map[string]any `json:"project_impact"` + DocumentImpacts []DocImpact `json:"document_impacts"` + OpenQuestions []string `json:"open_questions"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "json parse failed"), fmt.Errorf("%w: %v", ErrReportInvalid, err) + } + if payload.SchemaVersion != 1 { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "bad schema"), fmt.Errorf("%w: schema_version", ErrReportInvalid) + } + if fromSHA != toSHA && len(payload.Summary) == 0 && len(payload.Changes) == 0 { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "claims no change"), fmt.Errorf("%w: missing summary for changed content", ErrReportInvalid) + } + if fromSHA != toSHA && strings.EqualFold(strings.Join(payload.Summary, " "), "未修改") { + return fallbackReport(fromSHA, toSHA, ruleRisk, impacts, hunks, "claims unmodified"), fmt.Errorf("%w: unmodified claim", ErrReportInvalid) + } + finalRisk := MaxRisk(ruleRisk, payload.Risk.Level) + if finalRisk == "" { + finalRisk = RiskUnassessedAsHigh() + } + rep := &ChangeReport{ + SchemaVersion: 1, + Summary: payload.Summary, + RiskLevel: finalRisk, + RiskItems: payload.Risk.Items, + ProjectImpacts: payload.ProjectImpact, + DocumentImpacts: mergeImpacts(payload.DocumentImpacts, impacts), + OpenQuestions: payload.OpenQuestions, + AnalysisStatus: AnalysisComplete, + RawJSON: raw, + DeterministicFacts: map[string]any{ + "from_sha": fromSHA, + "to_sha": toSHA, + "rule_risk": ruleRisk, + "hunk_count": len(hunks), + "impact_count": len(impacts), + }, + } + return rep, nil +} + +func RiskUnassessedAsHigh() string { return RiskHigh } + +func fallbackReport(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, hunks []DiffHunk, reason string) *ChangeReport { + risk := ruleRisk + if risk == "" { + risk = RiskHigh + } + return &ChangeReport{ + SchemaVersion: 1, + Summary: []string{"变更报告未完整评估,已按保守策略处理"}, + RiskLevel: risk, + DocumentImpacts: impacts, + OpenQuestions: []string{reason}, + AnalysisStatus: AnalysisUnassessed, + DeterministicFacts: map[string]any{ + "from_sha": fromSHA, + "to_sha": toSHA, + "rule_risk": ruleRisk, + "hunk_count": len(hunks), + "fallback": reason, + }, + } +} + +func mergeImpacts(model, det []DocImpact) []DocImpact { + seen := map[string]bool{} + out := make([]DocImpact, 0, len(model)+len(det)) + for _, d := range det { + if d.DocID == "" || seen[d.DocID] { + continue + } + seen[d.DocID] = true + out = append(out, d) + } + for _, m := range model { + if m.DocID == "" || seen[m.DocID] { + continue + } + seen[m.DocID] = true + out = append(out, m) + } + return out +} + +// NeedsConfirmation reports whether frozen policy requires author confirmation. +func NeedsConfirmation(risk string) bool { + return risk == RiskHigh || risk == RiskCritical +} diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go new file mode 100644 index 00000000..b837b4c5 --- /dev/null +++ b/internal/docrevision/service.go @@ -0,0 +1,712 @@ +package docrevision + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Service is the document revision ledger. +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 + + nextSnapID int64 + nextVerID int64 + nextEvtID int64 + nextCandID int64 + nextRepID int64 + + // Outbox hooks (Wave 5); optional. + OnOutbox func(ctx context.Context, eventType string, payload map[string]any) error + + // Feature flags + LedgerWrite bool + LedgerRead bool + AgentCommit bool + DiffUI bool + Notify bool +} + +func NewService(pool *pgxpool.Pool) *Service { + return &Service{ + pool: pool, + 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{}, + LedgerWrite: true, + LedgerRead: true, + AgentCommit: true, + DiffUI: true, + Notify: true, + } +} + +func (s *Service) EnsureRoot(docID, kind string, projectID int64, lifecycle string) *DocRootPointers { + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.roots[docID]; ok { + return r + } + r := &DocRootPointers{ + DocID: docID, + Kind: kind, + LifecycleState: lifecycle, + ProjectID: projectID, + RevisionSchemaVer: SchemaVersion, + } + s.roots[docID] = r + return r +} + +func (s *Service) GetRevisionState(ctx context.Context, docID string) (*RevisionState, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + root, ok := s.roots[docID] + if !ok || root.LedgerVersionID == nil { + return nil, ErrNotFound + } + ver := s.versions[*root.LedgerVersionID] + head := s.snapshots[*root.HeadSnapshotID] + caps := []string{"read"} + if root.LifecycleState == "drafting" { + caps = append(caps, "edit") + } + if s.DiffUI { + caps = append(caps, "diff", "overlay", "history") + } + return &RevisionState{ + DocID: docID, + CurrentVersionNo: ver.VersionNo, + VersionStatus: ver.Status, + BaselineSnapshotID: ver.BaselineSnapshotID, + HeadSnapshotID: ver.HeadSnapshotID, + HeadRevisionNo: root.HeadRevisionNo, + HeadSHA256: head.ContentSHA256, + ManualChangeCount: ver.HumanRevisionCount, + Capabilities: caps, + ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo), + }, nil +} + +func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequest) (*HumanRevisionResult, error) { + _ = ctx + if req.IdempotencyKey == "" { + return nil, fmt.Errorf("docrevision: idempotency key required") + } + if req.LifecycleState != "" && req.LifecycleState != "drafting" { + return nil, ErrLifecycleLocked + } + canon := CanonicalizeMarkdown(req.Markdown) + if len(canon) > MaxMarkdownBytes { + return nil, ErrTooLarge + } + sha := SHA256Hex(canon) + + s.mu.Lock() + defer s.mu.Unlock() + + if eid, ok := s.idemp[req.IdempotencyKey]; ok { + ev := s.events[eid] + st, _ := s.revisionStateLocked(req.DocID) + overlay := s.manualOverlayLocked(req.DocID) + return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil + } + + root := s.roots[req.DocID] + if root == nil { + root = &DocRootPointers{DocID: req.DocID, Kind: "controlled", LifecycleState: req.LifecycleState, ProjectID: req.ProjectID, RevisionSchemaVer: SchemaVersion, WorkDir: req.WorkDir} + s.roots[req.DocID] = root + } + + if root.LedgerVersionID == nil { + if !req.AllowCreateBaseline { + return nil, ErrNoOpenVersion + } + snap := s.putSnapshotLocked(req.DocID, canon, sha, nil, SourceHuman, &req.ActorUserID, "", req.ActorSnapshot, "", req.ClientBlockMap) + ver := s.openVersionLocked(req.DocID, OriginTemplate, snap.ID, snap.ID, "") + root.LedgerVersionID = &ver.ID + root.HeadSnapshotID = &snap.ID + root.HeadRevisionNo = 0 + root.ContentSHA256 = sha + root.RowRevision++ + ev := s.putEventLocked(req.DocID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, nil, req.EditSessionID, req.RequestID, req.IdempotencyKey) + st := s.mustStateLocked(req.DocID) + return &HumanRevisionResult{State: *st, Event: *ev}, nil + } + + ver := s.versions[*root.LedgerVersionID] + if ver.Status != VersionOpen { + return nil, ErrNoOpenVersion + } + if req.ExpectedHeadSnapshot != nil && *req.ExpectedHeadSnapshot != ver.HeadSnapshotID { + cur := s.snapshots[ver.HeadSnapshotID] + return &HumanRevisionResult{Conflict: &ConflictInfo{ + Code: ErrCodeHeadConflict, BaseSnapshotID: ver.BaselineSnapshotID, + CurrentSnapshotID: ver.HeadSnapshotID, CurrentRevisionNo: root.HeadRevisionNo, CurrentSHA256: cur.ContentSHA256, + }}, ErrHeadConflict + } + if req.ExpectedRevisionNo != nil && *req.ExpectedRevisionNo != root.HeadRevisionNo { + cur := s.snapshots[ver.HeadSnapshotID] + return &HumanRevisionResult{Conflict: &ConflictInfo{ + Code: ErrCodeHeadConflict, BaseSnapshotID: ver.BaselineSnapshotID, + CurrentSnapshotID: ver.HeadSnapshotID, CurrentRevisionNo: root.HeadRevisionNo, CurrentSHA256: cur.ContentSHA256, + }}, ErrHeadConflict + } + + parentID := ver.HeadSnapshotID + parent := s.snapshots[parentID] + if parent.ContentSHA256 == sha { + // no-op: return current without new snapshot/version + st := s.mustStateLocked(req.DocID) + overlay := s.manualOverlayLocked(req.DocID) + return &HumanRevisionResult{State: *st, Overlay: overlay}, nil + } + + prevBlocks := parent.BlockMap + snap := s.putSnapshotLocked(req.DocID, canon, sha, &parentID, SourceHuman, &req.ActorUserID, "", req.ActorSnapshot, "", req.ClientBlockMap) + if len(req.ClientBlockMap) == 0 { + snap.BlockMap = BuildBlockMap(canon, prevBlocks) + } else { + snap.BlockMap = BuildBlockMap(canon, req.ClientBlockMap) + } + diff := ComputeDiff(s.blobs[parent.ContentSHA256].ContentMarkdown, canon, prevBlocks, snap.BlockMap) + ver.HeadSnapshotID = snap.ID + ver.HumanRevisionCount++ + root.HeadSnapshotID = &snap.ID + root.HeadRevisionNo++ + root.ContentSHA256 = sha + root.RowRevision++ + eventNo := s.nextEventNoLocked(req.DocID) + ev := s.putEventLocked(req.DocID, ver.ID, eventNo, &parentID, snap.ID, ChangeHumanRevision, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, diff.Hunks, req.EditSessionID, req.RequestID, req.IdempotencyKey) + st := s.mustStateLocked(req.DocID) + overlay := DiffResult{FromSnapshotID: ver.BaselineSnapshotID, ToSnapshotID: snap.ID, Summary: diff.Summary, Hunks: diff.Hunks} + baseMD := s.blobs[s.snapshots[ver.BaselineSnapshotID].ContentSHA256].ContentMarkdown + overlay = ComputeDiff(baseMD, canon, s.snapshots[ver.BaselineSnapshotID].BlockMap, snap.BlockMap) + overlay.FromSnapshotID = ver.BaselineSnapshotID + overlay.ToSnapshotID = snap.ID + return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil +} + +func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdown string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) { + _ = ctx + canon := CanonicalizeMarkdown(markdown) + sha := SHA256Hex(canon) + s.mu.Lock() + defer s.mu.Unlock() + if root, ok := s.roots[docID]; ok && root.LedgerVersionID != nil { + return s.mustStateLocked(docID), nil + } + snap := s.putSnapshotLocked(docID, canon, sha, nil, SourceTemplate, nil, "template", actor, "", nil) + ver := s.openVersionLocked(docID, OriginTemplate, snap.ID, snap.ID, "") + root := &DocRootPointers{ + DocID: docID, Kind: "controlled", ProjectID: projectID, WorkDir: workDir, + LedgerVersionID: &ver.ID, HeadSnapshotID: &snap.ID, HeadRevisionNo: 0, + ContentSHA256: sha, LifecycleState: "drafting", RevisionSchemaVer: SchemaVersion, + } + s.roots[docID] = root + _ = s.putEventLocked(docID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorTemplate, nil, "template", actor, nil, "", "", fmt.Sprintf("template:%s:%s", docID, sha)) + return s.mustStateLocked(docID), nil +} + +func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRequest) (*AgentCandidate, *ChangeReport, error) { + _ = ctx + if !s.AgentCommit { + return nil, nil, fmt.Errorf("docrevision: agent commit disabled") + } + canon := CanonicalizeMarkdown(req.OutputMarkdown) + if len(canon) > MaxMarkdownBytes { + return nil, nil, ErrTooLarge + } + outSHA := SHA256Hex(canon) + + s.mu.Lock() + defer s.mu.Unlock() + + root := s.roots[req.DocID] + if root == nil || root.LedgerVersionID == nil || root.HeadSnapshotID == nil { + return nil, nil, ErrNoOpenVersion + } + if *root.HeadSnapshotID != req.InputSnapshotID { + // still create stale candidate + } + inSnap := s.snapshots[req.InputSnapshotID] + if inSnap == nil { + return nil, nil, ErrNotFound + } + inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown + outSnap := s.putSnapshotLocked(req.DocID, canon, outSHA, &req.InputSnapshotID, req.ActorKind, req.OnBehalfOf, req.RunID, req.ActorSnapshot, req.RunID, nil) + diff := ComputeDiff(inMD, canon, inSnap.BlockMap, outSnap.BlockMap) + impacts := s.impactLocked(req.DocID) + ruleRisk := RuleRiskFloor(diff.Hunks, impacts) + rep, err := ValidateChangeReportJSON(req.ReportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks) + status := CandidateReady + if err != nil { + status = CandidateReady + rep.AnalysisStatus = AnalysisUnassessed + } + if NeedsConfirmation(rep.RiskLevel) { + status = CandidateNeedsConfirmation + } + if *root.HeadSnapshotID != req.InputSnapshotID { + status = CandidateStale + } + if inSnap.ContentSHA256 == outSHA { + // noop path handled at commit + } + s.nextRepID++ + rep.ID = s.nextRepID + rep.DocID = req.DocID + rep.FromSnapshotID = req.InputSnapshotID + rep.ToSnapshotID = outSnap.ID + rep.CreatedAt = time.Now().UTC() + s.reports[rep.ID] = rep + + s.nextCandID++ + c := &AgentCandidate{ + ID: s.nextCandID, DocID: req.DocID, InputSnapshotID: req.InputSnapshotID, + OutputSnapshotID: &outSnap.ID, RunID: req.RunID, ProposalID: req.ProposalID, + Status: status, AuthorizedScope: req.AuthorizedScope, ChangeReportID: &rep.ID, + ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, CreatedAt: time.Now().UTC(), + } + s.candidates[c.ID] = c + if s.Notify && s.OnOutbox != nil && status == CandidateNeedsConfirmation { + _ = s.OnOutbox(ctx, "document.agent_candidate_ready", map[string]any{ + "doc_id": req.DocID, "candidate_id": c.ID, "risk": rep.RiskLevel, + }) + } + if err != nil && status != CandidateStale { + return c, rep, err + } + return c, rep, nil +} + +func (s *Service) CommitAgentVersion(ctx context.Context, candidateID int64, dec CommitAgentDecision) (*RevisionState, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.candidates[candidateID] + if !ok { + return nil, ErrNotFound + } + if c.Status == CandidateCommitted { + return s.mustStateLocked(c.DocID), nil + } + if c.Status == CandidateStale { + return nil, ErrCandidateStale + } + if c.Status == CandidateRejected { + return nil, fmt.Errorf("docrevision: candidate rejected") + } + root := s.roots[c.DocID] + if root == nil || root.HeadSnapshotID == nil { + return nil, ErrNoOpenVersion + } + if *root.HeadSnapshotID != c.InputSnapshotID { + c.Status = CandidateStale + return nil, ErrCandidateStale + } + if root.LifecycleState != "" && root.LifecycleState != "drafting" { + return nil, ErrLifecycleLocked + } + rep := s.reports[*c.ChangeReportID] + outSnap := s.snapshots[*c.OutputSnapshotID] + inSnap := s.snapshots[c.InputSnapshotID] + if dec.ExpectedInputSHA != "" && dec.ExpectedInputSHA != inSnap.ContentSHA256 { + return nil, ErrCandidateStale + } + if dec.ExpectedOutputSHA != "" && dec.ExpectedOutputSHA != outSnap.ContentSHA256 { + return nil, ErrCandidateStale + } + if dec.Decision == "reject" { + now := time.Now().UTC() + c.Status = CandidateRejected + c.DecidedAt = &now + return s.mustStateLocked(c.DocID), nil + } + if NeedsConfirmation(rep.RiskLevel) && !dec.Confirmed { + return nil, ErrNeedsConfirmation + } + if inSnap.ContentSHA256 == outSnap.ContentSHA256 { + // agent noop: event only + ver := s.versions[*root.LedgerVersionID] + eventNo := s.nextEventNoLocked(c.DocID) + _ = s.putEventLocked(c.DocID, ver.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentNoop, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, nil, "", "", fmt.Sprintf("agent_noop:%s:%s", c.RunID, outSnap.ContentSHA256)) + now := time.Now().UTC() + c.Status = CandidateCommitted + c.DecidedAt = &now + return s.mustStateLocked(c.DocID), nil + } + + oldVer := s.versions[*root.LedgerVersionID] + now := time.Now().UTC() + oldVer.Status = VersionSealed + oldVer.SealedAt = &now + oldVer.HeadSnapshotID = c.InputSnapshotID // seal at input (= previous head) + + origin := OriginAgent + if c.ActorKind == ActorAgentSquad { + origin = OriginAgentSquad + } + newVer := s.openVersionLocked(c.DocID, origin, outSnap.ID, outSnap.ID, c.RunID) + newVer.ChangeReportID = c.ChangeReportID + root.LedgerVersionID = &newVer.ID + root.HeadSnapshotID = &outSnap.ID + root.HeadRevisionNo = 0 + root.ContentSHA256 = outSnap.ContentSHA256 + root.RowRevision++ + + inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown + outMD := s.blobs[outSnap.ContentSHA256].ContentMarkdown + diff := ComputeDiff(inMD, outMD, inSnap.BlockMap, outSnap.BlockMap) + eventNo := s.nextEventNoLocked(c.DocID) + _ = s.putEventLocked(c.DocID, newVer.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentCommit, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, diff.Hunks, "", "", fmt.Sprintf("agent_commit:%s:%s", c.RunID, outSnap.ContentSHA256)) + c.Status = CandidateCommitted + c.DecidedAt = &now + + if s.Notify && s.OnOutbox != nil { + _ = s.OnOutbox(ctx, "document.agent_version_committed", map[string]any{ + "doc_id": c.DocID, "version_no": newVer.VersionNo, "risk": rep.RiskLevel, "candidate_id": c.ID, + }) + for _, imp := range rep.DocumentImpacts { + _ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{ + "doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo, + }) + } + if rep.AnalysisStatus == AnalysisUnassessed || rep.AnalysisStatus == AnalysisFallback { + _ = s.OnOutbox(ctx, "document.change_report_degraded", map[string]any{ + "doc_id": c.DocID, "report_id": rep.ID, + }) + } + } + return s.mustStateLocked(c.DocID), nil +} + +func (d CommitAgentDecision) ActorUserIDPtr() *int64 { + if d.ActorUserID == 0 { + return nil + } + id := d.ActorUserID + return &id +} + +func (s *Service) ListVersions(ctx context.Context, docID string) ([]Version, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + var out []Version + for _, v := range s.versions { + if v.DocID == docID { + out = append(out, *v) + } + } + // sort by version_no + for i := 0; i < len(out); i++ { + for j := i + 1; j < len(out); j++ { + if out[j].VersionNo < out[i].VersionNo { + out[i], out[j] = out[j], out[i] + } + } + } + return out, nil +} + +func (s *Service) GetVersionContent(ctx context.Context, docID string, versionNo int, view string) (string, string, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + var ver *Version + for _, v := range s.versions { + if v.DocID == docID && v.VersionNo == versionNo { + ver = v + break + } + } + if ver == nil { + return "", "", ErrNotFound + } + sid := ver.HeadSnapshotID + if view == "baseline" { + sid = ver.BaselineSnapshotID + } + snap := s.snapshots[sid] + return s.blobs[snap.ContentSHA256].ContentMarkdown, snap.ContentSHA256, nil +} + +func (s *Service) DiffVersions(ctx context.Context, docID string, fromNo, toNo int, fromView, toView string) (*DiffResult, error) { + fromMD, _, err := s.GetVersionContent(ctx, docID, fromNo, fromView) + if err != nil { + return nil, err + } + toMD, _, err := s.GetVersionContent(ctx, docID, toNo, toView) + if err != nil { + return nil, err + } + fromBlocks := BuildBlockMap(fromMD, nil) + toBlocks := BuildBlockMap(toMD, fromBlocks) + d := ComputeDiff(fromMD, toMD, fromBlocks, toBlocks) + return &d, nil +} + +func (s *Service) ManualOverlay(ctx context.Context, docID string) (*DiffResult, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + d := s.manualOverlayLocked(docID) + return &d, nil +} + +func (s *Service) GetCandidate(ctx context.Context, docID string, candidateID int64) (*AgentCandidate, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.candidates[candidateID] + if !ok || c.DocID != docID { + return nil, ErrNotFound + } + cp := *c + return &cp, nil +} + +func (s *Service) GetChangeReport(ctx context.Context, docID string, reportID int64) (*ChangeReport, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.reports[reportID] + if !ok || r.DocID != docID { + return nil, ErrNotFound + } + cp := *r + return &cp, nil +} + +// ImportFreeformLegacy imports legacy freeform checkpoint as sealed migration versions + open head. +func (s *Service) ImportFreeformLegacy(ctx context.Context, docID string, ownerID int64, projectID int64, checkpoints []struct { + VersionNo int + Markdown string + SHA string +}, currentMarkdown string) (*RevisionState, error) { + _ = ctx + s.mu.Lock() + defer s.mu.Unlock() + if root, ok := s.roots[docID]; ok && root.LedgerVersionID != nil { + return s.mustStateLocked(docID), nil + } + actor := ActorSnapshot{DisplayName: "migration", Role: "system"} + var lastSnap *Snapshot + startNo := 1 + for _, cp := range checkpoints { + canon := CanonicalizeMarkdown(cp.Markdown) + sha := cp.SHA + if sha == "" { + sha = SHA256Hex(canon) + } + var parent *int64 + if lastSnap != nil { + id := lastSnap.ID + parent = &id + } + snap := s.putSnapshotLocked(docID, canon, sha, parent, SourceMigration, &ownerID, "legacy", actor, "", nil) + ver := s.openVersionLocked(docID, OriginMigration, snap.ID, snap.ID, "") + // force version number and seal + ver.VersionNo = cp.VersionNo + now := time.Now().UTC() + ver.Status = VersionSealed + ver.SealedAt = &now + lastSnap = snap + if cp.VersionNo >= startNo { + startNo = cp.VersionNo + 1 + } + } + canon := CanonicalizeMarkdown(currentMarkdown) + sha := SHA256Hex(canon) + var parent *int64 + if lastSnap != nil { + id := lastSnap.ID + parent = &id + } + snap := s.putSnapshotLocked(docID, canon, sha, parent, SourceMigration, &ownerID, "legacy_head", actor, "", nil) + ver := &Version{ + ID: 0, DocID: docID, VersionNo: startNo, Status: VersionOpen, OriginKind: OriginMigration, + BaselineSnapshotID: snap.ID, HeadSnapshotID: snap.ID, OpenedAt: time.Now().UTC(), + } + s.nextVerID++ + ver.ID = s.nextVerID + s.versions[ver.ID] = ver + root := &DocRootPointers{ + DocID: docID, Kind: "freeform", ProjectID: projectID, + LedgerVersionID: &ver.ID, HeadSnapshotID: &snap.ID, HeadRevisionNo: 0, + ContentSHA256: sha, LifecycleState: "drafting", RevisionSchemaVer: SchemaVersion, + LegacyVersionID: startNo - 1, + } + s.roots[docID] = root + return s.mustStateLocked(docID), nil +} + +// ProjectOutputMarkdown writes DB head to workDir/output.md (compatibility projection). +func (s *Service) ProjectOutputMarkdown(docID, repoRoot string) error { + s.mu.Lock() + root := s.roots[docID] + if root == nil || root.HeadSnapshotID == nil || root.WorkDir == "" { + s.mu.Unlock() + return nil + } + snap := s.snapshots[*root.HeadSnapshotID] + md := s.blobs[snap.ContentSHA256].ContentMarkdown + workDir := root.WorkDir + s.mu.Unlock() + path := workDir + if !filepath.IsAbs(path) { + path = filepath.Join(repoRoot, workDir) + } + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(path, "output.md"), []byte(md), 0o644) +} + +// --- internal helpers --- + +func (s *Service) putSnapshotLocked(docID, canon, sha string, parent *int64, source string, userID *int64, actorRef string, actor ActorSnapshot, runID string, clientBlocks []BlockNode) *Snapshot { + if _, ok := s.blobs[sha]; !ok { + s.blobs[sha] = &ContentBlob{ContentSHA256: sha, ContentMarkdown: canon, ByteSize: int64(len(canon)), CreatedAt: time.Now().UTC()} + } + var prev []BlockNode + if parent != nil { + if p := s.snapshots[*parent]; p != nil { + prev = p.BlockMap + } + } + blocks := clientBlocks + if len(blocks) == 0 { + blocks = BuildBlockMap(canon, prev) + } else { + blocks = BuildBlockMap(canon, clientBlocks) + } + s.nextSnapID++ + snap := &Snapshot{ + ID: s.nextSnapID, DocID: docID, ContentSHA256: sha, ParentSnapshotID: parent, + BlockMap: blocks, SourceKind: source, ActorUserID: userID, ActorRef: actorRef, + ActorSnapshot: actor, SourceRunID: runID, CreatedAt: time.Now().UTC(), + } + s.snapshots[snap.ID] = snap + return snap +} + +func (s *Service) openVersionLocked(docID, origin string, baselineID, headID int64, runID string) *Version { + maxNo := 0 + for _, v := range s.versions { + if v.DocID == docID && v.VersionNo > maxNo { + maxNo = v.VersionNo + } + if v.DocID == docID && v.Status == VersionOpen { + now := time.Now().UTC() + v.Status = VersionSealed + v.SealedAt = &now + } + } + s.nextVerID++ + ver := &Version{ + ID: s.nextVerID, DocID: docID, VersionNo: maxNo + 1, Status: VersionOpen, + OriginKind: origin, BaselineSnapshotID: baselineID, HeadSnapshotID: headID, + SourceRunID: runID, OpenedAt: time.Now().UTC(), + } + s.versions[ver.ID] = ver + return ver +} + +func (s *Service) putEventLocked(docID string, versionID int64, eventNo int, from *int64, to int64, kind, actorKind string, userID *int64, actorRef string, actor ActorSnapshot, hunks []DiffHunk, editSession, requestID, idemp string) *ChangeEvent { + s.nextEvtID++ + ev := &ChangeEvent{ + ID: s.nextEvtID, DocID: docID, VersionID: versionID, EventNo: eventNo, + FromSnapshotID: from, ToSnapshotID: to, ChangeKind: kind, ActorKind: actorKind, + ActorUserID: userID, ActorRef: actorRef, ActorSnapshot: actor, ChangeSet: hunks, + EditSessionID: editSession, RequestID: requestID, IdempotencyKey: idemp, CreatedAt: time.Now().UTC(), + } + s.events[ev.ID] = ev + if idemp != "" { + s.idemp[idemp] = ev.ID + } + return ev +} + +func (s *Service) nextEventNoLocked(docID string) int { + max := 0 + for _, e := range s.events { + if e.DocID == docID && e.EventNo > max { + max = e.EventNo + } + } + return max + 1 +} + +func (s *Service) revisionStateLocked(docID string) (*RevisionState, error) { + root, ok := s.roots[docID] + if !ok || root.LedgerVersionID == nil { + return nil, ErrNotFound + } + return s.mustStateLocked(docID), nil +} + +func (s *Service) mustStateLocked(docID string) *RevisionState { + root := s.roots[docID] + ver := s.versions[*root.LedgerVersionID] + head := s.snapshots[*root.HeadSnapshotID] + return &RevisionState{ + DocID: docID, CurrentVersionNo: ver.VersionNo, VersionStatus: ver.Status, + BaselineSnapshotID: ver.BaselineSnapshotID, HeadSnapshotID: ver.HeadSnapshotID, + HeadRevisionNo: root.HeadRevisionNo, HeadSHA256: head.ContentSHA256, + ManualChangeCount: ver.HumanRevisionCount, + Capabilities: []string{"read", "edit", "diff", "overlay", "history"}, + ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo), + } +} + +func (s *Service) manualOverlayLocked(docID string) DiffResult { + root := s.roots[docID] + if root == nil || root.LedgerVersionID == nil { + return DiffResult{} + } + ver := s.versions[*root.LedgerVersionID] + base := s.snapshots[ver.BaselineSnapshotID] + head := s.snapshots[ver.HeadSnapshotID] + d := ComputeDiff(s.blobs[base.ContentSHA256].ContentMarkdown, s.blobs[head.ContentSHA256].ContentMarkdown, base.BlockMap, head.BlockMap) + d.FromSnapshotID = base.ID + d.ToSnapshotID = head.ID + return d +} + +func (s *Service) impactLocked(docID string) []DocImpact { + // Deterministic placeholder: no lineage graph in memory mode. + _ = docID + return nil +} + +// SetLifecycle updates lifecycle gate used by write paths. +func (s *Service) SetLifecycle(docID, state string) { + s.mu.Lock() + defer s.mu.Unlock() + if r := s.roots[docID]; r != nil { + r.LifecycleState = state + } +} diff --git a/internal/docrevision/testdata/change_report_v1_valid.json b/internal/docrevision/testdata/change_report_v1_valid.json new file mode 100644 index 00000000..7a99056b --- /dev/null +++ b/internal/docrevision/testdata/change_report_v1_valid.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "summary": ["补充系统边界条件", "调整三项验收阈值"], + "changes": [ + { + "section_locator": "3.2 性能要求 / REQ-PERF-007", + "change_type": "modified", + "rationale": "依据新增用户材料与法规检索证据", + "evidence_refs": ["material:123#sha256", "kb:GB9706#8.4"] + } + ], + "risk": { + "level": "high", + "items": [ + { + "category": "verification", + "description": "阈值变化会使既有用例失效", + "mitigation": "重跑 TC-007" + } + ] + }, + "project_impact": { + "schedule": "可能增加验证工作量", + "cost": "待评估", + "scope": "影响性能验证范围", + "compliance": "需复核风险管理文件" + }, + "document_impacts": [ + { + "doc_id": "doc_proj1_test_manager", + "impact_type": "stale_candidate", + "reason": "引用的 REQ-PERF-007 已改变", + "recommended_action": "由测试方案编制人复核并重新基线" + } + ], + "open_questions": ["阈值是否已获得硬件负责人确认"] +} diff --git a/internal/docrevision/testdata/golden_diff_cases.json b/internal/docrevision/testdata/golden_diff_cases.json new file mode 100644 index 00000000..2ed6f928 --- /dev/null +++ b/internal/docrevision/testdata/golden_diff_cases.json @@ -0,0 +1,41 @@ +{ + "name": "chinese_paragraph_table_list_mermaid_move_noop", + "cases": [ + { + "id": "paragraph", + "before": "# 概述\n\n原系统用于监护。\n", + "after": "# 概述\n\n原系统用于重症监护与转运。\n", + "expect_kinds": ["modify"] + }, + { + "id": "table", + "before": "| 项目 | 值 |\n| --- | --- |\n| REQ-PERF-007 | ±1℃ |\n", + "after": "| 项目 | 值 |\n| --- | --- |\n| REQ-PERF-007 | ±0.5℃ |\n", + "expect_kinds": ["modify"] + }, + { + "id": "list", + "before": "## 清单\n\n- 项 A\n- 项 B\n", + "after": "## 清单\n\n- 项 A\n- 项 C\n- 项 B\n", + "expect_kinds": ["insert"] + }, + { + "id": "mermaid", + "before": "```mermaid\nflowchart LR\n A-->B\n```\n", + "after": "```mermaid\nflowchart LR\n A-->C\n```\n", + "expect_kinds": ["modify"] + }, + { + "id": "move", + "before": "## 一\n\n移动段落内容不变\n\n## 二\n\n其他\n", + "after": "## 一\n\n其他\n\n## 二\n\n移动段落内容不变\n", + "expect_kinds": ["move"] + }, + { + "id": "noop", + "before": "# 相同\n\n内容完全一致。\n", + "after": "# 相同\n\n内容完全一致。\n", + "expect_kinds": [] + } + ] +} diff --git a/internal/docrevision/types.go b/internal/docrevision/types.go new file mode 100644 index 00000000..deb15b56 --- /dev/null +++ b/internal/docrevision/types.go @@ -0,0 +1,323 @@ +// Package docrevision implements controlled/freeform document Version + Revision ledger. +package docrevision + +import ( + "errors" + "time" +) + +const ( + SchemaVersion = 1 + + VersionOpen = "open" + VersionSealed = "sealed" + + OriginTemplate = "template" + OriginAgent = "agent" + OriginAgentSquad = "agent_squad" + OriginMigration = "migration" + + SourceTemplate = "template" + SourceHuman = "human" + SourceAgent = "agent" + SourceAgentSquad = "agent_squad" + SourceMigration = "migration" + SourceSystem = "system" + + ChangeHumanRevision = "human_revision" + ChangeAgentCommit = "agent_commit" + ChangeAgentNoop = "agent_noop" + ChangeImport = "import" + ChangeRestoreAsNew = "restore_as_new" + + ActorHuman = "human" + ActorAgent = "agent" + ActorAgentSquad = "agent_squad" + ActorTemplate = "template" + ActorMigration = "migration" + ActorSystem = "system" + + CandidateReady = "ready" + CandidateNeedsConfirmation = "needs_confirmation" + CandidateCommitted = "committed" + CandidateRejected = "rejected" + CandidateStale = "stale" + CandidateQualityFailed = "quality_failed" + + RiskLow = "low" + RiskMedium = "medium" + RiskHigh = "high" + RiskCritical = "critical" + + AnalysisComplete = "complete" + AnalysisFallback = "fallback" + AnalysisUnassessed = "unassessed" + AnalysisCorrected = "corrected" + + ErrCodeHeadConflict = "DOCUMENT_HEAD_CONFLICT" + ErrCodeEditForbidden = "DOCUMENT_EDIT_FORBIDDEN" + ErrCodeBusy = "DOCUMENT_BUSY" + ErrCodeCandidateStale = "AGENT_CANDIDATE_STALE" + ErrCodeReportInvalid = "CHANGE_REPORT_INVALID" + ErrCodeLifecycleLocked = "DOCUMENT_LIFECYCLE_LOCKED" + ErrCodeTooLarge = "DOCUMENT_TOO_LARGE" + ErrCodeNeedsConfirmation = "AGENT_NEEDS_CONFIRMATION" + ErrCodeNotFound = "DOCUMENT_REVISION_NOT_FOUND" +) + +var ( + ErrNotFound = errors.New("docrevision: not found") + ErrHeadConflict = errors.New("docrevision: head conflict") + ErrLifecycleLocked = errors.New("docrevision: lifecycle locked") + ErrCandidateStale = errors.New("docrevision: candidate stale") + ErrReportInvalid = errors.New("docrevision: change report invalid") + ErrNeedsConfirmation = errors.New("docrevision: high/critical needs confirmation") + ErrIdempotencyConflict = errors.New("docrevision: idempotency conflict") + ErrForbidden = errors.New("docrevision: forbidden") + ErrTooLarge = errors.New("docrevision: document too large") + ErrNoOpenVersion = errors.New("docrevision: no open version") +) + +const MaxMarkdownBytes = 8 << 20 // 8 MiB + +// ActorSnapshot is an immutable attribution payload frozen at write time. +type ActorSnapshot struct { + DisplayName string `json:"display_name,omitempty"` + Role string `json:"role,omitempty"` + Duty string `json:"duty,omitempty"` + AgentKey string `json:"agent_key,omitempty"` + SquadName string `json:"squad_name,omitempty"` + Model string `json:"model,omitempty"` + ConfigVer string `json:"config_version,omitempty"` + Extra map[string]string `json:"extra,omitempty"` +} + +type ContentBlob struct { + ContentSHA256 string `json:"content_sha256"` + ContentMarkdown string `json:"content_markdown"` + ByteSize int64 `json:"byte_size"` + CreatedAt time.Time `json:"created_at"` +} + +type BlockNode struct { + ID string `json:"id"` + Type string `json:"type"` + StartOffset int `json:"start_offset"` + EndOffset int `json:"end_offset"` + HeadingPath string `json:"heading_path,omitempty"` + BusinessKey string `json:"business_key,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Order int `json:"order"` +} + +type Snapshot struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + ContentSHA256 string `json:"content_sha256"` + ParentSnapshotID *int64 `json:"parent_snapshot_id,omitempty"` + BlockMap []BlockNode `json:"block_map"` + SourceKind string `json:"source_kind"` + ActorUserID *int64 `json:"actor_user_id,omitempty"` + ActorRef string `json:"actor_ref,omitempty"` + ActorSnapshot ActorSnapshot `json:"actor_snapshot"` + SourceRunID string `json:"source_run_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Version struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + VersionNo int `json:"version_no"` + Status string `json:"status"` + OriginKind string `json:"origin_kind"` + BaselineSnapshotID int64 `json:"baseline_snapshot_id"` + HeadSnapshotID int64 `json:"head_snapshot_id"` + HumanRevisionCount int `json:"human_revision_count"` + SourceRunID string `json:"source_run_id,omitempty"` + ChangeReportID *int64 `json:"change_report_id,omitempty"` + OpenedAt time.Time `json:"opened_at"` + SealedAt *time.Time `json:"sealed_at,omitempty"` +} + +type ChangeEvent struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + VersionID int64 `json:"version_id"` + EventNo int `json:"event_no"` + FromSnapshotID *int64 `json:"from_snapshot_id,omitempty"` + ToSnapshotID int64 `json:"to_snapshot_id"` + ChangeKind string `json:"change_kind"` + ActorKind string `json:"actor_kind"` + ActorUserID *int64 `json:"actor_user_id,omitempty"` + ActorRef string `json:"actor_ref,omitempty"` + ActorSnapshot ActorSnapshot `json:"actor_snapshot"` + ChangeSet []DiffHunk `json:"change_set,omitempty"` + EditSessionID string `json:"edit_session_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + IdempotencyKey string `json:"idempotency_key"` + CreatedAt time.Time `json:"created_at"` +} + +type AgentCandidate struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + InputSnapshotID int64 `json:"input_snapshot_id"` + OutputSnapshotID *int64 `json:"output_snapshot_id,omitempty"` + RunID string `json:"run_id"` + ProposalID string `json:"proposal_id,omitempty"` + Status string `json:"status"` + AuthorizedScope string `json:"authorized_scope,omitempty"` + QualityResult string `json:"quality_result,omitempty"` + ChangeReportID *int64 `json:"change_report_id,omitempty"` + ActorKind string `json:"actor_kind"` + ActorSnapshot ActorSnapshot `json:"actor_snapshot"` + CreatedAt time.Time `json:"created_at"` + DecidedAt *time.Time `json:"decided_at,omitempty"` +} + +type ChangeReport struct { + ID int64 `json:"id"` + DocID string `json:"doc_id"` + FromSnapshotID int64 `json:"from_snapshot_id"` + ToSnapshotID int64 `json:"to_snapshot_id"` + SchemaVersion int `json:"schema_version"` + Summary []string `json:"summary"` + RiskLevel string `json:"risk_level"` + RiskItems []RiskItem `json:"risk_items"` + ProjectImpacts map[string]any `json:"project_impacts"` + DocumentImpacts []DocImpact `json:"document_impacts"` + OpenQuestions []string `json:"open_questions"` + AnalysisStatus string `json:"analysis_status"` + ModelSnapshot string `json:"model_snapshot,omitempty"` + PromptVersion string `json:"prompt_version,omitempty"` + DeterministicFacts map[string]any `json:"deterministic_facts"` + CorrectsReportID *int64 `json:"corrects_report_id,omitempty"` + RawJSON string `json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +type RiskItem struct { + Category string `json:"category"` + Description string `json:"description"` + Mitigation string `json:"mitigation,omitempty"` +} + +type DocImpact struct { + DocID string `json:"doc_id"` + ImpactType string `json:"impact_type"` + Reason string `json:"reason"` + RecommendedAction string `json:"recommended_action,omitempty"` +} + +type DiffHunk struct { + ID string `json:"id"` + Kind string `json:"kind"` // insert|delete|modify|move + Section string `json:"section,omitempty"` + BeforeText string `json:"before_text,omitempty"` + AfterText string `json:"after_text,omitempty"` + BlockIDBefore string `json:"block_id_before,omitempty"` + BlockIDAfter string `json:"block_id_after,omitempty"` + ActorKind string `json:"actor_kind,omitempty"` + ActorLabel string `json:"actor_label,omitempty"` + LineStart int `json:"line_start,omitempty"` + LineEnd int `json:"line_end,omitempty"` +} + +type DiffResult struct { + FromSnapshotID int64 `json:"from_snapshot_id"` + ToSnapshotID int64 `json:"to_snapshot_id"` + Summary DiffSummary `json:"summary"` + Hunks []DiffHunk `json:"hunks"` +} + +type DiffSummary struct { + Inserts int `json:"inserts"` + Deletes int `json:"deletes"` + Modifies int `json:"modifies"` + Moves int `json:"moves"` +} + +type DocRootPointers struct { + DocID string + Kind string // controlled|freeform + LedgerVersionID *int64 + HeadSnapshotID *int64 + HeadRevisionNo int + ReviewSnapshotID *int64 + RevisionSchemaVer int + RowRevision int64 + LifecycleState string + ContentSHA256 string + LegacyVersionID int // freeform only + WorkDir string + ProjectID int64 + AuthorID *int64 +} + +type RevisionState struct { + DocID string `json:"doc_id"` + CurrentVersionNo int `json:"current_version_no"` + VersionStatus string `json:"version_status"` + BaselineSnapshotID int64 `json:"baseline_snapshot_id"` + HeadSnapshotID int64 `json:"head_snapshot_id"` + HeadRevisionNo int `json:"head_revision_no"` + HeadSHA256 string `json:"head_sha256"` + ManualChangeCount int `json:"manual_change_count"` + Capabilities []string `json:"capabilities"` + ETag string `json:"etag"` +} + +type HumanRevisionRequest struct { + DocID string + Markdown string + ExpectedHeadSnapshot *int64 + ExpectedRevisionNo *int + IdempotencyKey string + EditSessionID string + RequestID string + ActorUserID int64 + ActorSnapshot ActorSnapshot + LifecycleState string + WorkDir string + ProjectID int64 + AllowCreateBaseline bool + ClientBlockMap []BlockNode +} + +type HumanRevisionResult struct { + State RevisionState `json:"state"` + Event ChangeEvent `json:"event"` + Overlay DiffResult `json:"manual_overlay"` + Conflict *ConflictInfo `json:"conflict,omitempty"` +} + +type ConflictInfo struct { + Code string `json:"code"` + BaseSnapshotID int64 `json:"base_snapshot_id"` + CurrentSnapshotID int64 `json:"current_snapshot_id"` + CurrentRevisionNo int `json:"current_revision_no"` + CurrentSHA256 string `json:"current_sha256"` +} + +type AgentCandidateRequest struct { + DocID string + RunID string + ProposalID string + InputSnapshotID int64 + OutputMarkdown string + ReportJSON string + ActorKind string + ActorSnapshot ActorSnapshot + AuthorizedScope string + OnBehalfOf *int64 +} + +type CommitAgentDecision struct { + Decision string // commit|reject + Confirmed bool // required when risk high/critical + ExpectedInputSHA string + ExpectedOutputSHA string + Reason string + ActorUserID int64 + ActorSnapshot ActorSnapshot +} diff --git a/internal/freedoc/service.go b/internal/freedoc/service.go index f3a06b50..5afa94b6 100644 --- a/internal/freedoc/service.go +++ b/internal/freedoc/service.go @@ -12,12 +12,14 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docrevision" "chat-agent-system/internal/replyexport" ) type Service struct { pool *pgxpool.Pool ctxMgr *ctxmgr.ContextManager + docRev *docrevision.Service mu sync.RWMutex memDocs map[string]*FreeformDocument @@ -35,6 +37,13 @@ func NewService(pool *pgxpool.Pool, ctxMgr *ctxmgr.ContextManager) *Service { } } +// SetDocRevision attaches the shared revision ledger (Wave 6 freeform compatibility). +func (s *Service) SetDocRevision(rev *docrevision.Service) { + if s != nil { + s.docRev = rev + } +} + func computeSHA256(text string) string { sum := sha256.Sum256([]byte(text)) return hex.EncodeToString(sum[:]) @@ -84,7 +93,7 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF var existingDoc FreeformDocument row := s.pool.QueryRow(ctx, ` SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, current_version_id, current_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, is_deleted, deleted_at, created_at, updated_at FROM freeform_documents WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE @@ -119,7 +128,7 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF _, err = tx.Exec(ctx, ` INSERT INTO freeform_documents ( doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, current_version_id, current_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, is_deleted, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8) `, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now) @@ -233,7 +242,7 @@ func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*F var doc FreeformDocument row := s.pool.QueryRow(ctx, ` SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, current_version_id, current_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, is_deleted, deleted_at, created_at, updated_at FROM freeform_documents WHERE doc_id = $1 @@ -323,14 +332,14 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, // 我的私密:属于当前用户 if projectID > 0 { rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at FROM freeform_documents WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE ORDER BY updated_at DESC `, userID, projectID) } else { rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at FROM freeform_documents WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE ORDER BY updated_at DESC @@ -339,7 +348,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, } else if space == "project_shared" { // 项目共享参考:属于当前项目的所有公开参考 rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at FROM freeform_documents WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE ORDER BY updated_at DESC @@ -347,7 +356,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, } else { // 回收站:当前用户删除的 rows, err = s.pool.Query(ctx, ` - SELECT doc_id, project_id, owner_user_id, title, space, current_version_id, current_sha256, created_at, updated_at + SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at FROM freeform_documents WHERE owner_user_id = $1 AND is_deleted = TRUE ORDER BY deleted_at DESC @@ -429,7 +438,7 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, var doc FreeformDocument row := tx.QueryRow(ctx, ` SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, - title, governance_mode, space, current_version_id, current_sha256, + title, governance_mode, space, legacy_version_id, current_sha256, is_deleted, created_at, updated_at FROM freeform_documents WHERE doc_id = $1 FOR UPDATE @@ -496,7 +505,7 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, _, err = tx.Exec(ctx, ` UPDATE freeform_documents - SET title = $1, current_version_id = $2, current_sha256 = $3, updated_at = $4 + SET title = $1, legacy_version_id = $2, current_sha256 = $3, updated_at = $4 WHERE doc_id = $5 `, newTitle, nextVersion, newHash, now, doc.DocID) if err != nil { @@ -554,9 +563,11 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, } now := time.Now() + newTitle := doc.Title if req.Title != nil && *req.Title != "" { - doc.Title = *req.Title + newTitle = *req.Title } + doc.Title = newTitle curContent := "" versions := s.memVersions[docID] @@ -586,10 +597,49 @@ func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, }) } doc.UpdatedAt = now - return &FreeformDocDetail{ + detail := &FreeformDocDetail{ Document: *doc, Content: newContent, - }, nil + } + s.syncLedgerLocked(ctx, userID, detail) + return detail, nil +} + +// syncLedgerLocked imports legacy checkpoints then records human revisions on the shared ledger. +func (s *Service) syncLedgerLocked(ctx context.Context, userID int, detail *FreeformDocDetail) { + if s.docRev == nil || detail == nil { + return + } + doc := detail.Document + checkpoints := make([]struct { + VersionNo int + Markdown string + SHA string + }, 0, len(s.memVersions[doc.DocID])) + for _, v := range s.memVersions[doc.DocID] { + checkpoints = append(checkpoints, struct { + VersionNo int + Markdown string + SHA string + }{VersionNo: v.VersionNo, Markdown: v.ContentMarkdown, SHA: v.ContentSHA256}) + } + prior := checkpoints + if len(prior) > 0 { + prior = prior[:len(prior)-1] + } + st, err := s.docRev.ImportFreeformLegacy(ctx, doc.DocID, int64(userID), int64(doc.ProjectID), prior, detail.Content) + if err != nil || st == nil { + return + } + _, _ = s.docRev.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: doc.DocID, Markdown: detail.Content, + ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo, + IdempotencyKey: fmt.Sprintf("freeform:%s:%s", doc.DocID, detail.Document.CurrentSHA256), + ActorUserID: int64(userID), + ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "freeform-owner", Role: "owner"}, + LifecycleState: "drafting", + ProjectID: int64(doc.ProjectID), + }) } // ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作) diff --git a/internal/notification/document_events.go b/internal/notification/document_events.go new file mode 100644 index 00000000..fb994606 --- /dev/null +++ b/internal/notification/document_events.go @@ -0,0 +1,100 @@ +package notification + +import ( + "context" + "encoding/json" + "fmt" +) + +const ( + EventDocAgentCandidateReady = "document.agent_candidate_ready" + EventDocAgentVersionCommitted = "document.agent_version_committed" + EventDocHumanRevisionConflict = "document.human_revision_conflict" + EventDocDownstreamImpact = "document.downstream_impact_detected" + EventDocChangeReportDegraded = "document.change_report_degraded" + + TypeDocAgentCandidateReady = "document_agent_candidate_ready" + TypeDocAgentVersionCommitted = "document_agent_version_committed" + TypeDocDownstreamImpact = "document_downstream_impact" + TypeDocChangeReportDegraded = "document_change_report_degraded" +) + +// DocumentEventPayload is a minimal, permission-safe notification body (no markdown excerpts). +type DocumentEventPayload struct { + ProjectID int64 `json:"project_id"` + DocID string `json:"doc_id"` + DocTitle string `json:"doc_title,omitempty"` + VersionNo int `json:"version_no,omitempty"` + CandidateID int64 `json:"candidate_id,omitempty"` + ReportID int64 `json:"report_id,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + Summary string `json:"summary,omitempty"` + ImpactedDocID string `json:"impacted_doc_id,omitempty"` + ImpactedCount int `json:"impacted_count,omitempty"` + RestrictedN int `json:"restricted_count,omitempty"` +} + +// EmitDocumentEvent writes outbox + per-recipient notifications with dedupe. +func (s *Service) EmitDocumentEvent(ctx context.Context, eventType, notifType, title, body, dedupeKey string, recipientUserID int64, payload DocumentEventPayload) (bool, error) { + if !s.Available() { + return false, ErrNotAvailable + } + if recipientUserID <= 0 || payload.DocID == "" { + return false, nil + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer tx.Rollback(ctx) + + raw, err := json.Marshal(payload) + if err != nil { + return false, err + } + var eventID int64 + aggregateID := fmt.Sprintf("document:%s", payload.DocID) + err = tx.QueryRow(ctx, ` + INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload, project_id, published_at) + VALUES ('document', $1, $2, $3::jsonb, $4, now()) + RETURNING id + `, aggregateID, eventType, string(raw), payload.ProjectID).Scan(&eventID) + if err != nil { + return false, err + } + tag, err := tx.Exec(ctx, ` + INSERT INTO notifications (user_id, project_id, event_id, type, title, body, dedupe_key, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'unread') + ON CONFLICT (user_id, dedupe_key) DO NOTHING + `, recipientUserID, payload.ProjectID, eventID, notifType, title, body, dedupeKey) + if err != nil { + return false, err + } + if err := tx.Commit(ctx); err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// UpdateNotificationStatus marks a notification read/acted/dismissed for the owner. +func (s *Service) UpdateNotificationStatus(ctx context.Context, userID, notifID int64, status string) error { + if !s.Available() { + return ErrNotAvailable + } + switch status { + case "read", "acted", "dismissed", "unread": + default: + return fmt.Errorf("notification: invalid status %q", status) + } + tag, err := s.pool.Exec(ctx, ` + UPDATE notifications SET status = $1 + WHERE id = $2 AND user_id = $3 + `, status, notifID, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("notification: not found") + } + return nil +} diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go new file mode 100644 index 00000000..c98a4f47 --- /dev/null +++ b/internal/server/document_revision_handlers.go @@ -0,0 +1,337 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "chat-agent-system/internal/docrevision" +) + +func (s *Server) registerDocumentRevisionRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/documents/{doc_id}/revision-state", s.handleDocRevisionState) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/versions", s.handleDocRevisionListVersions) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/versions/{version_no}", s.handleDocRevisionGetVersion) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/diff", s.handleDocRevisionDiff) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/manual-overlay", s.handleDocRevisionManualOverlay) + mux.HandleFunc("PATCH /api/v1/documents/{doc_id}/content", s.handleDocRevisionPatchContent) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/agent-candidates/{candidate_id}", s.handleDocRevisionGetCandidate) + mux.HandleFunc("POST /api/v1/documents/{doc_id}/agent-candidates/{candidate_id}/decision", s.handleDocRevisionCandidateDecision) + mux.HandleFunc("GET /api/v1/documents/{doc_id}/change-reports/{report_id}", s.handleDocRevisionGetReport) + mux.HandleFunc("PATCH /api/v1/notifications/{id}", s.handlePatchNotification) +} + +func (s *Server) docRev() *docrevision.Service { + if s == nil || s.svc == nil { + return nil + } + return s.svc.DocRevision() +} + +func (s *Server) handleDocRevisionState(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + _ = user + docID := r.PathValue("doc_id") + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + st, err := rev.GetRevisionState(r.Context(), docID) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, st) +} + +func (s *Server) handleDocRevisionListVersions(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + items, err := rev.ListVersions(r.Context(), r.PathValue("doc_id")) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"versions": items}) +} + +func (s *Server) handleDocRevisionGetVersion(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + vno, _ := strconv.Atoi(r.PathValue("version_no")) + view := r.URL.Query().Get("view") + if view == "" { + view = "final" + } + md, sha, err := rev.GetVersionContent(r.Context(), r.PathValue("doc_id"), vno, view) + if err != nil { + writeDocRevisionError(w, err) + return + } + w.Header().Set("ETag", `"`+sha+`"`) + writeJSONResponse(w, http.StatusOK, map[string]any{ + "version_no": vno, "view": view, "content_sha256": sha, "content_markdown": md, + }) +} + +func (s *Server) handleDocRevisionDiff(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + fromNo, _ := strconv.Atoi(r.URL.Query().Get("from_version")) + toNo, _ := strconv.Atoi(r.URL.Query().Get("to_version")) + fromView := r.URL.Query().Get("from_view") + toView := r.URL.Query().Get("to_view") + if fromView == "" { + fromView = "final" + } + if toView == "" { + toView = "final" + } + diff, err := rev.DiffVersions(r.Context(), r.PathValue("doc_id"), fromNo, toNo, fromView, toView) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, diff) +} + +func (s *Server) handleDocRevisionManualOverlay(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + diff, err := rev.ManualOverlay(r.Context(), r.PathValue("doc_id")) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, diff) +} + +func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + docID := r.PathValue("doc_id") + var body struct { + Markdown string `json:"markdown"` + EditSessionID string `json:"edit_session_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) + return + } + idemp := r.Header.Get("Idempotency-Key") + if idemp == "" { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "Idempotency-Key required"}) + return + } + var expectedHead *int64 + var expectedRev *int + if match := strings.Trim(r.Header.Get("If-Match"), `"`); match != "" { + parts := strings.Split(match, ":") + if len(parts) == 2 { + if sid, err := strconv.ParseInt(parts[0], 10, 64); err == nil { + expectedHead = &sid + } + if rn, err := strconv.Atoi(parts[1]); err == nil { + expectedRev = &rn + } + } + } + lifecycle := "drafting" + workDir := "" + projectID := int64(0) + if s.svc != nil && s.svc.DocGov() != nil { + if doc, err := s.svc.DocGov().GetDocument(r.Context(), docID); err == nil && doc != nil { + lifecycle = string(doc.CurrentState) + workDir = doc.WorkDir + projectID = doc.ProjectID + // Ensure ledger baseline exists from current content when first edit arrives. + if st, stErr := rev.GetRevisionState(r.Context(), docID); stErr != nil { + md := body.Markdown + if content, cErr := s.svc.DocGov().ReadDocumentContent(r.Context(), docID, "."); cErr == nil && content != nil && content.Markdown != "" { + md = content.Markdown + } + _, _ = rev.OpenTemplateVersion(r.Context(), docID, md, docrevision.ActorSnapshot{ + DisplayName: user.Name, Role: "author", Duty: "author", + }, projectID, workDir) + _ = st + } + } + } + res, err := rev.CommitHumanRevision(r.Context(), docrevision.HumanRevisionRequest{ + DocID: docID, Markdown: body.Markdown, ExpectedHeadSnapshot: expectedHead, ExpectedRevisionNo: expectedRev, + IdempotencyKey: idemp, EditSessionID: body.EditSessionID, ActorUserID: user.ID, + ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"}, + LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: true, + }) + if errors.Is(err, docrevision.ErrHeadConflict) && res != nil && res.Conflict != nil { + writeJSONResponse(w, http.StatusConflict, map[string]any{ + "code": docrevision.ErrCodeHeadConflict, "conflict": res.Conflict, + }) + return + } + if err != nil { + writeDocRevisionError(w, err) + return + } + _ = rev.ProjectOutputMarkdown(docID, ".") + // Compatibility projection into docgov current hash. + if s.svc != nil && s.svc.DocGov() != nil { + _ = s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false) + } + writeJSONResponse(w, http.StatusOK, res) +} + +func (s *Server) handleDocRevisionGetCandidate(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + cid, _ := strconv.ParseInt(r.PathValue("candidate_id"), 10, 64) + c, err := rev.GetCandidate(r.Context(), r.PathValue("doc_id"), cid) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, c) +} + +func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + cid, _ := strconv.ParseInt(r.PathValue("candidate_id"), 10, 64) + var body struct { + Decision string `json:"decision"` + Confirmed bool `json:"confirmed"` + ExpectedInputSHA string `json:"expected_input_sha"` + ExpectedOutputSHA string `json:"expected_output_sha"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) + return + } + st, err := rev.CommitAgentVersion(r.Context(), cid, docrevision.CommitAgentDecision{ + Decision: body.Decision, Confirmed: body.Confirmed, + ExpectedInputSHA: body.ExpectedInputSHA, ExpectedOutputSHA: body.ExpectedOutputSHA, + Reason: body.Reason, ActorUserID: user.ID, + ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name}, + }) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, st) +} + +func (s *Server) handleDocRevisionGetReport(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + rev := s.docRev() + if rev == nil { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"}) + return + } + rid, _ := strconv.ParseInt(r.PathValue("report_id"), 10, 64) + rep, err := rev.GetChangeReport(r.Context(), r.PathValue("doc_id"), rid) + if err != nil { + writeDocRevisionError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, rep) +} + +func (s *Server) handlePatchNotification(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + if s.notifySvc == nil || !s.notifySvc.Available() { + writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "notifications unavailable"}) + return + } + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + var body struct { + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) + return + } + if err := s.notifySvc.UpdateNotificationStatus(r.Context(), user.ID, id, body.Status); err != nil { + writeJSONResponse(w, http.StatusNotFound, map[string]any{"error": err.Error()}) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"ok": true}) +} + +func writeDocRevisionError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, docrevision.ErrNotFound): + writeJSONResponse(w, http.StatusNotFound, map[string]any{"code": docrevision.ErrCodeNotFound, "error": err.Error()}) + case errors.Is(err, docrevision.ErrHeadConflict): + writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeHeadConflict, "error": err.Error()}) + case errors.Is(err, docrevision.ErrLifecycleLocked): + writeJSONResponse(w, http.StatusLocked, map[string]any{"code": docrevision.ErrCodeLifecycleLocked, "error": err.Error()}) + case errors.Is(err, docrevision.ErrCandidateStale): + writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeCandidateStale, "error": err.Error()}) + case errors.Is(err, docrevision.ErrNeedsConfirmation): + writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeNeedsConfirmation, "error": err.Error()}) + case errors.Is(err, docrevision.ErrReportInvalid): + writeJSONResponse(w, http.StatusUnprocessableEntity, map[string]any{"code": docrevision.ErrCodeReportInvalid, "error": err.Error()}) + case errors.Is(err, docrevision.ErrTooLarge): + writeJSONResponse(w, http.StatusRequestEntityTooLarge, map[string]any{"code": docrevision.ErrCodeTooLarge, "error": err.Error()}) + case errors.Is(err, docrevision.ErrForbidden): + writeJSONResponse(w, http.StatusForbidden, map[string]any{"code": docrevision.ErrCodeEditForbidden, "error": err.Error()}) + default: + writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8a29c905..2cbfe63a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -114,6 +114,65 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { s.registerWorkflowApprovalHook() s.startNotificationReminderLoop() s.initTaskRunExecutors() + if svc != nil && svc.DocRevision() != nil && s.notifySvc != nil { + rev := svc.DocRevision() + rev.OnOutbox = func(ctx context.Context, eventType string, payload map[string]any) error { + if !s.notifySvc.Available() { + return nil + } + docID, _ := payload["doc_id"].(string) + projectID, _ := payload["project_id"].(int64) + risk, _ := payload["risk"].(string) + var recipient int64 + if s.svc.DocGov() != nil { + if doc, err := s.svc.DocGov().GetDocument(ctx, docID); err == nil && doc != nil && doc.AuthorID > 0 { + recipient = doc.AuthorID + projectID = doc.ProjectID + } + } + if recipient == 0 { + return nil + } + p := notification.DocumentEventPayload{ + ProjectID: projectID, DocID: docID, RiskLevel: risk, + } + switch eventType { + case notification.EventDocAgentCandidateReady: + if v, ok := payload["candidate_id"].(int64); ok { + p.CandidateID = v + } + _, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocAgentCandidateReady, + "智能体变更待确认", "文档有高风险智能体候选变更,请确认后生效。", + fmt.Sprintf("doc_candidate:%s:%d:%d", docID, p.CandidateID, recipient), recipient, p) + return err + case notification.EventDocAgentVersionCommitted: + if v, ok := payload["version_no"].(int); ok { + p.VersionNo = v + } + _, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocAgentVersionCommitted, + "智能体版本已生效", "新的智能体文档版本已提交。", + fmt.Sprintf("doc_agent_version:%s:%d:%d", docID, p.VersionNo, recipient), recipient, p) + return err + case notification.EventDocDownstreamImpact: + impacted, _ := payload["impacted_doc_id"].(string) + p.ImpactedDocID = impacted + _, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocDownstreamImpact, + "下游文档受影响", "上游文档变更,请复核关联文档。", + fmt.Sprintf("doc_impact:%s:%s:%d", docID, impacted, recipient), recipient, p) + return err + case notification.EventDocChangeReportDegraded: + if v, ok := payload["report_id"].(int64); ok { + p.ReportID = v + } + _, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocChangeReportDegraded, + "变更报告未完整评估", "变更报告降级,受控文档暂不可送审。", + fmt.Sprintf("doc_report_degraded:%s:%d:%d", docID, p.ReportID, recipient), recipient, p) + return err + default: + return nil + } + } + } return s } @@ -249,6 +308,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/docgov/lease/heartbeat", s.handleDocGovHeartbeatLease) mux.HandleFunc("POST /api/v1/docgov/lease/release", s.handleDocGovReleaseLease) + // 8.1 文档版本 / 修订 ledger + s.registerDocumentRevisionRoutes(mux) + // 9. 用户认证、安全改密与超级管理员后台接口 mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister) diff --git a/web/e2e/document-revision.spec.ts b/web/e2e/document-revision.spec.ts new file mode 100644 index 00000000..4557cebe --- /dev/null +++ b/web/e2e/document-revision.spec.ts @@ -0,0 +1,14 @@ +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/src/App.tsx b/web/src/App.tsx index c3e27daa..7518d253 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ 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'; @@ -19,6 +20,8 @@ import { } from './api/freeformDoc'; import { fetchChatSessions, upsertChatSession } from './api/chatSessions'; import { listProjectWorkflowDocuments } from './api/projectDocuments'; +import { patchDocumentContent, getRevisionState } from './api/documentRevision'; +import type { DiffResult, OverlayMode } from './features/documentRevision/types'; import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow'; import { addProjectMember, @@ -673,6 +676,9 @@ export default function App() { if (targetProjId === 3) return 'doc-cusa-feasibility-reg'; return deliverables[0]?.docId || 'doc-001'; }); + const [docRevOverlayMode, setDocRevOverlayMode] = useState('manual'); + const [docRevOverlay, setDocRevOverlay] = useState(null); + const [docRevConflict, setDocRevConflict] = useState(null); const [deckDrawerOpen, setDeckDrawerOpen] = useState(false); // 认证与会话状态 @@ -3128,16 +3134,52 @@ export default function App() { ]); }; - // 块操作 - const handleSaveBlockEdit = (updated: DocumentBlock) => { + // 块操作:更新本地 AST 后经 revision ledger 持久化(服务端身份为准) + const handleSaveBlockEdit = async (updated: DocumentBlock) => { const children = docAST.rootBlock.children || []; const idx = children.findIndex((c) => c.id === updated.id); - if (idx !== -1) { - children[idx] = updated; - recomputeNumbering(docAST.rootBlock); - setDocAST({ ...docAST }); + if (idx === -1) { + setEditingBlock(null); + return; } + children[idx] = updated; + recomputeNumbering(docAST.rootBlock); + const nextAST = { ...docAST }; + setDocAST(nextAST); + setDocCache((prev) => ({ ...prev, [activeDocId]: nextAST })); setEditingBlock(null); + setDocRevConflict(null); + + const target = deliverables.find((d) => d.docId === activeDocId); + if (!target || target.governanceMode === 'freeform') { + return; + } + try { + let etag = '0:0'; + try { + const st = await getRevisionState(activeDocId); + etag = st.etag; + } catch { + /* first save bootstraps ledger */ + } + const markdown = blockASTToMarkdown(nextAST); + const idempotencyKey = + (typeof crypto !== 'undefined' && 'randomUUID' in crypto && crypto.randomUUID()) || + `edit-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const res = await patchDocumentContent( + activeDocId, + { markdown, edit_session_id: `ui-${currentUser.id}` }, + { etag, idempotencyKey }, + ); + setDocRevOverlay(res.manual_overlay || null); + } catch (e) { + const err = e as Error & { code?: string; conflict?: { current_revision_no?: number } }; + if (err.code === 'DOCUMENT_HEAD_CONFLICT') { + setDocRevConflict('文档已被他人更新。请刷新后重做,系统不会自动覆盖你的副本。'); + } else { + setDocRevConflict(err.message || '保存失败'); + } + } }; const handleInsertBlock = (index: number, position: 'above' | 'below') => { @@ -3797,6 +3839,25 @@ export default function App() { onExportXlsx={handleExportXlsx} onExportMarkdown={handleExportMarkdown} /> + + {docRevConflict && ( +
+ {docRevConflict} + +
+ )} + {docRevOverlayMode === 'manual' && docRevOverlay && docRevOverlay.hunks?.length > 0 && ( +
+ 人工差异 {docRevOverlay.summary.modifies + docRevOverlay.summary.inserts + docRevOverlay.summary.deletes} 处 +
+ )} {/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */} setEditingBlock(null)} /> diff --git a/web/src/api/documentRevision.ts b/web/src/api/documentRevision.ts new file mode 100644 index 00000000..bad7f6ea --- /dev/null +++ b/web/src/api/documentRevision.ts @@ -0,0 +1,108 @@ +import type { + ChangeReport, + DiffResult, + DocumentVersion, + PatchContentBody, + RevisionState, +} from '../features/documentRevision/types'; +import { mergeAuthHeaders } from './authHeaders'; + +async function api(path: string, init?: RequestInit): Promise { + const extra = + init?.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : ((init?.headers as Record | undefined) ?? {}); + const contentType = + typeof extra['Content-Type'] === 'string' ? extra['Content-Type'] : 'application/json'; + const headers = mergeAuthHeaders(extra, contentType); + const res = await fetch(path, { + credentials: 'include', + ...init, + headers, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error((data as { error?: string }).error || res.statusText) as Error & { + status: number; + code?: string; + conflict?: unknown; + body?: unknown; + }; + err.status = res.status; + err.code = (data as { code?: string }).code; + err.conflict = (data as { conflict?: unknown }).conflict; + err.body = data; + throw err; + } + return data as T; +} + +export function getRevisionState(docId: string) { + return api(`/api/v1/documents/${encodeURIComponent(docId)}/revision-state`); +} + +export function listVersions(docId: string) { + return api<{ versions: DocumentVersion[] }>(`/api/v1/documents/${encodeURIComponent(docId)}/versions`); +} + +export function getVersionContent(docId: string, versionNo: number, view: 'final' | 'baseline' = 'final') { + return api<{ content_markdown: string; content_sha256: string }>( + `/api/v1/documents/${encodeURIComponent(docId)}/versions/${versionNo}?view=${view}`, + ); +} + +export function getDiff(docId: string, fromVersion: number, toVersion: number) { + return api( + `/api/v1/documents/${encodeURIComponent(docId)}/diff?from_version=${fromVersion}&to_version=${toVersion}&from_view=final&to_view=final`, + ); +} + +export function getManualOverlay(docId: string) { + return api(`/api/v1/documents/${encodeURIComponent(docId)}/manual-overlay`); +} + +export function patchDocumentContent( + docId: string, + body: PatchContentBody, + opts: { etag: string; idempotencyKey: string }, +) { + return api<{ state: RevisionState; manual_overlay: DiffResult; conflict?: unknown }>( + `/api/v1/documents/${encodeURIComponent(docId)}/content`, + { + method: 'PATCH', + headers: { + 'If-Match': `"${opts.etag}"`, + 'Idempotency-Key': opts.idempotencyKey, + }, + body: JSON.stringify(body), + }, + ); +} + +export function getChangeReport(docId: string, reportId: number) { + return api(`/api/v1/documents/${encodeURIComponent(docId)}/change-reports/${reportId}`); +} + +export function decideAgentCandidate( + docId: string, + candidateId: number, + body: { + decision: 'commit' | 'reject'; + confirmed?: boolean; + expected_input_sha?: string; + expected_output_sha?: string; + reason?: string; + }, +) { + return api( + `/api/v1/documents/${encodeURIComponent(docId)}/agent-candidates/${candidateId}/decision`, + { 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/components/doc/BlockEditorModal.tsx b/web/src/components/doc/BlockEditorModal.tsx index 0bb7d29a..682f42fa 100644 --- a/web/src/components/doc/BlockEditorModal.tsx +++ b/web/src/components/doc/BlockEditorModal.tsx @@ -28,9 +28,11 @@ interface BlockEditorModalProps { block: DocumentBlock; onSave: (updated: DocumentBlock) => void; onClose: () => void; + /** Server-trusted display name; never hardcode. */ + actorLabel?: string; } -export const BlockEditorModal: React.FC = ({ block, onSave, onClose }) => { +export const BlockEditorModal: React.FC = ({ block, onSave, onClose, actorLabel }) => { const [content, setContent] = useState(block.content || ''); const [level, setLevel] = useState(block.level || 1); const [tableData, setTableData] = useState( @@ -101,7 +103,7 @@ export const BlockEditorModal: React.FC = ({ block, onSav }, metadata: { ...block.metadata, - lastModifiedBy: '张工 (编制负责人)', + lastModifiedBy: actorLabel || '未知用户', lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), }, }; diff --git a/web/src/components/doc/ChangeReportPanel.tsx b/web/src/components/doc/ChangeReportPanel.tsx new file mode 100644 index 00000000..7423ad81 --- /dev/null +++ b/web/src/components/doc/ChangeReportPanel.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import type { ChangeReport } from '../../features/documentRevision/types'; +import { getChangeReport } from '../../api/documentRevision'; + +export function ChangeReportPanel(props: { docId: string; reportId: number; onClose: () => void }) { + const [report, setReport] = useState(null); + useEffect(() => { + getChangeReport(props.docId, props.reportId).then(setReport).catch(() => setReport(null)); + }, [props.docId, props.reportId]); + return ( +
+
+

变更报告

+ +
+ {!report ?

加载中…

: ( + <> +

风险:{report.risk_level} · {report.analysis_status}

+
    {report.summary.map((s) =>
  • {s}
  • )}
+ {report.document_impacts?.length > 0 && ( +

影响文档 {report.document_impacts.length} 份

+ )} + + )} +
+ ); +} diff --git a/web/src/components/doc/DiffNavigator.tsx b/web/src/components/doc/DiffNavigator.tsx new file mode 100644 index 00000000..1592075a --- /dev/null +++ b/web/src/components/doc/DiffNavigator.tsx @@ -0,0 +1,13 @@ +import type { DiffHunk } from '../../features/documentRevision/types'; + +export function DiffNavigator(props: { hunks: DiffHunk[] }) { + return ( + + ); +} diff --git a/web/src/components/doc/DocumentRevisionToolbar.tsx b/web/src/components/doc/DocumentRevisionToolbar.tsx new file mode 100644 index 00000000..34b53fa6 --- /dev/null +++ b/web/src/components/doc/DocumentRevisionToolbar.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react'; +import type { DiffResult, DocumentVersion, OverlayMode, RevisionState } from '../../features/documentRevision/types'; +import { getDiff, getManualOverlay, getRevisionState, listVersions } from '../../api/documentRevision'; +import { VersionHistoryDrawer } from './VersionHistoryDrawer'; +import { VersionCompareView } from './VersionCompareView'; +import { ChangeReportPanel } from './ChangeReportPanel'; +import { ManualChangeOverlay } from './ManualChangeOverlay'; + +export function DocumentRevisionToolbar(props: { + docId: string; + overlayMode: OverlayMode; + onOverlayModeChange: (m: OverlayMode) => void; + onOverlay: (d: DiffResult | null) => void; +}) { + const { docId, overlayMode, onOverlayModeChange, onOverlay } = props; + const [state, setState] = useState(null); + const [versions, setVersions] = useState([]); + const [historyOpen, setHistoryOpen] = useState(false); + const [compare, setCompare] = useState<{ a: number; b: number; diff: DiffResult | null } | null>(null); + const [reportId, setReportId] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const st = await getRevisionState(docId); + if (cancelled) return; + setState(st); + setError(null); + if (overlayMode === 'manual') { + onOverlay(await getManualOverlay(docId)); + } else { + onOverlay(null); + } + } catch (e) { + if (!cancelled) { + setState(null); + setError((e as Error).message); + onOverlay(null); + } + } + })(); + return () => { + cancelled = true; + }; + }, [docId, overlayMode, onOverlay]); + + return ( +
+ {state ? ( + <> + V{state.current_version_no} + {state.version_status === 'open' ? '当前' : '已封存'} + r{state.head_revision_no} + {state.manual_change_count} 处人工修改 + + ) : ( + {error ? `版本账本未就绪:${error}` : '加载版本状态…'} + )} + + + + + {historyOpen && ( + setHistoryOpen(false)} + onSelectCompare={async (a, b) => { + const diff = await getDiff(docId, a, b); + setCompare({ a, b, diff }); + setHistoryOpen(false); + }} + onOpenReport={(id) => setReportId(id)} + /> + )} + {compare && compare.diff && ( + setCompare(null)} + /> + )} + {reportId != null && ( + setReportId(null)} /> + )} + {overlayMode === 'manual' && } +
+ ); +} diff --git a/web/src/components/doc/ManualChangeOverlay.tsx b/web/src/components/doc/ManualChangeOverlay.tsx new file mode 100644 index 00000000..67d5d934 --- /dev/null +++ b/web/src/components/doc/ManualChangeOverlay.tsx @@ -0,0 +1,8 @@ +/** Marker component — decorations are applied via overlay hunks on blocks. */ +export function ManualChangeOverlay() { + return ( + + 人工修改标记:琥珀底 + 下划线(相对最新智能体 baseline) + + ); +} diff --git a/web/src/components/doc/VersionCompareView.tsx b/web/src/components/doc/VersionCompareView.tsx new file mode 100644 index 00000000..8b2a7373 --- /dev/null +++ b/web/src/components/doc/VersionCompareView.tsx @@ -0,0 +1,34 @@ +import type { DiffResult } from '../../features/documentRevision/types'; +import { DiffNavigator } from './DiffNavigator'; + +export function VersionCompareView(props: { + fromVersion: number; + toVersion: number; + diff: DiffResult; + onClose: () => void; +}) { + const { fromVersion, toVersion, diff, onClose } = props; + const narrow = typeof window !== 'undefined' && window.innerWidth < 900; + return ( +
+
+

比较 V{fromVersion} → V{toVersion}

+ +
+

+ +{diff.summary.inserts} / -{diff.summary.deletes} / ~{diff.summary.modifies} / ↔{diff.summary.moves} + {narrow ? '(窄屏 unified)' : '(宽屏 side-by-side)'} +

+ +
+ {diff.hunks.map((h) => ( +
+
{h.kind} · {h.section || '正文'}
+ {h.before_text != null &&
{h.before_text}
} + {h.after_text != null &&
{h.after_text}
} +
+ ))} +
+
+ ); +} diff --git a/web/src/components/doc/VersionHistoryDrawer.tsx b/web/src/components/doc/VersionHistoryDrawer.tsx new file mode 100644 index 00000000..3365de5b --- /dev/null +++ b/web/src/components/doc/VersionHistoryDrawer.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type { DocumentVersion } from '../../features/documentRevision/types'; + +export function VersionHistoryDrawer(props: { + versions: DocumentVersion[]; + onClose: () => void; + onSelectCompare: (a: number, b: number) => void; + onOpenReport: (reportId: number) => void; +}) { + const { versions, onClose, onSelectCompare, onOpenReport } = props; + const [a, setA] = React.useState(null); + const [b, setB] = React.useState(null); + return ( +
+
+

版本历史

+ +
+
    + {versions.map((v) => ( +
  • +
    + V{v.version_no} {v.status === 'open' ? '当前' : '已封存'} · {v.origin_kind} +
    +
    人工修订 {v.human_revision_count}
    +
    + + + {v.change_report_id ? ( + + ) : null} +
    +
  • + ))} +
+ +
+ ); +} diff --git a/web/src/components/notifications/DocumentNotificationList.tsx b/web/src/components/notifications/DocumentNotificationList.tsx new file mode 100644 index 00000000..7bdf6e42 --- /dev/null +++ b/web/src/components/notifications/DocumentNotificationList.tsx @@ -0,0 +1,55 @@ +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/features/documentRevision/types.ts b/web/src/features/documentRevision/types.ts new file mode 100644 index 00000000..1320c35b --- /dev/null +++ b/web/src/features/documentRevision/types.ts @@ -0,0 +1,113 @@ +/** Document revision ledger types (aligned with internal/docrevision). */ +export type VersionStatus = 'open' | 'sealed'; +export type OriginKind = 'template' | 'agent' | 'agent_squad' | 'migration'; +export type ActorKind = 'human' | 'agent' | 'agent_squad' | 'template' | 'migration' | 'system'; +export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'; +export type OverlayMode = 'pure' | 'manual' | 'all'; + +export interface ActorSnapshot { + display_name?: string; + role?: string; + duty?: string; + agent_key?: string; + squad_name?: string; + model?: string; + config_version?: string; +} + +export interface BlockNode { + id: string; + type: string; + start_offset: number; + end_offset: number; + heading_path?: string; + business_key?: string; + fingerprint?: string; + order: number; +} + +export interface RevisionState { + doc_id: string; + current_version_no: number; + version_status: VersionStatus; + baseline_snapshot_id: number; + head_snapshot_id: number; + head_revision_no: number; + head_sha256: string; + manual_change_count: number; + capabilities: string[]; + etag: string; +} + +export interface DiffHunk { + id: string; + kind: 'insert' | 'delete' | 'modify' | 'move'; + section?: string; + before_text?: string; + after_text?: string; + block_id_before?: string; + block_id_after?: string; + actor_kind?: ActorKind; + actor_label?: string; + line_start?: number; + line_end?: number; +} + +export interface DiffResult { + from_snapshot_id?: number; + to_snapshot_id?: number; + summary: { inserts: number; deletes: number; modifies: number; moves: number }; + hunks: DiffHunk[]; +} + +export interface DocumentVersion { + id: number; + doc_id: string; + version_no: number; + status: VersionStatus; + origin_kind: OriginKind; + baseline_snapshot_id: number; + head_snapshot_id: number; + human_revision_count: number; + source_run_id?: string; + change_report_id?: number; + opened_at: string; + sealed_at?: string; +} + +export interface ChangeReport { + id: number; + doc_id: string; + schema_version: number; + summary: string[]; + risk_level: RiskLevel; + risk_items: { category: string; description: string; mitigation?: string }[]; + document_impacts: { doc_id: string; impact_type: string; reason: string; recommended_action?: string }[]; + open_questions: string[]; + analysis_status: string; +} + +export interface ConflictInfo { + code: string; + base_snapshot_id: number; + current_snapshot_id: number; + current_revision_no: number; + current_sha256: string; +} + +export interface PatchContentBody { + markdown: string; + edit_session_id?: string; + client_block_map_version?: number; +} + +export const DOC_REV_ERROR = { + HEAD_CONFLICT: 'DOCUMENT_HEAD_CONFLICT', + EDIT_FORBIDDEN: 'DOCUMENT_EDIT_FORBIDDEN', + BUSY: 'DOCUMENT_BUSY', + CANDIDATE_STALE: 'AGENT_CANDIDATE_STALE', + REPORT_INVALID: 'CHANGE_REPORT_INVALID', + LIFECYCLE_LOCKED: 'DOCUMENT_LIFECYCLE_LOCKED', + TOO_LARGE: 'DOCUMENT_TOO_LARGE', + NEEDS_CONFIRMATION: 'AGENT_NEEDS_CONFIRMATION', +} as const;