diff --git a/.doc/audit/2026-09-13-document-revision-editor-hydration-race.md b/.doc/audit/2026-09-13-document-revision-editor-hydration-race.md new file mode 100644 index 00000000..a61f28ef --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-editor-hydration-race.md @@ -0,0 +1,55 @@ +--- +title: "UI-06 编辑弹窗打开期间 hydration 换块 ID 导致保存静默丢稿" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# UI-06 编辑弹窗打开期间 hydration 换块 ID 导致保存静默丢稿 + +## 现象 + +第十二轮真实 UI(schema 30,dev@4c465d2 基线): + +- 双击正文块、fill 标记、点击「保存修改」动作均成功,编辑器随后关闭 +- 30 秒内修订仍为 **r0** +- 网络 trace 中 `PATCH /api/v1/documents/.../content` **数量为 0** +- 动作 monotonic:dblclick 611755–611807;fill 611860–611910;save 611917–611979 +- 恰在 fill 开始时,后台 `GET /api/v1/docgov/documents/doc_proj6140_intake/content` 于 **611862.379** 发起并完成 hydration + +本轮不是选择器点错按钮(第七轮 save-selector),而是保存路径在客户端被静默短路。 + +## 根因分析 + +| 层 | 问题 | 后果 | +|---|---|---| +| AST 身份 | `markdownToBlockAST` / `generateId()` 明确 `Math.random()`,每次 parse 新建 `blk_*` | 弹窗持有打开时的块 ID,当前 `docAST` 已是新 ID | +| hydrate 丢账本 | `fetchRevisionState` 依赖被窄化为 `Promise<{etag:string}>`;`buildControlledDocumentAST` 之后**没有** `applyHeadBlockMap(rev.st.head_block_map)` | 服务端已有稳定块身份,前端每次 hydration 仍随机换 ID | +| 保存 | `handleSaveBlockEdit` 按 `updated.id` 在 `docAST.rootBlock.children` 查找;`idx === -1` 时 `setEditingBlock(null)` 后 return | 关窗、不发 PATCH、无提示 | +| hydration 触发 | `useEffect([session, sessionValidated, activeDocId, deliverables])` 在 lease/工作流同步改 `deliverables` 时重跑 | fill 窗口打出新 GET | +| 迟到响应 | hydrate `.then` 无 generation / 编辑门闩 | 弹窗打开后或 PATCH 成功后仍可覆盖编辑基线(即使 ID 已对齐,也会回滚新正文) | + +`deliverables` 被 5s lease 同步、任务完成后 `syncControlledDocsAfterTask`、工作流列表合并(清 cache)以及 governance 事件改写。任一变化都会在编辑期间再拉正文并换块身份。 + +## 严重度 + +`high` + +用户可见「已保存」(弹窗关闭),账本仍 r0。属于静默丢稿,不是提示后的失败。 + +## 关联整改 + +- Remediation: [../remediation/2026-09-13-document-revision-editor-hydration-race.md](../remediation/2026-09-13-document-revision-editor-hydration-race.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-editor-hydration-race.md](../ops/short-term/2026-09-13-document-revision-editor-hydration-race.md) +- 分支: `fix/document-revision-editor-hydration-race` + +## 证据 + +- 失败证据目录(根代理保留、本轮不得改/移/删/提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui06-r-bump-noop-failure/` +- Playwright 断言:`[data-testid="doc-rev-revision"]` 30s 内仍为 `r0` + +## 开放问题 + +- 本轮只做前端门闩与单测/构建验证;**未跑真实 UI-06**,不得标 E2E 通过或任务 `done` +- 关窗后的下一次 deliverables 驱动 hydrate 仍会重建块 ID(编辑会话已结束,可接受) diff --git a/.doc/audit/INDEX.md b/.doc/audit/INDEX.md index 82408b1f..cc715b17 100644 --- a/.doc/audit/INDEX.md +++ b/.doc/audit/INDEX.md @@ -31,6 +31,14 @@ ### 最新专项审计(2026-09-13) +**文件**: [2026-09-13-document-revision-editor-hydration-race.md](./2026-09-13-document-revision-editor-hydration-race.md) + +**结论**: 第十二轮 UI-06 在 fill 窗口完成后台 hydration,块 ID 被重建;保存按旧 ID 找不到目标后静默关窗,未发 PATCH,修订停在 r0。 + +**整改**: [编辑弹窗 hydration 竞态整改](../remediation/2026-09-13-document-revision-editor-hydration-race.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 b54bf811..c36ae0e4 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,13 +1,13 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30**;第11轮 UI-11 prelim output 契约;编码可改 Dockerfile / 测 build context,不执行 env-service 重建) +**production_ready**: no(DOCREV:schema **30**;第十二轮 UI-06 编辑弹窗 hydration 竞态;不执行 env-service 重建 / 不声称 E2E 通过) ## 焦点 -1. **DOCREV-RT**: [prelim_design 缺 output.md 产物契约修复](short-term/2026-09-13-document-revision-prelim-output.md)(`fix/document-revision-pi-runner-module`:dev@55a4f39 重建后 runner 缺 `settle-policy.mjs` 热修;不执行 env-service 重建;仍 `in_progress`) -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 +1. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(`fix/document-revision-editor-hydration-race`;第十二轮 UI-06 r0 静默丢稿;不声称 E2E 通过) +2. **DOCREV-RT**: [prelim_design 缺 output.md 产物契约修复](short-term/2026-09-13-document-revision-prelim-output.md)(`fix/document-revision-pi-runner-module` 已合入 `dev@4c465d2`;仍 `in_progress`,待占槽重跑 UI-11) +3. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) ## 并行认领 @@ -28,6 +28,7 @@ | Cursor-Auto | document-revision-e2e-overlay-transition | fix/document-revision-e2e-overlay-transition | - | document-revision-overlay-transition | in_progress | | Cursor-Auto | document-revision-second-doc-routing-lease | fix/document-revision-second-doc-routing-lease | - | document-revision-second-doc-routing-lease | in_progress | | Cursor-Auto | document-revision-prelim-output | fix/document-revision-pi-runner-module | - | document-revision-prelim-output | in_progress | +| Cursor-Auto | document-revision-editor-hydration-race | fix/document-revision-editor-hydration-race | - | document-revision-editor-hydration-race | in_progress | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 @@ -73,3 +74,4 @@ | UI-07 transition 中间帧取样审计 | [../audit/2026-09-13-document-revision-overlay-transition.md](../audit/2026-09-13-document-revision-overlay-transition.md) | | UI-11 第二份文档路由与 lease 审计 | [../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../audit/2026-09-13-document-revision-second-doc-routing-lease.md) | | UI-11 prelim_design 缺 output.md 审计 | [../audit/2026-09-13-document-revision-prelim-output.md](../audit/2026-09-13-document-revision-prelim-output.md) | +| UI-06 编辑弹窗 hydration 竞态审计 | [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-editor-hydration-race.md b/.doc/ops/short-term/2026-09-13-document-revision-editor-hydration-race.md new file mode 100644 index 00000000..3809ccf3 --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-editor-hydration-race.md @@ -0,0 +1,59 @@ +--- +title: "编辑弹窗 hydration 竞态修复" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor-Auto +branch: fix/document-revision-editor-hydration-race +worktree: "-" +mutex: document-revision-editor-hydration-race +--- + +# 编辑弹窗 hydration 竞态修复 + +## 目标 + +堵住第十二轮 UI-06:编辑弹窗打开期间后台 hydration 换块 ID,保存静默关窗且不发 PATCH。用户编辑期间与 PATCH 成功后,迟到 hydration 不得覆盖基线;找不到块 fail-closed。 + +## 范围 + +- `web/src/lib/controlledDocHydrate.ts`:`fetchRevisionState` 保留 `head_block_map`,hydrate 后 `applyHeadBlockMap` +- `web/src/lib/editorHydrationGate.ts` 与单测(迟到响应不得覆盖 PATCH 成功正文) +- `web/src/lib/blockSavePolicy.ts` 与单测(stale-id fail-closed) +- `web/src/App.tsx`:初始/切换/重试 hydration、打开/关闭编辑器、保存 +- audit / remediation / CURRENT 认领 + +## 非目标 + +- 不修改、移动、删除或提交 `.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/` +- 不合 `dev`、不 push、不启动/重建 Docker +- 不运行或伪造真实 UI/E2E;不得声称 UI-06 已通过 +- 不改 FEATURE-MATRIX 完成度;不削弱 ETag/409 + +## 步骤 + +1. [x] 从 `dev@4c465d2` 开 `fix/document-revision-editor-hydration-race`,mutex 不与现有 in_progress 冲突 +2. [x] 定位全部会在弹窗打开期间替换 AST 的路径(effect / 选文档 / 重试;lease 改 deliverables 触发 effect) +3. [x] hydrate 对齐 `head_block_map` + generation token / 编辑门闩 + stale-id fail-closed +4. [x] 单测:同 map ID 稳定、缺 map 仍随机、deferred hydrate、stale id、迟到 hydrate、PATCH ETag、409 +5. [x] `web` npm test / build(tsx 相关 25 项 + 全量 `npm test` + `npm run build` 通过) +6. [ ] 真实 UI-06 重跑(本轮不做) + +## 验收标准 + +- [x] 编辑中不因后台 hydration 换 ID 而静默丢稿(逻辑门闩 + 单测) +- [x] 找不到目标块保留编辑器并提示 +- [ ] 现场 r0→r1(未跑 E2E,保持未勾选) + +## 相关链接 + +- Audit: [../../audit/2026-09-13-document-revision-editor-hydration-race.md](../../audit/2026-09-13-document-revision-editor-hydration-race.md) +- Remediation: [../../remediation/2026-09-13-document-revision-editor-hydration-race.md](../../remediation/2026-09-13-document-revision-editor-hydration-race.md) +- 失败证据(勿提交): `.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui06-r-bump-noop-failure/` + +## 证据 / 备注 + +- mutex:`document-revision-editor-hydration-race`(新 mutex,未占用 `document-revision-save-selector` 等 in_progress 行) +- 基线:`dev@4c465d2` +- 验证:`npx tsx --test` 覆盖 gate / save policy / hydrate / etag 共 25 pass;全量 `npm test` + `npm run build` 通过 +- **未跑真实 UI-06,不得标 done** diff --git a/.doc/remediation/2026-09-13-document-revision-editor-hydration-race.md b/.doc/remediation/2026-09-13-document-revision-editor-hydration-race.md new file mode 100644 index 00000000..5d8c5485 --- /dev/null +++ b/.doc/remediation/2026-09-13-document-revision-editor-hydration-race.md @@ -0,0 +1,50 @@ +--- +title: "编辑弹窗 hydration 竞态整改" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +--- + +# 编辑弹窗 hydration 竞态整改 + +## 背景 + +链到 [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md)。 + +第十二轮 UI-06:fill 瞬间后台 GET 完成 hydration,块 ID 被重建;保存按旧 ID 找不到目标后静默关窗,PATCH 次数为 0,修订停在 r0。 + +## 目标 + +- 编辑期间后台 hydration(含 fill 与 save 之间完成的响应)不得替换块身份、不得覆盖编辑基线 +- PATCH 成功后迟到 hydration 不得回滚新正文 / 新 ETag +- 找不到目标块时 fail-closed:保留弹窗与用户输入,明确提示,绝不关窗、不按正文改另一个块 +- 正常保存仍发 PATCH,采用打开时/hydrate 基线 ETag;409 语义不削弱 +- **不声称真实 UI/E2E 已通过** + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| Wave 0 | hydrate 对齐 `head_block_map`;抽出 `editorHydrationGate` + `blockSavePolicy`;App 三条 hydrate 路径与保存接线 | 单测:同 map 两次 hydrate ID 稳定;缺 map 仍随机;deferred hydrate / stale id / 迟到 hydrate / PATCH+409;`npm test` + `npm run build` | +| Wave 1 | 占槽后真实 UI-06(本轮不做) | 现场 PATCH 次数 ≥ 1 且 r0→r1 | + +## 设计 + +两层,缺一不可;不靠 Playwright sleep: + +1. **身份对齐**:`hydrateControlledDocumentContent` 保留完整 `RevisionState`(含 `head_block_map`),`buildControlledDocumentAST` 后 `applyHeadBlockMap`。同一 head 的重复 hydrate 不再因 `Math.random` 换 ID。 +2. **generation / 编辑门闩**:双击立刻 `setEditorOpen(true)`;编辑中 `beginHydration` 返回 `null`;已在途 token 经 `decideHydrationApply` 丢弃。PATCH 成功先 `markSaved()` 再关窗,迟到 hydrate **即使 ID 已对齐**也不得回滚新正文。 +3. **stale-id fail-closed**:`resolveSaveTargetBlock` **只按 id**;找不到则提示并留窗,绝不按正文改另一块、绝不静默关窗。 + +## 验收标准 + +- [x] 纯函数门闩覆盖 UI-06 时序(hydrate 在 fill 后 resolve 不覆盖) +- [x] stale block id 保存不静默关闭、不假成功、不改同内容另一块 +- [x] PATCH 成功后迟到 hydration 不回滚;409 保留草稿 +- [ ] 真实 UI-06 E2E(本轮不做,不得勾选) + +## 相关链接 + +- Audit: [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-editor-hydration-race.md](../ops/short-term/2026-09-13-document-revision-editor-hydration-race.md) +- 证据(只读): `.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui06-r-bump-noop-failure/` diff --git a/.doc/remediation/INDEX.md b/.doc/remediation/INDEX.md index 56cba23f..fad92970 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-editor-hydration-race.md](./2026-09-13-document-revision-editor-hydration-race.md) | 编辑弹窗打开期间 hydration 换块 ID,保存静默丢稿 | | [2026-09-13-document-revision-runtime-remediation.md](./2026-09-13-document-revision-runtime-remediation.md) | 账本 PG 持久化、bootstrap、保存/overlay/确定性 change report | | [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) | 对话写意图、刷新可恢复任务状态、文档占用可见性与诊断隔离 | diff --git a/web/src/App.tsx b/web/src/App.tsx index 8030df9c..5032de41 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -69,6 +69,14 @@ import { resolveCanonicalDocId, shouldLoadControlledContent, } from './lib/controlledDocHydrate'; +import { + createEditorHydrationGate, + type HydrationToken, +} from './lib/editorHydrationGate'; +import { + interpretBlockSaveError, + resolveSaveTargetBlock, +} from './lib/blockSavePolicy'; import { DocumentLineage } from './types/projectWorkflow'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; @@ -721,6 +729,22 @@ export default function App() { /** 人工保存 / 受控正文 hydrate 后递增,驱动工具条重新拉取 revision-state 与 overlay */ const [docRevRefreshKey, setDocRevRefreshKey] = useState(0); const [deckDrawerOpen, setDeckDrawerOpen] = useState(false); + const editorHydrationGateRef = useRef(createEditorHydrationGate()); + const [editingBlock, setEditingBlock] = useState(null); + + const openBlockEditor = useCallback((block: DocumentBlock) => { + editorHydrationGateRef.current.setEditorOpen(true); + setEditingBlock(block); + }, []); + + const closeBlockEditor = useCallback(() => { + editorHydrationGateRef.current.setEditorOpen(false); + setEditingBlock(null); + }, []); + + useEffect(() => { + editorHydrationGateRef.current.setActiveDocId(activeDocId); + }, [activeDocId]); // 认证与会话状态 const [session, setSession] = useState(() => authClient.loadSavedSession()); @@ -1148,11 +1172,35 @@ export default function App() { }); }, [deliverables, activeDocId]); - // 受控非模板文档:挂载/切换/同步后自动拉取服务端正文 + const applyHydratedDocument = useCallback( + ( + token: HydrationToken, + ast: DocumentAST | null | undefined, + docId: string, + opts?: { refreshToolbar?: boolean }, + ) => { + if (!ast) return false; + if (!editorHydrationGateRef.current.decide(token).apply) return false; + setDocCache((prev) => ({ ...prev, [docId]: ast })); + setControlledContentReady((prev) => ({ ...prev, [docId]: true })); + setDocLoadErrors((prev) => ({ ...prev, [docId]: false })); + if (opts?.refreshToolbar !== false) { + setDocRevRefreshKey((k) => k + 1); + } + return true; + }, + [], + ); + + // 受控非模板文档:挂载/切换/同步后自动拉取服务端正文。 + // hydrate 会按 head_block_map 对齐块 ID;编辑弹窗打开期间仍不得发起新 GET, + // 已在途响应也不得覆盖编辑基线或 PATCH 成功后的新正文。 useEffect(() => { if (!session || sessionValidated !== true) return; const target = deliverables.find((d) => d.docId === activeDocId); if (!target || !shouldLoadControlledContent(target)) return; + const token = editorHydrationGateRef.current.beginHydration(activeDocId); + if (!token) return; let cancelled = false; setDocLoadErrors((prev) => { @@ -1162,15 +1210,13 @@ export default function App() { }); void hydrateControlledDocumentContent(activeDocId, target) .then((ast) => { - if (cancelled || !ast) return; - setDocCache((prev) => ({ ...prev, [activeDocId]: ast })); - setControlledContentReady((prev) => ({ ...prev, [activeDocId]: true })); - setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: false })); - setDocRevRefreshKey((k) => k + 1); + if (cancelled) return; + applyHydratedDocument(token, ast, activeDocId); }) .catch((err) => { console.warn('自动拉取受控文档正文失败:', err); if (cancelled) return; + if (!editorHydrationGateRef.current.decide(token).apply) return; setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); setDocCache((prev) => ({ ...prev, @@ -1181,11 +1227,13 @@ export default function App() { return () => { cancelled = true; }; - }, [session, sessionValidated, activeDocId, deliverables]); + }, [session, sessionValidated, activeDocId, deliverables, applyHydratedDocument]); const retryControlledDocLoad = useCallback(() => { const target = deliverables.find((d) => d.docId === activeDocId); if (!target || !shouldLoadControlledContent(target)) return; + const token = editorHydrationGateRef.current.beginHydration(activeDocId); + if (!token) return; setDocLoadErrors((prev) => { const next = { ...prev }; @@ -1200,21 +1248,18 @@ export default function App() { void hydrateControlledDocumentContent(activeDocId, target) .then((ast) => { - if (!ast) return; - setDocCache((prev) => ({ ...prev, [activeDocId]: ast })); - setControlledContentReady((prev) => ({ ...prev, [activeDocId]: true })); - setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: false })); - setDocRevRefreshKey((k) => k + 1); + applyHydratedDocument(token, ast, activeDocId); }) .catch((err) => { console.warn('重试拉取受控文档正文失败:', err); + if (!editorHydrationGateRef.current.decide(token).apply) return; setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true })); setDocCache((prev) => ({ ...prev, [activeDocId]: buildControlledLoadErrorSkeleton(target), })); }); - }, [activeDocId, deliverables]); + }, [activeDocId, deliverables, applyHydratedDocument]); // 自愈后自动回写入缓存,保障后续操作直接命中 useEffect(() => { @@ -1251,6 +1296,10 @@ export default function App() { // 切换文档 const handleSelectDoc = (id: string) => { + if (id !== activeDocId && editorHydrationGateRef.current.isEditorOpen()) { + closeBlockEditor(); + } + editorHydrationGateRef.current.setActiveDocId(id); setActiveDocId(id); let targetDoc = deliverables.find((d) => d.docId === id); @@ -1311,11 +1360,11 @@ export default function App() { } if (targetDoc && shouldLoadControlledContent(targetDoc)) { + const token = editorHydrationGateRef.current.beginHydration(id); + if (!token) return; void hydrateControlledDocumentContent(id, targetDoc) .then((ast) => { - if (!ast) return; - setDocCache((prev) => ({ ...prev, [id]: ast })); - setControlledContentReady((prev) => ({ ...prev, [id]: true })); + applyHydratedDocument(token, ast, id, { refreshToolbar: false }); }) .catch((err) => { console.warn('获取受控文档正文失败:', err); @@ -1738,7 +1787,6 @@ export default function App() { }, [session, sessionValidated, currentProjectId]); // 模态弹窗与侧栏 - const [editingBlock, setEditingBlock] = useState(null); const [commentingBlock, setCommentingBlock] = useState(null); const [highlightedBlockId, setHighlightedBlockId] = useState(null); const [pendingProposal, setPendingProposal] = useState<{ @@ -3324,11 +3372,12 @@ export default function App() { // 块操作:先 PATCH,成功才提交 AST;失败保留编辑器并明确提示 const handleSaveBlockEdit = async (updated: DocumentBlock) => { const children = docAST.rootBlock.children || []; - const idx = children.findIndex((c) => c.id === updated.id); - if (idx === -1) { - setEditingBlock(null); + const targetBlock = resolveSaveTargetBlock(children, updated); + if (!targetBlock.ok) { + setDocRevConflict(targetBlock.message); return; } + const idx = targetBlock.index; const nextRoot = { ...docAST.rootBlock, children: children.map((c, i) => (i === idx ? updated : { ...c })), @@ -3343,7 +3392,7 @@ export default function App() { target.governanceMode === 'freeform' && !docAST.rootBlock?.children?.length ) { - setEditingBlock(null); + closeBlockEditor(); return; } try { @@ -3368,19 +3417,17 @@ export default function App() { if (res.state?.etag) { aligned.revisionEtag = res.state.etag; } + editorHydrationGateRef.current.markSaved(); setDocAST(aligned); setDocCache((prev) => ({ ...prev, [activeDocId]: aligned })); - setEditingBlock(null); + closeBlockEditor(); setDocRevConflict(null); setDocRevOverlay(res.manual_overlay || null); setDocRevRefreshKey((k) => k + 1); } 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 ui = interpretBlockSaveError(err); + setDocRevConflict(ui.conflict); } }; @@ -4219,7 +4266,7 @@ export default function App() { setEditingBlock(null)} + onClose={closeBlockEditor} /> )} diff --git a/web/src/lib/blockSavePolicy.test.ts b/web/src/lib/blockSavePolicy.test.ts new file mode 100644 index 00000000..9fbc1d88 --- /dev/null +++ b/web/src/lib/blockSavePolicy.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolvePatchEtag } from './revisionEtag'; +import { + HEAD_CONFLICT_SAVE_MESSAGE, + STALE_BLOCK_SAVE_MESSAGE, + interpretBlockSaveError, + nextRevisionEtagAfterPatch, + resolveSaveTargetBlock, + shouldCloseEditorAfterSave, +} from './blockSavePolicy'; + +describe('resolveSaveTargetBlock', () => { + it('keeps the editor and does not fake success when the block id is gone', () => { + const decision = resolveSaveTargetBlock( + [{ id: 'blk_rebuilt', content: '同一段正文' }], + { id: 'blk_from_modal', content: '同一段正文' }, + ); + assert.equal(decision.ok, false); + if (decision.ok) return; + assert.equal(decision.keepEditor, true); + assert.equal(decision.reason, 'block_not_found'); + assert.equal(decision.message, STALE_BLOCK_SAVE_MESSAGE); + assert.equal(shouldCloseEditorAfterSave('block_not_found'), false); + }); + + it('never retargets another block that happens to share the same content', () => { + const children = [ + { id: 'blk_other', content: '条款甲' }, + { id: 'blk_current', content: '条款甲' }, + ]; + const stale = resolveSaveTargetBlock(children, { id: 'blk_missing', content: '条款甲' }); + assert.equal(stale.ok, false); + const hit = resolveSaveTargetBlock(children, { id: 'blk_current', content: '条款甲(已改)' }); + assert.deepEqual(hit, { ok: true, index: 1 }); + }); +}); + +describe('block save PATCH / 409 policy', () => { + it('successful save still uses the hydrate ETag baseline and adopts the response ETag', () => { + const etag = resolvePatchEtag({ + revisionEtag: '1:0', + governanceMode: 'controlled', + }); + assert.equal(etag, '1:0'); + assert.equal( + nextRevisionEtagAfterPatch({ previousEtag: '1:0', responseEtag: '1:1' }), + '1:1', + ); + assert.equal(shouldCloseEditorAfterSave('success'), true); + }); + + it('409 keeps the draft in the editor', () => { + const ui = interpretBlockSaveError({ code: 'DOCUMENT_HEAD_CONFLICT' }); + assert.equal(ui.keepEditor, true); + assert.equal(ui.conflict, HEAD_CONFLICT_SAVE_MESSAGE); + assert.equal(shouldCloseEditorAfterSave('conflict'), false); + }); + + it('other PATCH failures also keep the editor', () => { + const ui = interpretBlockSaveError({ message: 'network down' }); + assert.equal(ui.keepEditor, true); + assert.match(ui.conflict, /network down/); + assert.match(ui.conflict, /仍保留/); + }); +}); diff --git a/web/src/lib/blockSavePolicy.ts b/web/src/lib/blockSavePolicy.ts new file mode 100644 index 00000000..6ac50e6f --- /dev/null +++ b/web/src/lib/blockSavePolicy.ts @@ -0,0 +1,55 @@ +export const STALE_BLOCK_SAVE_MESSAGE = + '保存失败:当前正文块身份已随后台刷新失效,编辑器仍保留你的修改。请勿关闭以免丢稿;刷新对照后重新打开该块再保存。'; + +export const HEAD_CONFLICT_SAVE_MESSAGE = + '保存失败:文档已被他人更新。编辑器仍保留你的未保存修改,请刷新对照后重试。'; + +export type SaveTargetDecision = + | { ok: true; index: number } + | { ok: false; keepEditor: true; reason: 'block_not_found'; message: string }; + +/** + * Resolve the PATCH target strictly by block id. + * Matching another node by identical markdown would write the wrong slot. + */ +export function resolveSaveTargetBlock( + children: ReadonlyArray<{ id: string; content?: string }>, + updated: { id: string; content?: string }, +): SaveTargetDecision { + const index = children.findIndex((child) => child.id === updated.id); + if (index === -1) { + return { + ok: false, + keepEditor: true, + reason: 'block_not_found', + message: STALE_BLOCK_SAVE_MESSAGE, + }; + } + return { ok: true, index }; +} + +export function interpretBlockSaveError(err: { code?: string; message?: string }): { + keepEditor: true; + conflict: string; +} { + if (err.code === 'DOCUMENT_HEAD_CONFLICT') { + return { keepEditor: true, conflict: HEAD_CONFLICT_SAVE_MESSAGE }; + } + return { + keepEditor: true, + conflict: `保存失败:${err.message || '请求未成功'}。编辑器仍保留当前修改,请重试。`, + }; +} + +export function nextRevisionEtagAfterPatch(input: { + previousEtag?: string; + responseEtag?: string; +}): string | undefined { + return input.responseEtag || input.previousEtag; +} + +export function shouldCloseEditorAfterSave( + kind: 'success' | 'conflict' | 'block_not_found', +): boolean { + return kind === 'success'; +} diff --git a/web/src/lib/controlledDocHydrate.test.ts b/web/src/lib/controlledDocHydrate.test.ts index 81d3db16..3d7cded1 100644 --- a/web/src/lib/controlledDocHydrate.test.ts +++ b/web/src/lib/controlledDocHydrate.test.ts @@ -7,6 +7,7 @@ import { shouldLoadControlledContent, } from './controlledDocHydrate'; import { resolvePatchEtag } from './revisionEtag'; +import { createEditorHydrationGate } from './editorHydrationGate'; import type { DeliverableItem } from '../types/workbench'; const base: DeliverableItem = { @@ -89,6 +90,55 @@ describe('controlledDocHydrate', () => { ); }); + it('aligns markdownToBlockAST random ids onto revision-state head_block_map', async () => { + const markdown = '# 标题\n\n正文'; + const head_block_map = [ + { id: 'srv_heading_intake', type: 'heading', start_offset: 0, end_offset: 4, order: 0 }, + { id: 'srv_para_intake', type: 'paragraph', start_offset: 5, end_offset: 7, order: 1 }, + ]; + const hydrate = () => + hydrateControlledDocumentContent('doc_proj56_intake', base, { + fetchContent: async () => ({ + doc_id: 'doc_proj56_intake', + version: 1, + content_sha256: 'abc', + markdown, + is_template_only: false, + }), + fetchRevisionState: async () => ({ etag: '1:0', head_block_map }), + }); + const first = await hydrate(); + const second = await hydrate(); + assert.ok(first && second); + const firstIds = first.rootBlock.children?.map((c) => c.id); + const secondIds = second.rootBlock.children?.map((c) => c.id); + assert.deepEqual(firstIds, ['srv_heading_intake', 'srv_para_intake']); + assert.deepEqual(secondIds, firstIds); + }); + + it('leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain)', async () => { + const markdown = '# 标题\n\n正文'; + const hydrate = () => + hydrateControlledDocumentContent('doc_proj56_intake', base, { + fetchContent: async () => ({ + doc_id: 'doc_proj56_intake', + version: 1, + content_sha256: 'abc', + markdown, + is_template_only: false, + }), + fetchRevisionState: async () => ({ etag: '1:0' }), + }); + const first = await hydrate(); + const second = await hydrate(); + assert.ok(first && second); + const firstIds = first.rootBlock.children?.map((c) => c.id) ?? []; + const secondIds = second.rootBlock.children?.map((c) => c.id) ?? []; + assert.equal(firstIds.length, 2); + assert.equal(secondIds.length, 2); + assert.notDeepEqual(firstIds, secondIds); + }); + it('rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT', async () => { const missingLedger = async () => { const err = new Error('needs content') as Error & { code?: string }; @@ -110,4 +160,46 @@ describe('controlledDocHydrate', () => { (err: Error & { code?: string }) => err.code === 'DOCUMENT_REVISION_NEEDS_CONTENT', ); }); + + it('aligned late hydrate still cannot overwrite a PATCH-success baseline', async () => { + const markdown = '# 标题\n\n正文'; + const head_block_map = [ + { id: 'srv_heading_intake', type: 'heading', start_offset: 0, end_offset: 4, order: 0 }, + { id: 'srv_para_intake', type: 'paragraph', start_offset: 5, end_offset: 7, order: 1 }, + ]; + let release!: (value: 'go') => void; + const hold = new Promise<'go'>((resolve) => { + release = resolve; + }); + const gate = createEditorHydrationGate(); + gate.setActiveDocId('doc_proj56_intake'); + const token = gate.beginHydration('doc_proj56_intake'); + assert.ok(token); + + const pending = hydrateControlledDocumentContent('doc_proj56_intake', base, { + fetchContent: async () => { + await hold; + return { + doc_id: 'doc_proj56_intake', + version: 1, + content_sha256: 'abc', + markdown, + is_template_only: false, + }; + }, + fetchRevisionState: async () => ({ etag: '1:0', head_block_map }), + }); + + gate.setEditorOpen(true); + gate.markSaved(); + gate.setEditorOpen(false); + release('go'); + const ast = await pending; + assert.ok(ast); + assert.deepEqual(ast.rootBlock.children?.map((c) => c.id), [ + 'srv_heading_intake', + 'srv_para_intake', + ]); + assert.deepEqual(gate.decide(token), { apply: false, reason: 'superseded_by_save' }); + }); }); diff --git a/web/src/lib/controlledDocHydrate.ts b/web/src/lib/controlledDocHydrate.ts index a2524053..540cf658 100644 --- a/web/src/lib/controlledDocHydrate.ts +++ b/web/src/lib/controlledDocHydrate.ts @@ -1,10 +1,16 @@ import { fetchControlledDocumentContent } from '../api/projectDocuments'; import { getRevisionState } from '../api/documentRevision'; import { markdownToBlockAST } from '../engine/markdownToBlock'; +import { applyHeadBlockMap } from '../features/documentRevision/overlayMatch'; +import type { RevisionState } from '../features/documentRevision/types'; import type { DocumentAST } from '../types/block'; import type { DeliverableItem } from '../types/workbench'; import { revisionEtagFromHydrate } from './revisionEtag'; +/** Tests may stub a slice; production getRevisionState returns the full ledger. */ +export type HydrateRevisionState = Pick & + Partial>; + export function isControlledWorkflowDeliverable(doc: DeliverableItem): boolean { return ( doc.isFixedWorkflow !== false && @@ -104,7 +110,7 @@ export async function hydrateControlledDocumentContent( doc: DeliverableItem, deps?: { fetchContent?: typeof fetchControlledDocumentContent; - fetchRevisionState?: (id: string) => Promise<{ etag: string }>; + fetchRevisionState?: (id: string) => Promise; }, ): Promise { const loadContent = deps?.fetchContent ?? fetchControlledDocumentContent; @@ -124,7 +130,12 @@ export async function hydrateControlledDocumentContent( if (!etag) { throw new Error('受控文档打开时未取得修订基线,请重试'); } - const ast = buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version); + // markdownToBlockAST uses Math.random IDs; pin them to the ledger map so a + // later hydrate of the same head does not invent a new identity. + const ast = applyHeadBlockMap( + buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version), + rev.st.head_block_map, + ); ast.revisionEtag = etag; ast.dutiesAuthoritative = doc.dutiesAuthoritative ?? true; return ast; diff --git a/web/src/lib/editorHydrationGate.test.ts b/web/src/lib/editorHydrationGate.test.ts new file mode 100644 index 00000000..e04490e0 --- /dev/null +++ b/web/src/lib/editorHydrationGate.test.ts @@ -0,0 +1,120 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + createEditorHydrationGate, + decideHydrationApply, +} from './editorHydrationGate'; + +describe('decideHydrationApply', () => { + const token = { gen: 2, saveEpoch: 0, docId: 'doc_a' }; + + it('applies only when gen, doc, save epoch match and editor is closed', () => { + assert.deepEqual( + decideHydrationApply({ + token, + currentGen: 2, + currentSaveEpoch: 0, + activeDocId: 'doc_a', + editorOpen: false, + }), + { apply: true, reason: 'ok' }, + ); + }); + + it('rejects a response for another document', () => { + assert.deepEqual( + decideHydrationApply({ + token, + currentGen: 2, + currentSaveEpoch: 0, + activeDocId: 'doc_b', + editorOpen: false, + }), + { apply: false, reason: 'doc_mismatch' }, + ); + }); + + it('rejects a superseded generation', () => { + assert.deepEqual( + decideHydrationApply({ + token, + currentGen: 3, + currentSaveEpoch: 0, + activeDocId: 'doc_a', + editorOpen: false, + }), + { apply: false, reason: 'stale_generation' }, + ); + }); +}); + +describe('createEditorHydrationGate', () => { + it('does not start a new hydrate while the editor is open (UI-06 fill-window GET)', () => { + const gate = createEditorHydrationGate(); + gate.setActiveDocId('doc_proj6140_intake'); + const before = gate.beginHydration('doc_proj6140_intake'); + assert.ok(before); + gate.setEditorOpen(true); + assert.equal(gate.beginHydration('doc_proj6140_intake'), null); + assert.equal(gate.decide(before).reason, 'editor_open'); + assert.equal(gate.decide(before).apply, false); + }); + + it('deferred hydrate that resolves after the editor opens does not replace the session', async () => { + const gate = createEditorHydrationGate(); + gate.setActiveDocId('doc_proj6140_intake'); + const token = gate.beginHydration('doc_proj6140_intake'); + assert.ok(token); + + let resolveHydrate!: (ast: { blockId: string }) => void; + const pending = new Promise<{ blockId: string }>((resolve) => { + resolveHydrate = resolve; + }); + + gate.setEditorOpen(true); + resolveHydrate({ blockId: 'blk_rebuilt_during_fill' }); + const rebuilt = await pending; + const decision = gate.decide(token); + + assert.equal(rebuilt.blockId, 'blk_rebuilt_during_fill'); + assert.equal(decision.apply, false); + assert.equal(decision.reason, 'editor_open'); + }); + + it('covers hydrate resolving between fill and save', async () => { + const gate = createEditorHydrationGate(); + gate.setActiveDocId('doc_proj6140_intake'); + const token = gate.beginHydration('doc_proj6140_intake'); + assert.ok(token); + + gate.setEditorOpen(true); + + let resolveDuringFill!: (value: 'hydrated') => void; + const hydrateDuringFill = new Promise<'hydrated'>((resolve) => { + resolveDuringFill = resolve; + }); + + resolveDuringFill('hydrated'); + await hydrateDuringFill; + assert.equal(gate.decide(token).reason, 'editor_open'); + + gate.markSaved(); + gate.setEditorOpen(false); + assert.deepEqual(gate.decide(token), { apply: false, reason: 'superseded_by_save' }); + }); + + it('late hydrate after a successful PATCH does not roll back the new baseline', () => { + const gate = createEditorHydrationGate(); + gate.setActiveDocId('doc_a'); + const stale = gate.beginHydration('doc_a'); + assert.ok(stale); + gate.setEditorOpen(true); + gate.markSaved(); + gate.setEditorOpen(false); + assert.deepEqual(gate.decide(stale), { apply: false, reason: 'superseded_by_save' }); + + const fresh = gate.beginHydration('doc_a'); + assert.ok(fresh); + assert.deepEqual(gate.decide(fresh), { apply: true, reason: 'ok' }); + }); +}); diff --git a/web/src/lib/editorHydrationGate.ts b/web/src/lib/editorHydrationGate.ts new file mode 100644 index 00000000..76d3ce3a --- /dev/null +++ b/web/src/lib/editorHydrationGate.ts @@ -0,0 +1,92 @@ +export type HydrationApplyReason = + | 'ok' + | 'editor_open' + | 'stale_generation' + | 'doc_mismatch' + | 'superseded_by_save'; + +export type HydrationToken = { + gen: number; + saveEpoch: number; + docId: string; +}; + +export type HydrationApplyDecision = { + apply: boolean; + reason: HydrationApplyReason; +}; + +/** + * Decide whether an in-flight controlled-doc hydration may replace the AST. + * + * Even after hydrate aligns IDs via revision-state head_block_map, a stale + * GET can still rewrite markdown/ETag after PATCH. Applying a response that + * started before the editor opened or before a successful save would drop + * the user's draft or roll back the new baseline. + */ +export function decideHydrationApply(input: { + token: HydrationToken; + currentGen: number; + currentSaveEpoch: number; + activeDocId: string; + editorOpen: boolean; +}): HydrationApplyDecision { + if (input.token.docId !== input.activeDocId) { + return { apply: false, reason: 'doc_mismatch' }; + } + if (input.token.gen !== input.currentGen) { + return { apply: false, reason: 'stale_generation' }; + } + if (input.editorOpen) { + return { apply: false, reason: 'editor_open' }; + } + if (input.token.saveEpoch !== input.currentSaveEpoch) { + return { apply: false, reason: 'superseded_by_save' }; + } + return { apply: true, reason: 'ok' }; +} + +export type EditorHydrationGate = { + setEditorOpen: (open: boolean) => void; + isEditorOpen: () => boolean; + setActiveDocId: (id: string) => void; + beginHydration: (docId: string) => HydrationToken | null; + markSaved: () => void; + decide: (token: HydrationToken) => HydrationApplyDecision; +}; + +export function createEditorHydrationGate(): EditorHydrationGate { + let hydrationGen = 0; + let saveEpoch = 0; + let editorOpen = false; + let activeDocId = ''; + + return { + setEditorOpen(open) { + editorOpen = open; + }, + isEditorOpen() { + return editorOpen; + }, + setActiveDocId(id) { + activeDocId = id; + }, + beginHydration(docId) { + if (editorOpen) return null; + hydrationGen += 1; + return { gen: hydrationGen, saveEpoch, docId }; + }, + markSaved() { + saveEpoch += 1; + }, + decide(token) { + return decideHydrationApply({ + token, + currentGen: hydrationGen, + currentSaveEpoch: saveEpoch, + activeDocId, + editorOpen, + }); + }, + }; +}