refactor(web): sync server documents from one hook

Keep freeform append, workflow reconcile, and lease polling together so a failed list cannot wipe local docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 11:33:03 +00:00
co-authored by Cursor
parent 113dea4ac5
commit 22ea4c31ed
4 changed files with 206 additions and 132 deletions
@@ -46,7 +46,7 @@ updated: 2026-09-19
| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` |
| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 |
| M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 |
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。工作台主体仍在 `App.tsx` |
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。自由文档、工作流对账和编辑租约轮询在 `useServerDocumentSync`。工作台主体仍在 `App.tsx` |
| H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 |
仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。公式引擎已离开入口包(`index-j0BIrjIR.js` 737.38 kB,br 170,451 B,包内无 katex),U3 仍缺浏览器瀑布,不关。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
+9 -131
View File
@@ -12,7 +12,6 @@ import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActi
import {
createFreeformDoc,
saveMessageToPrivate,
listFreeformDocs,
fetchFreeformDoc,
shareFreeformDoc,
unshareFreeformDoc,
@@ -87,7 +86,6 @@ import {
planControlledContentInvalidation,
type ControlledInvalidationPlan,
} from './lib/controlledContentInvalidation';
import { applyLeaseUpdates, sameLease } from './lib/deliverableLeaseSync';
import {
createEditorHydrationGate,
} from './lib/editorHydrationGate';
@@ -196,6 +194,7 @@ import {
import { useChatWindow } from './hooks/useChatWindow';
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration';
import { useServerDocumentSync } from './hooks/useServerDocumentSync';
import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence';
import { useTurnRuntime } from './hooks/useTurnRuntime';
import { useTaskRunStream } from './hooks/useTaskRunStream';
@@ -1188,140 +1187,19 @@ export default function App() {
}
};
// 挂载或项目切换时,拉取服务端的自由文档(我的私密与项目共享)并同步至 deliverables
useEffect(() => {
if (!session || sessionValidated !== true || !currentProjectId) return;
let active = true;
const fetchDocs = async () => {
try {
const [privateSummaries, sharedSummaries] = await Promise.all([
listFreeformDocs('my_private', currentProjectId).catch(() => []),
listFreeformDocs('project_shared', currentProjectId).catch(() => []),
]);
if (!active) return;
const allSummaries = [...privateSummaries, ...sharedSummaries];
if (allSummaries.length === 0) return;
setDeliverables((prev) => {
let updated = [...prev];
for (const s of allSummaries) {
const exists = updated.find((d) => d.docId === s.doc_id);
const category = s.space === 'my_private' ? 'private' : 'shared';
if (!exists) {
updated.unshift({
docId: s.doc_id,
projectId: s.project_id || currentProjectId,
taskKey: 'freeform_doc',
title: s.title,
stage: 'V0.1',
category,
creatorId: s.owner_user_id || currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16),
governanceMode: 'freeform',
space: s.space,
});
}
}
return updated;
});
} catch (e) {
console.warn('获取服务端自由文档列表异常:', e);
}
};
fetchDocs();
return () => {
active = false;
};
}, [session, sessionValidated, currentProjectId, currentUser.id]);
// 挂载或项目切换时,拉取服务端工作流受控文档并覆盖本地模板状态
useEffect(() => {
if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return;
let active = true;
const syncWorkflowDocs = async () => {
try {
// 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。
const workflowDocs = await listProjectWorkflowDocuments(currentProjectId);
if (!active) return;
setDeliverables((prev) =>
mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser),
);
applyControlledInvalidationPlan(
planControlledContentInvalidation({
reason: 'list_sync',
candidateIds: controlledHydrateCandidateIds(
deliverablesRef.current,
currentProjectId,
),
activeDocId: activeDocIdRef.current,
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
}),
);
} catch (e) {
console.warn('[deliverables] 工作流受控文档同步失败:', e);
}
};
void syncWorkflowDocs();
return () => {
active = false;
};
}, [
useServerDocumentSync({
session,
sessionValidated,
currentProjectId,
currentUser.id,
currentUser.name,
currentUser,
workflowDocsEpoch,
setDeliverables,
setDocCache,
deliverablesRef,
activeDocIdRef,
editorHydrationGateRef,
applyControlledInvalidationPlan,
]);
// 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。
useEffect(() => {
if (!session || sessionValidated !== true || currentProjectId <= 0) return;
let cancelled = false;
const syncLeases = async () => {
const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []);
if (cancelled || docs.length === 0) return;
const leases = new Map(
docs.map((doc) => [
doc.doc_id,
doc.lease_holder_type
? {
holderType: doc.lease_holder_type,
holderId: doc.lease_holder_id,
expiresAt: doc.lease_expires_at,
heartbeatAt: doc.lease_heartbeat_at,
}
: undefined,
]),
);
setDeliverables((prev) => applyLeaseUpdates(prev, leases, currentProjectId));
setDocCache((prev) => {
let changed = false;
const next = { ...prev };
leases.forEach((editLease, docId) => {
if (!next[docId]) return;
if (sameLease(next[docId].editLease, editLease)) return;
next[docId] = { ...next[docId], editLease };
changed = true;
});
return changed ? next : prev;
});
};
const timer = window.setInterval(() => void syncLeases(), 5000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [session, sessionValidated, currentProjectId]);
});
// 模态弹窗与侧栏
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('server document list sync lives in one hook', () => {
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
const hook = readFileSync(new URL('./useServerDocumentSync.ts', import.meta.url), 'utf8');
assert.equal((app.match(/useServerDocumentSync\(/g) || []).length, 1);
assert.equal(app.includes('获取服务端自由文档列表异常'), false);
assert.equal(app.includes('工作流受控文档同步失败'), false);
assert.equal(hook.includes('listFreeformDocs'), true);
assert.equal(hook.includes('mergeWorkflowDocumentsFromServer'), true);
assert.equal(hook.includes('applyLeaseUpdates'), true);
assert.equal(hook.includes('失败不得调用 merge'), true);
});
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { listFreeformDocs } from '../api/freeformDoc';
import { listProjectWorkflowDocuments } from '../api/projectDocuments';
import { applyLeaseUpdates, sameLease } from '../lib/deliverableLeaseSync';
import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync';
import {
controlledHydrateCandidateIds,
planControlledContentInvalidation,
type ControlledInvalidationPlan,
} from '../lib/controlledContentInvalidation';
import {
createEditorHydrationGate,
} from '../lib/editorHydrationGate';
import type { LoginResult } from '../types/auth';
import type { DocumentAST } from '../types/block';
import type { UserProfile } from '../types/docgov';
import type { DeliverableItem } from '../types/workbench';
type Gate = ReturnType<typeof createEditorHydrationGate>;
export interface ServerDocumentSyncInput {
session: LoginResult | null;
sessionValidated: boolean | null;
currentProjectId: number;
currentUser: UserProfile;
workflowDocsEpoch: number;
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
deliverablesRef: MutableRefObject<DeliverableItem[]>;
activeDocIdRef: MutableRefObject<string>;
editorHydrationGateRef: MutableRefObject<Gate>;
applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void;
}
export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
const {
session,
sessionValidated,
currentProjectId,
currentUser,
workflowDocsEpoch,
setDeliverables,
setDocCache,
deliverablesRef,
activeDocIdRef,
editorHydrationGateRef,
applyControlledInvalidationPlan,
} = input;
useEffect(() => {
if (!session || sessionValidated !== true || !currentProjectId) return;
let active = true;
const fetchDocs = async () => {
try {
const [privateSummaries, sharedSummaries] = await Promise.all([
listFreeformDocs('my_private', currentProjectId).catch(() => []),
listFreeformDocs('project_shared', currentProjectId).catch(() => []),
]);
if (!active) return;
const allSummaries = [...privateSummaries, ...sharedSummaries];
if (allSummaries.length === 0) return;
setDeliverables((prev) => {
let updated = [...prev];
for (const s of allSummaries) {
const exists = updated.find((d) => d.docId === s.doc_id);
const category = s.space === 'my_private' ? 'private' : 'shared';
if (!exists) {
updated.unshift({
docId: s.doc_id,
projectId: s.project_id || currentProjectId,
taskKey: 'freeform_doc',
title: s.title,
stage: 'V0.1',
category,
creatorId: s.owner_user_id || currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
updatedAt: (s.updated_at || s.created_at || '').replace('T', ' ').substring(0, 16),
governanceMode: 'freeform',
space: s.space,
});
}
}
return updated;
});
} catch (e) {
console.warn('获取服务端自由文档列表异常:', e);
}
};
fetchDocs();
return () => {
active = false;
};
}, [session, sessionValidated, currentProjectId, currentUser.id]);
useEffect(() => {
if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return;
let active = true;
const syncWorkflowDocs = async () => {
try {
// 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。
const workflowDocs = await listProjectWorkflowDocuments(currentProjectId);
if (!active) return;
setDeliverables((prev) =>
mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser),
);
applyControlledInvalidationPlan(
planControlledContentInvalidation({
reason: 'list_sync',
candidateIds: controlledHydrateCandidateIds(
deliverablesRef.current,
currentProjectId,
),
activeDocId: activeDocIdRef.current,
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
}),
);
} catch (e) {
console.warn('[deliverables] 工作流受控文档同步失败:', e);
}
};
void syncWorkflowDocs();
return () => {
active = false;
};
}, [
session,
sessionValidated,
currentProjectId,
currentUser.id,
currentUser.name,
workflowDocsEpoch,
applyControlledInvalidationPlan,
]);
useEffect(() => {
if (!session || sessionValidated !== true || currentProjectId <= 0) return;
let cancelled = false;
const syncLeases = async () => {
const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []);
if (cancelled || docs.length === 0) return;
const leases = new Map(
docs.map((doc) => [
doc.doc_id,
doc.lease_holder_type
? {
holderType: doc.lease_holder_type,
holderId: doc.lease_holder_id,
expiresAt: doc.lease_expires_at,
heartbeatAt: doc.lease_heartbeat_at,
}
: undefined,
]),
);
setDeliverables((prev) => applyLeaseUpdates(prev, leases, currentProjectId));
setDocCache((prev) => {
let changed = false;
const next = { ...prev };
leases.forEach((editLease, docId) => {
if (!next[docId]) return;
if (sameLease(next[docId].editLease, editLease)) return;
next[docId] = { ...next[docId], editLease };
changed = true;
});
return changed ? next : prev;
});
};
const timer = window.setInterval(() => void syncLeases(), 5000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [session, sessionValidated, currentProjectId]);
}