Files
Agent-System/web/src/hooks/useServerDocumentSync.ts
T
weichenandCursor fd4b95030f feat(web): score write-path matches by workflow relation
The document list now reports WORKFLOW_SCOPE_MODE, and weighted mode only blocks a cross-workflow write when that title outscores the active workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 09:22:13 +00:00

194 lines
7.0 KiB
TypeScript

import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { listFreeformDocs } from '../api/freeformDoc';
import { listProjectWorkflowDocumentsDetailed } 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;
onActiveWorkflowKey?: (key: string) => void;
onWorkflowScopeMode?: (mode: 'strict' | 'weighted') => void;
}
export function useServerDocumentSync(input: ServerDocumentSyncInput): void {
const {
session,
sessionValidated,
currentProjectId,
currentUser,
workflowDocsEpoch,
setDeliverables,
setDocCache,
deliverablesRef,
activeDocIdRef,
editorHydrationGateRef,
applyControlledInvalidationPlan,
onActiveWorkflowKey,
onWorkflowScopeMode,
} = 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 listed = await listProjectWorkflowDocumentsDetailed(currentProjectId);
if (!active) return;
if (listed.active_workflow_key) onActiveWorkflowKey?.(listed.active_workflow_key);
if (listed.workflow_scope_mode) onWorkflowScopeMode?.(listed.workflow_scope_mode);
const workflowDocs = listed.documents;
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,
onActiveWorkflowKey,
onWorkflowScopeMode,
]);
useEffect(() => {
if (!session || sessionValidated !== true || currentProjectId <= 0) return;
let cancelled = false;
const syncLeases = async () => {
const listed = await listProjectWorkflowDocumentsDetailed(currentProjectId).catch(() => null);
const docs = listed?.documents ?? [];
if (listed?.active_workflow_key) onActiveWorkflowKey?.(listed.active_workflow_key);
if (listed?.workflow_scope_mode) onWorkflowScopeMode?.(listed.workflow_scope_mode);
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, onActiveWorkflowKey, onWorkflowScopeMode]);
}