From 9b43bd6a73d44c3216320d4bd0e1a3c0c4bd21a9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 20:00:47 +0000 Subject: [PATCH] fix(web): bind editor saves to the opened doc and real PATCH Drop test-only ETag helpers. App now calls saveControlledBlockEdit, which PATCHes once on the bound editor docId or fail-closes. Resume hydrate after a blocked first load, and refuse silent doc switches. Co-authored-by: Cursor --- ...document-revision-editor-hydration-race.md | 7 +- ...document-revision-editor-hydration-race.md | 12 +- ...document-revision-editor-hydration-race.md | 6 + web/src/App.tsx | 100 ++++----- web/src/lib/blockSavePolicy.test.ts | 59 +++-- web/src/lib/blockSavePolicy.ts | 56 +++-- web/src/lib/editorHydrationGate.test.ts | 25 +++ web/src/lib/editorHydrationGate.ts | 23 +- web/src/lib/saveControlledBlockEdit.test.ts | 207 ++++++++++++++++++ web/src/lib/saveControlledBlockEdit.ts | 148 +++++++++++++ 10 files changed, 544 insertions(+), 99 deletions(-) create mode 100644 web/src/lib/saveControlledBlockEdit.test.ts create mode 100644 web/src/lib/saveControlledBlockEdit.ts 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 index a61f28ef..c392d231 100644 --- a/.doc/audit/2026-09-13-document-revision-editor-hydration-race.md +++ b/.doc/audit/2026-09-13-document-revision-editor-hydration-race.md @@ -29,6 +29,9 @@ severity: high | 保存 | `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 已对齐,也会回滚新正文) | +| 会话绑定 | 只按块 ID 保存;`activeDocId` 可被 canonical/session/任务流改写,不经 `handleSelectDoc` | 可能对另一份文档发 PATCH,或块 ID 碰巧对上 | +| 关窗续跑 | 编辑中拒绝/跳过的首次 hydrate,effect 依赖不再变化 | 关窗后可能永久停在 loading skeleton | +| 切文档 | `handleSelectDoc` 在编辑中静默 `closeBlockEditor` | 草稿被丢;modal 虽 `fixed inset:0 zIndex 100` 挡住 SlimTabBar,但 chat/drawer 等仍会调 `handleSelectDoc` | `deliverables` 被 5s lease 同步、任务完成后 `syncControlledDocsAfterTask`、工作流列表合并(清 cache)以及 governance 事件改写。任一变化都会在编辑期间再拉正文并换块身份。 @@ -52,4 +55,6 @@ severity: high ## 开放问题 - 本轮只做前端门闩与单测/构建验证;**未跑真实 UI-06**,不得标 E2E 通过或任务 `done` -- 关窗后的下一次 deliverables 驱动 hydrate 仍会重建块 ID(编辑会话已结束,可接受) +- 关窗后的下一次 deliverables 驱动 hydrate 仍会重建块 ID(编辑会话已结束;缺 map 时仍随机,门闩已消费) +- **首次 hydrate 在 `editor_open` 被拒后会卡 skeleton**:可达(骨架块可双击,或清 cache 后编辑中跳过 GET)。关窗时 `takeHydrationResume()` 递增 `hydrateResumeKey` 重跑 effect。 +- modal 不能硬保证挡死所有 `handleSelectDoc`;编辑中切文档 fail-closed,不静默关窗。 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 index 3809ccf3..e217600d 100644 --- 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 @@ -19,8 +19,9 @@ mutex: document-revision-editor-hydration-race - `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、打开/关闭编辑器、保存 +- `web/src/lib/blockSavePolicy.ts` 与单测(stale-id / doc 绑定 / 切文档 fail-closed) +- `web/src/lib/saveControlledBlockEdit.ts`:App 真实调用的 PATCH 编排(可注入 patch API) +- `web/src/App.tsx`:`editorSession`、关窗续跑、切文档 fail-closed - audit / remediation / CURRENT 认领 ## 非目标 @@ -36,8 +37,9 @@ mutex: document-revision-editor-hydration-race 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 重跑(本轮不做) +5. [x] `web` npm test / build(首轮) +6. [x] 独立审查补强:真实 PATCH 编排测试、editorDocId 绑定、关窗续跑、切文档 fail-closed +7. [ ] 真实 UI-06 重跑(本轮不做) ## 验收标准 @@ -55,5 +57,5 @@ mutex: document-revision-editor-hydration-race - 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` 通过 +- 验证:补强后全量 `npm test` + `npm run build` 通过;`saveControlledBlockEdit` 断言一次 PATCH - **未跑真实 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 index 5d8c5485..438907eb 100644 --- a/.doc/remediation/2026-09-13-document-revision-editor-hydration-race.md +++ b/.doc/remediation/2026-09-13-document-revision-editor-hydration-race.md @@ -35,12 +35,18 @@ updated: 2026-09-13 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**;找不到则提示并留窗,绝不按正文改另一块、绝不静默关窗。 +4. **真实 PATCH 编排**:`saveControlledBlockEdit` 被 `App.tsx` 调用;依赖注入 `patchDocumentContent`。成功路径恰好一次 PATCH(绑定 `editorDocId`、hydrate ETag、`client_block_map`),采用响应 ETag。409/网络错误不关窗。 +5. **编辑会话绑 docId**:`editorSession = { docId, block }`。`activeDocId` 被 canonical/session 改写后保存 fail-closed,无 PATCH。 +6. **关窗续跑**:`editor_open` 跳过/丢弃的 hydrate 置 `blockedNeedsResume`;安全关闭后 `hydrateResumeKey` 重跑 effect,避免永久 loading skeleton。 +7. **切文档**:modal `fixed inset:0 z-index 100` 挡住 SlimTabBar,但 chat/drawer 仍可调 `handleSelectDoc`。编辑中切换 fail-closed 并提示,不静默关窗。 ## 验收标准 - [x] 纯函数门闩覆盖 UI-06 时序(hydrate 在 fill 后 resolve 不覆盖) - [x] stale block id 保存不静默关闭、不假成功、不改同内容另一块 - [x] PATCH 成功后迟到 hydration 不回滚;409 保留草稿 +- [x] `saveControlledBlockEdit` 成功路径断言恰好一次 PATCH(docId/ETag/body/map);doc 漂移/409/网络不关窗 +- [x] 关窗后续跑被阻塞的 hydrate;编辑中切文档 fail-closed - [ ] 真实 UI-06 E2E(本轮不做,不得勾选) ## 相关链接 diff --git a/web/src/App.tsx b/web/src/App.tsx index 5032de41..bfab2c0d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -22,10 +22,7 @@ import { import { registerUploadAttachments } from './lib/uploadFreeformRegister'; import { fetchChatSessions, upsertChatSession } from './api/chatSessions'; import { listProjectWorkflowDocuments } from './api/projectDocuments'; -import { patchDocumentContent } from './api/documentRevision'; -import { resolvePatchEtag } from './lib/revisionEtag'; import type { DiffResult, OverlayMode } from './features/documentRevision/types'; -import { applyHeadBlockMap, astToClientBlockMap } from './features/documentRevision/overlayMatch'; import { documentRevisionInactiveHint, revisionToolbarMountMode, @@ -74,9 +71,10 @@ import { type HydrationToken, } from './lib/editorHydrationGate'; import { - interpretBlockSaveError, - resolveSaveTargetBlock, + decideActiveDocSwitch, + type BlockEditorSession, } from './lib/blockSavePolicy'; +import { saveControlledBlockEdit } from './lib/saveControlledBlockEdit'; import { DocumentLineage } from './types/projectWorkflow'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; @@ -730,16 +728,19 @@ export default function App() { const [docRevRefreshKey, setDocRevRefreshKey] = useState(0); const [deckDrawerOpen, setDeckDrawerOpen] = useState(false); const editorHydrationGateRef = useRef(createEditorHydrationGate()); - const [editingBlock, setEditingBlock] = useState(null); + const [hydrateResumeKey, setHydrateResumeKey] = useState(0); + const [editorSession, setEditorSession] = useState(null); const openBlockEditor = useCallback((block: DocumentBlock) => { editorHydrationGateRef.current.setEditorOpen(true); - setEditingBlock(block); - }, []); + setEditorSession({ docId: activeDocId, block }); + }, [activeDocId]); const closeBlockEditor = useCallback(() => { + const resume = editorHydrationGateRef.current.takeHydrationResume(); editorHydrationGateRef.current.setEditorOpen(false); - setEditingBlock(null); + setEditorSession(null); + if (resume) setHydrateResumeKey((k) => k + 1); }, []); useEffect(() => { @@ -1227,7 +1228,7 @@ export default function App() { return () => { cancelled = true; }; - }, [session, sessionValidated, activeDocId, deliverables, applyHydratedDocument]); + }, [session, sessionValidated, activeDocId, deliverables, applyHydratedDocument, hydrateResumeKey]); const retryControlledDocLoad = useCallback(() => { const target = deliverables.find((d) => d.docId === activeDocId); @@ -1296,8 +1297,13 @@ export default function App() { // 切换文档 const handleSelectDoc = (id: string) => { - if (id !== activeDocId && editorHydrationGateRef.current.isEditorOpen()) { - closeBlockEditor(); + const switchDecision = decideActiveDocSwitch({ + editorDocId: editorSession?.docId, + nextDocId: id, + }); + if (!switchDecision.allow) { + setDocRevConflict(switchDecision.message); + return; } editorHydrationGateRef.current.setActiveDocId(id); setActiveDocId(id); @@ -3369,22 +3375,8 @@ export default function App() { ]); }; - // 块操作:先 PATCH,成功才提交 AST;失败保留编辑器并明确提示 + // 块操作:编排层先 PATCH,成功才提交 AST;失败保留编辑器并明确提示 const handleSaveBlockEdit = async (updated: DocumentBlock) => { - const children = docAST.rootBlock.children || []; - 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 })), - }; - recomputeNumbering(nextRoot); - const nextAST = { ...docAST, rootBlock: nextRoot }; - const target = deliverables.find((d) => d.docId === activeDocId); if ( target && @@ -3395,40 +3387,24 @@ export default function App() { closeBlockEditor(); return; } - try { - const etag = resolvePatchEtag({ - revisionEtag: nextAST.revisionEtag ?? docAST.revisionEtag, - governanceMode: nextAST.governanceMode ?? docAST.governanceMode, - }); - 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}`, - client_block_map: astToClientBlockMap(nextAST), - }, - { etag, idempotencyKey }, - ); - const aligned = applyHeadBlockMap(nextAST, res.state?.head_block_map); - if (res.state?.etag) { - aligned.revisionEtag = res.state.etag; - } - editorHydrationGateRef.current.markSaved(); - setDocAST(aligned); - setDocCache((prev) => ({ ...prev, [activeDocId]: aligned })); - 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 } }; - const ui = interpretBlockSaveError(err); - setDocRevConflict(ui.conflict); + const result = await saveControlledBlockEdit({ + editorDocId: editorSession?.docId, + activeDocId, + ast: docAST, + updated, + actorUserId: currentUser.id, + }); + if (!result.ok) { + setDocRevConflict(result.message); + return; } + editorHydrationGateRef.current.markSaved(); + setDocAST(result.ast); + setDocCache((prev) => ({ ...prev, [activeDocId]: result.ast })); + closeBlockEditor(); + setDocRevConflict(null); + setDocRevOverlay(result.overlay); + setDocRevRefreshKey((k) => k + 1); }; const handleInsertBlock = (index: number, position: 'above' | 'below') => { @@ -4320,9 +4296,9 @@ export default function App() { )} {/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */} - {editingBlock && ( + {editorSession && ( { @@ -21,7 +22,6 @@ describe('resolveSaveTargetBlock', () => { 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', () => { @@ -36,25 +36,52 @@ describe('resolveSaveTargetBlock', () => { }); }); -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', +describe('editor session document binding', () => { + it('fail-closes when activeDocId drifts away from the opened editor doc', () => { + const decision = decideEditorDocBinding({ + editorDocId: 'doc_proj6140_intake', + activeDocId: 'doc_proj6140_research', }); - assert.equal(etag, '1:0'); - assert.equal( - nextRevisionEtagAfterPatch({ previousEtag: '1:0', responseEtag: '1:1' }), - '1:1', - ); - assert.equal(shouldCloseEditorAfterSave('success'), true); + assert.equal(decision.ok, false); + if (decision.ok) return; + assert.equal(decision.keepEditor, true); + assert.equal(decision.reason, 'doc_mismatch'); + assert.equal(decision.message, EDITOR_DOC_MISMATCH_MESSAGE); }); + it('accepts a save only when the editor is still bound to the active doc', () => { + assert.deepEqual( + decideEditorDocBinding({ + editorDocId: 'doc_proj6140_intake', + activeDocId: 'doc_proj6140_intake', + }), + { ok: true }, + ); + }); + + it('blocks switching documents while the editor is open', () => { + const blocked = decideActiveDocSwitch({ + editorDocId: 'doc_a', + nextDocId: 'doc_b', + }); + assert.equal(blocked.allow, false); + if (blocked.allow) return; + assert.equal(blocked.keepEditor, true); + assert.equal(blocked.message, DOC_SWITCH_WHILE_EDITING_MESSAGE); + assert.deepEqual(decideActiveDocSwitch({ editorDocId: 'doc_a', nextDocId: 'doc_a' }), { + allow: true, + }); + assert.deepEqual(decideActiveDocSwitch({ editorDocId: null, nextDocId: 'doc_b' }), { + allow: true, + }); + }); +}); + +describe('interpretBlockSaveError', () => { 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', () => { diff --git a/web/src/lib/blockSavePolicy.ts b/web/src/lib/blockSavePolicy.ts index 6ac50e6f..e9db7796 100644 --- a/web/src/lib/blockSavePolicy.ts +++ b/web/src/lib/blockSavePolicy.ts @@ -1,13 +1,30 @@ +import type { DocumentBlock } from '../types/block'; + export const STALE_BLOCK_SAVE_MESSAGE = '保存失败:当前正文块身份已随后台刷新失效,编辑器仍保留你的修改。请勿关闭以免丢稿;刷新对照后重新打开该块再保存。'; export const HEAD_CONFLICT_SAVE_MESSAGE = '保存失败:文档已被他人更新。编辑器仍保留你的未保存修改,请刷新对照后重试。'; +export const EDITOR_DOC_MISMATCH_MESSAGE = + '保存失败:当前编辑会话不属于正在查看的文档。编辑器仍保留你的修改,请先回到原文档再保存。'; + +export const DOC_SWITCH_WHILE_EDITING_MESSAGE = + '文档切换已取消:编辑器仍保留未保存修改。请先保存或关闭编辑器,再切换文档。'; + +export type BlockEditorSession = { + docId: string; + block: DocumentBlock; +}; + export type SaveTargetDecision = | { ok: true; index: number } | { ok: false; keepEditor: true; reason: 'block_not_found'; message: string }; +export type DocSwitchDecision = + | { allow: true } + | { allow: false; keepEditor: true; message: string }; + /** * Resolve the PATCH target strictly by block id. * Matching another node by identical markdown would write the wrong slot. @@ -28,6 +45,32 @@ export function resolveSaveTargetBlock( return { ok: true, index }; } +export function decideEditorDocBinding(input: { + editorDocId?: string | null; + activeDocId: string; +}): { ok: true } | { ok: false; keepEditor: true; reason: 'doc_mismatch'; message: string } { + if (!input.editorDocId || input.editorDocId !== input.activeDocId) { + return { + ok: false, + keepEditor: true, + reason: 'doc_mismatch', + message: EDITOR_DOC_MISMATCH_MESSAGE, + }; + } + return { ok: true }; +} + +/** SlimTabBar is usually under the modal, but handleSelectDoc also comes from chat/drawer/effects. */ +export function decideActiveDocSwitch(input: { + editorDocId?: string | null; + nextDocId: string; +}): DocSwitchDecision { + if (input.editorDocId && input.editorDocId !== input.nextDocId) { + return { allow: false, keepEditor: true, message: DOC_SWITCH_WHILE_EDITING_MESSAGE }; + } + return { allow: true }; +} + export function interpretBlockSaveError(err: { code?: string; message?: string }): { keepEditor: true; conflict: string; @@ -40,16 +83,3 @@ export function interpretBlockSaveError(err: { code?: string; message?: string } 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/editorHydrationGate.test.ts b/web/src/lib/editorHydrationGate.test.ts index e04490e0..6d12011a 100644 --- a/web/src/lib/editorHydrationGate.test.ts +++ b/web/src/lib/editorHydrationGate.test.ts @@ -103,6 +103,31 @@ describe('createEditorHydrationGate', () => { assert.deepEqual(gate.decide(token), { apply: false, reason: 'superseded_by_save' }); }); + it('asks for a hydrate resume after editor_open blocked the first apply', () => { + const gate = createEditorHydrationGate(); + gate.setActiveDocId('doc_a'); + const token = gate.beginHydration('doc_a'); + assert.ok(token); + assert.equal(gate.takeHydrationResume(), false); + gate.setEditorOpen(true); + assert.equal(gate.decide(token).reason, 'editor_open'); + gate.setEditorOpen(false); + assert.equal(gate.takeHydrationResume(), true); + assert.equal(gate.takeHydrationResume(), false); + const retry = gate.beginHydration('doc_a'); + assert.ok(retry); + assert.deepEqual(gate.decide(retry), { apply: true, reason: 'ok' }); + }); + + it('asks for a hydrate resume when beginHydration is skipped while editing', () => { + const gate = createEditorHydrationGate(); + gate.setActiveDocId('doc_a'); + gate.setEditorOpen(true); + assert.equal(gate.beginHydration('doc_a'), null); + gate.setEditorOpen(false); + assert.equal(gate.takeHydrationResume(), true); + }); + it('late hydrate after a successful PATCH does not roll back the new baseline', () => { const gate = createEditorHydrationGate(); gate.setActiveDocId('doc_a'); diff --git a/web/src/lib/editorHydrationGate.ts b/web/src/lib/editorHydrationGate.ts index 76d3ce3a..71ef57e3 100644 --- a/web/src/lib/editorHydrationGate.ts +++ b/web/src/lib/editorHydrationGate.ts @@ -53,6 +53,12 @@ export type EditorHydrationGate = { beginHydration: (docId: string) => HydrationToken | null; markSaved: () => void; decide: (token: HydrationToken) => HydrationApplyDecision; + /** + * True when a hydrate was skipped or discarded because the editor was open. + * Consuming the flag lets the caller re-run hydrate after a safe close, + * so a first-load skeleton is not left forever (effect deps may not change). + */ + takeHydrationResume: () => boolean; }; export function createEditorHydrationGate(): EditorHydrationGate { @@ -60,6 +66,7 @@ export function createEditorHydrationGate(): EditorHydrationGate { let saveEpoch = 0; let editorOpen = false; let activeDocId = ''; + let blockedNeedsResume = false; return { setEditorOpen(open) { @@ -72,7 +79,10 @@ export function createEditorHydrationGate(): EditorHydrationGate { activeDocId = id; }, beginHydration(docId) { - if (editorOpen) return null; + if (editorOpen) { + blockedNeedsResume = true; + return null; + } hydrationGen += 1; return { gen: hydrationGen, saveEpoch, docId }; }, @@ -80,13 +90,22 @@ export function createEditorHydrationGate(): EditorHydrationGate { saveEpoch += 1; }, decide(token) { - return decideHydrationApply({ + const decision = decideHydrationApply({ token, currentGen: hydrationGen, currentSaveEpoch: saveEpoch, activeDocId, editorOpen, }); + if (decision.reason === 'editor_open') { + blockedNeedsResume = true; + } + return decision; + }, + takeHydrationResume() { + if (!blockedNeedsResume) return false; + blockedNeedsResume = false; + return true; }, }; } diff --git a/web/src/lib/saveControlledBlockEdit.test.ts b/web/src/lib/saveControlledBlockEdit.test.ts new file mode 100644 index 00000000..89bc034a --- /dev/null +++ b/web/src/lib/saveControlledBlockEdit.test.ts @@ -0,0 +1,207 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { DocumentAST, DocumentBlock } from '../types/block'; +import type { DiffResult, RevisionState } from '../features/documentRevision/types'; +import { + EDITOR_DOC_MISMATCH_MESSAGE, + HEAD_CONFLICT_SAVE_MESSAGE, + STALE_BLOCK_SAVE_MESSAGE, +} from './blockSavePolicy'; +import { saveControlledBlockEdit, type PatchDocumentContent } from './saveControlledBlockEdit'; + +function sampleAst(): DocumentAST { + return { + docId: 'doc_proj6140_intake', + title: '用户初始需求收集表', + taskKey: 'intake', + version: '1', + lifecycleState: 'drafting', + isClaimed: true, + author: '张工', + reviewer: '李主管', + approver: '王总监', + updatedAt: '2026-09-13', + revisionEtag: '1:0', + governanceMode: 'controlled', + rootBlock: { + id: 'root', + type: 'document', + content: '', + children: [ + { id: 'srv_heading', type: 'heading', level: 1, content: '标题' }, + { id: 'srv_para', type: 'paragraph', content: '正文' }, + ], + }, + }; +} + +const updated: DocumentBlock = { + id: 'srv_para', + type: 'paragraph', + content: '正文【已修订】', +}; + +function revisionState(etag: string): RevisionState { + return { + doc_id: 'doc_proj6140_intake', + current_version_no: 1, + version_status: 'open', + baseline_snapshot_id: 1, + head_snapshot_id: 1, + head_revision_no: 1, + head_sha256: 'abc', + manual_change_count: 1, + capabilities: [], + etag, + head_block_map: [ + { id: 'srv_heading', type: 'heading', start_offset: 0, end_offset: 2, order: 0 }, + { id: 'srv_para', type: 'paragraph', start_offset: 3, end_offset: 8, order: 1 }, + ], + }; +} + +const emptyOverlay: DiffResult = { + summary: { inserts: 0, deletes: 0, modifies: 0, moves: 0 }, + hunks: [], +}; + +describe('saveControlledBlockEdit', () => { + it('successful save calls PATCH once with bound docId, hydrate ETag, body and client_block_map', async () => { + const calls: Array<{ + docId: string; + body: { markdown?: string; edit_session_id?: string; client_block_map?: { id: string }[] }; + opts: { etag: string; idempotencyKey: string }; + }> = []; + const patch: PatchDocumentContent = async (docId, body, opts) => { + calls.push({ docId, body, opts }); + return { state: revisionState('1:1'), manual_overlay: emptyOverlay }; + }; + + const result = await saveControlledBlockEdit( + { + editorDocId: 'doc_proj6140_intake', + activeDocId: 'doc_proj6140_intake', + ast: sampleAst(), + updated, + actorUserId: 7, + idempotencyKey: 'idem-ui06', + }, + { patchDocumentContent: patch }, + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0].docId, 'doc_proj6140_intake'); + assert.equal(calls[0].opts.etag, '1:0'); + assert.equal(calls[0].opts.idempotencyKey, 'idem-ui06'); + assert.equal(calls[0].body.edit_session_id, 'ui-7'); + assert.match(calls[0].body.markdown || '', /正文【已修订】/); + assert.deepEqual( + (calls[0].body.client_block_map || []).map((n) => n.id), + ['srv_heading', 'srv_para'], + ); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.closeEditor, true); + assert.equal(result.markSaved, true); + assert.equal(result.ast.revisionEtag, '1:1'); + assert.equal(result.request.etag, '1:0'); + assert.equal(result.request.docId, 'doc_proj6140_intake'); + }); + + it('does not PATCH when editorDocId and activeDocId diverge', async () => { + let calls = 0; + const patch: PatchDocumentContent = async () => { + calls += 1; + return { state: revisionState('1:1'), manual_overlay: emptyOverlay }; + }; + const result = await saveControlledBlockEdit( + { + editorDocId: 'doc_proj6140_intake', + activeDocId: 'doc_proj6140_research', + ast: sampleAst(), + updated, + actorUserId: 7, + }, + { patchDocumentContent: patch }, + ); + assert.equal(calls, 0); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.closeEditor, false); + assert.equal(result.keepEditor, true); + assert.equal(result.reason, 'doc_mismatch'); + assert.equal(result.message, EDITOR_DOC_MISMATCH_MESSAGE); + }); + + it('does not PATCH on stale block id and keeps the draft', async () => { + let calls = 0; + const result = await saveControlledBlockEdit( + { + editorDocId: 'doc_proj6140_intake', + activeDocId: 'doc_proj6140_intake', + ast: sampleAst(), + updated: { ...updated, id: 'blk_from_modal' }, + actorUserId: 7, + }, + { + patchDocumentContent: async () => { + calls += 1; + return { state: revisionState('1:1'), manual_overlay: emptyOverlay }; + }, + }, + ); + assert.equal(calls, 0); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.closeEditor, false); + assert.equal(result.message, STALE_BLOCK_SAVE_MESSAGE); + }); + + it('409 does not close the editor or drop the draft', async () => { + const result = await saveControlledBlockEdit( + { + editorDocId: 'doc_proj6140_intake', + activeDocId: 'doc_proj6140_intake', + ast: sampleAst(), + updated, + actorUserId: 7, + }, + { + patchDocumentContent: async () => { + const err = new Error('conflict') as Error & { code?: string }; + err.code = 'DOCUMENT_HEAD_CONFLICT'; + throw err; + }, + }, + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.closeEditor, false); + assert.equal(result.keepEditor, true); + assert.equal(result.reason, 'head_conflict'); + assert.equal(result.message, HEAD_CONFLICT_SAVE_MESSAGE); + }); + + it('network errors do not close the editor or drop the draft', async () => { + const result = await saveControlledBlockEdit( + { + editorDocId: 'doc_proj6140_intake', + activeDocId: 'doc_proj6140_intake', + ast: sampleAst(), + updated, + actorUserId: 7, + }, + { + patchDocumentContent: async () => { + throw new Error('network down'); + }, + }, + ); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.closeEditor, false); + assert.equal(result.keepEditor, true); + assert.equal(result.reason, 'request_failed'); + assert.match(result.message, /network down/); + }); +}); diff --git a/web/src/lib/saveControlledBlockEdit.ts b/web/src/lib/saveControlledBlockEdit.ts new file mode 100644 index 00000000..62c38269 --- /dev/null +++ b/web/src/lib/saveControlledBlockEdit.ts @@ -0,0 +1,148 @@ +import { patchDocumentContent } from '../api/documentRevision'; +import { blockASTToMarkdown } from '../engine/blockToMarkdown'; +import { recomputeNumbering } from '../engine/autoNumber'; +import { applyHeadBlockMap, astToClientBlockMap } from '../features/documentRevision/overlayMatch'; +import type { DiffResult, PatchContentBody, RevisionState } from '../features/documentRevision/types'; +import type { DocumentAST, DocumentBlock } from '../types/block'; +import { resolvePatchEtag } from './revisionEtag'; +import { + decideEditorDocBinding, + interpretBlockSaveError, + resolveSaveTargetBlock, +} from './blockSavePolicy'; + +export type PatchDocumentContent = ( + docId: string, + body: PatchContentBody, + opts: { etag: string; idempotencyKey: string }, +) => Promise<{ state: RevisionState; manual_overlay: DiffResult; conflict?: unknown }>; + +export type SaveControlledBlockEditInput = { + editorDocId?: string | null; + activeDocId: string; + ast: DocumentAST; + updated: DocumentBlock; + actorUserId: number | string; + idempotencyKey?: string; +}; + +export type SaveControlledBlockEditSuccess = { + ok: true; + closeEditor: true; + markSaved: true; + ast: DocumentAST; + overlay: DiffResult | null; + request: { + docId: string; + etag: string; + body: PatchContentBody; + idempotencyKey: string; + }; +}; + +export type SaveControlledBlockEditFailure = { + ok: false; + closeEditor: false; + keepEditor: true; + reason: 'doc_mismatch' | 'block_not_found' | 'head_conflict' | 'request_failed' | 'etag_missing'; + message: string; +}; + +export type SaveControlledBlockEditResult = + | SaveControlledBlockEditSuccess + | SaveControlledBlockEditFailure; + +function newIdempotencyKey(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `edit-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export async function saveControlledBlockEdit( + input: SaveControlledBlockEditInput, + deps?: { patchDocumentContent?: PatchDocumentContent }, +): Promise { + const bound = decideEditorDocBinding({ + editorDocId: input.editorDocId, + activeDocId: input.activeDocId, + }); + if (!bound.ok) { + return { + ok: false, + closeEditor: false, + keepEditor: true, + reason: 'doc_mismatch', + message: bound.message, + }; + } + + const children = input.ast.rootBlock.children || []; + const target = resolveSaveTargetBlock(children, input.updated); + if (!target.ok) { + return { + ok: false, + closeEditor: false, + keepEditor: true, + reason: 'block_not_found', + message: target.message, + }; + } + + const nextRoot = { + ...input.ast.rootBlock, + children: children.map((child, i) => (i === target.index ? input.updated : { ...child })), + }; + recomputeNumbering(nextRoot); + const nextAST: DocumentAST = { ...input.ast, rootBlock: nextRoot }; + + let etag: string; + try { + etag = resolvePatchEtag({ + revisionEtag: nextAST.revisionEtag ?? input.ast.revisionEtag, + governanceMode: nextAST.governanceMode ?? input.ast.governanceMode, + }); + } catch (e) { + const err = e as Error; + return { + ok: false, + closeEditor: false, + keepEditor: true, + reason: 'etag_missing', + message: interpretBlockSaveError(err).conflict, + }; + } + + const body: PatchContentBody = { + markdown: blockASTToMarkdown(nextAST), + edit_session_id: `ui-${input.actorUserId}`, + client_block_map: astToClientBlockMap(nextAST), + }; + const idempotencyKey = input.idempotencyKey || newIdempotencyKey(); + const patch = deps?.patchDocumentContent ?? patchDocumentContent; + + try { + const res = await patch(input.activeDocId, body, { etag, idempotencyKey }); + const aligned = applyHeadBlockMap(nextAST, res.state?.head_block_map); + if (res.state?.etag) { + aligned.revisionEtag = res.state.etag; + } + return { + ok: true, + closeEditor: true, + markSaved: true, + ast: aligned, + overlay: res.manual_overlay || null, + request: { docId: input.activeDocId, etag, body, idempotencyKey }, + }; + } catch (e) { + const err = e as Error & { code?: string }; + return { + ok: false, + closeEditor: false, + keepEditor: true, + reason: err.code === 'DOCUMENT_HEAD_CONFLICT' ? 'head_conflict' : 'request_failed', + message: interpretBlockSaveError(err).conflict, + }; + } +}