Files
Agent-System/web/src/lib/saveControlledBlockEdit.ts
T
weichenandCursor 9b43bd6a73 fix(web): bind editor saves to the opened doc and real PATCH
Drop test-only ETag helpers. App now calls saveControlledBlockEdit,
which PATCHes once on the bound editor docId or fail-closes. Resume
hydrate after a blocked first load, and refuse silent doc switches.

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

149 lines
4.3 KiB
TypeScript

import { patchDocumentContent } from '../api/documentRevision';
import { blockASTToMarkdown } from '../engine/blockToMarkdown';
import { recomputeNumbering } from '../engine/autoNumber';
import { applyHeadBlockMap, astToClientBlockMap } from '../features/documentRevision/overlayMatch';
import type { DiffResult, PatchContentBody, RevisionState } from '../features/documentRevision/types';
import type { DocumentAST, DocumentBlock } from '../types/block';
import { resolvePatchEtag } from './revisionEtag';
import {
decideEditorDocBinding,
interpretBlockSaveError,
resolveSaveTargetBlock,
} from './blockSavePolicy';
export type PatchDocumentContent = (
docId: string,
body: PatchContentBody,
opts: { etag: string; idempotencyKey: string },
) => Promise<{ state: RevisionState; manual_overlay: DiffResult; conflict?: unknown }>;
export type SaveControlledBlockEditInput = {
editorDocId?: string | null;
activeDocId: string;
ast: DocumentAST;
updated: DocumentBlock;
actorUserId: number | string;
idempotencyKey?: string;
};
export type SaveControlledBlockEditSuccess = {
ok: true;
closeEditor: true;
markSaved: true;
ast: DocumentAST;
overlay: DiffResult | null;
request: {
docId: string;
etag: string;
body: PatchContentBody;
idempotencyKey: string;
};
};
export type SaveControlledBlockEditFailure = {
ok: false;
closeEditor: false;
keepEditor: true;
reason: 'doc_mismatch' | 'block_not_found' | 'head_conflict' | 'request_failed' | 'etag_missing';
message: string;
};
export type SaveControlledBlockEditResult =
| SaveControlledBlockEditSuccess
| SaveControlledBlockEditFailure;
function newIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export async function saveControlledBlockEdit(
input: SaveControlledBlockEditInput,
deps?: { patchDocumentContent?: PatchDocumentContent },
): Promise<SaveControlledBlockEditResult> {
const bound = decideEditorDocBinding({
editorDocId: input.editorDocId,
activeDocId: input.activeDocId,
});
if (!bound.ok) {
return {
ok: false,
closeEditor: false,
keepEditor: true,
reason: 'doc_mismatch',
message: bound.message,
};
}
const children = input.ast.rootBlock.children || [];
const target = resolveSaveTargetBlock(children, input.updated);
if (!target.ok) {
return {
ok: false,
closeEditor: false,
keepEditor: true,
reason: 'block_not_found',
message: target.message,
};
}
const nextRoot = {
...input.ast.rootBlock,
children: children.map((child, i) => (i === target.index ? input.updated : { ...child })),
};
recomputeNumbering(nextRoot);
const nextAST: DocumentAST = { ...input.ast, rootBlock: nextRoot };
let etag: string;
try {
etag = resolvePatchEtag({
revisionEtag: nextAST.revisionEtag ?? input.ast.revisionEtag,
governanceMode: nextAST.governanceMode ?? input.ast.governanceMode,
});
} catch (e) {
const err = e as Error;
return {
ok: false,
closeEditor: false,
keepEditor: true,
reason: 'etag_missing',
message: interpretBlockSaveError(err).conflict,
};
}
const body: PatchContentBody = {
markdown: blockASTToMarkdown(nextAST),
edit_session_id: `ui-${input.actorUserId}`,
client_block_map: astToClientBlockMap(nextAST),
};
const idempotencyKey = input.idempotencyKey || newIdempotencyKey();
const patch = deps?.patchDocumentContent ?? patchDocumentContent;
try {
const res = await patch(input.activeDocId, body, { etag, idempotencyKey });
const aligned = applyHeadBlockMap(nextAST, res.state?.head_block_map);
if (res.state?.etag) {
aligned.revisionEtag = res.state.etag;
}
return {
ok: true,
closeEditor: true,
markSaved: true,
ast: aligned,
overlay: res.manual_overlay || null,
request: { docId: input.activeDocId, etag, body, idempotencyKey },
};
} catch (e) {
const err = e as Error & { code?: string };
return {
ok: false,
closeEditor: false,
keepEditor: true,
reason: err.code === 'DOCUMENT_HEAD_CONFLICT' ? 'head_conflict' : 'request_failed',
message: interpretBlockSaveError(err).conflict,
};
}
}