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>
194 lines
7.0 KiB
TypeScript
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]);
|
|
}
|