Files
Agent-System/web/src/hooks/useServerDocumentSync.ts
T
weichenandCursor 22ea4c31ed 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>
2026-09-19 11:33:03 +00:00

182 lines
6.3 KiB
TypeScript

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]);
}