fix(web): keep block ids and drafts across editor hydration races

Background GET rebuilt markdown AST with Math.random ids and dropped
head_block_map, so save closed the modal without PATCH. Align hydrate
to the ledger map, gate in-flight responses, and fail-closed on stale ids.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 19:54:36 +00:00
co-authored by Cursor
parent 4c465d2cb0
commit 1b04d962d1
13 changed files with 693 additions and 35 deletions
@@ -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(编辑会话已结束,可接受)
+8
View File
@@ -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)
**结论**: 真实会话被诊断探针污染;客户端名词匹配令只读总结请求误启动写任务;错误动作与文档租约状态刷新后无法恢复。
+6 -4
View File
@@ -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/<slug>` 或 `fix/<slug>`,**禁止** `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) |
@@ -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**
@@ -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/`
+1
View File
@@ -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) | 对话写意图、刷新可恢复任务状态、文档占用可见性与诊断隔离 |
+76 -29
View File
@@ -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<DocumentBlock | null>(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<LoginResult | null>(() => 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<DocumentBlock | null>(null);
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(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() {
<BlockRenderer
ast={docAST}
currentRole={currentDocRole}
onEditBlock={setEditingBlock}
onEditBlock={openBlockEditor}
onInsertBlock={handleInsertBlock}
onMoveBlock={handleMoveBlock}
onDeleteBlock={handleDeleteBlock}
@@ -4278,7 +4325,7 @@ export default function App() {
block={editingBlock}
actorLabel={`${currentUser.name} (${currentUser.position || '成员'})`}
onSave={handleSaveBlockEdit}
onClose={() => setEditingBlock(null)}
onClose={closeBlockEditor}
/>
)}
+66
View File
@@ -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, /仍保留/);
});
});
+55
View File
@@ -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';
}
+92
View File
@@ -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' });
});
});
+13 -2
View File
@@ -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<RevisionState, 'etag'> &
Partial<Pick<RevisionState, 'head_block_map'>>;
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<HydrateRevisionState>;
},
): Promise<DocumentAST | null> {
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;
+120
View File
@@ -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' });
});
});
+92
View File
@@ -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,
});
},
};
}