@superdoc/sdk 2.10.0-next.5 → 2.10.0-next.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/actions.cjs +135 -57
- package/dist/agent/actions.js +136 -58
- package/dist/agent/doc-snapshot.cjs +183 -40
- package/dist/agent/doc-snapshot.d.ts +11 -0
- package/dist/agent/doc-snapshot.js +181 -40
- package/dist/agent/runtime.cjs +54 -6
- package/dist/agent/runtime.js +55 -7
- package/dist/runtime/sdk-version.generated.cjs +1 -1
- package/dist/runtime/sdk-version.generated.d.ts +1 -1
- package/dist/runtime/sdk-version.generated.js +1 -1
- package/package.json +7 -6
|
@@ -99,6 +99,48 @@ function includesDomain(requested, domain) {
|
|
|
99
99
|
function truncateBlockText(value, limit) {
|
|
100
100
|
return limit == null ? value : value.slice(0, limit);
|
|
101
101
|
}
|
|
102
|
+
function populateTableCellsFromExtract(tables, extractRaw, tableOrdinalBase = 0) {
|
|
103
|
+
const extractRec = asRecord(extractRaw);
|
|
104
|
+
const extractBlocks = Array.isArray(extractRec?.blocks) ? extractRec.blocks : [];
|
|
105
|
+
const cellsByTableNodeId = new Map();
|
|
106
|
+
for (const block of extractBlocks) {
|
|
107
|
+
const rec = asRecord(block);
|
|
108
|
+
const tableContext = asRecord(rec?.tableContext);
|
|
109
|
+
if (!rec || !tableContext)
|
|
110
|
+
continue;
|
|
111
|
+
const tableOrdinal = asNumber(tableContext.tableOrdinal, -1);
|
|
112
|
+
const rowIndex = asNumber(tableContext.rowIndex, -1);
|
|
113
|
+
const columnIndex = asNumber(tableContext.columnIndex, -1);
|
|
114
|
+
if (tableOrdinal < 0 || rowIndex < 0 || columnIndex < 0)
|
|
115
|
+
continue;
|
|
116
|
+
const table = tables[tableOrdinal - tableOrdinalBase];
|
|
117
|
+
if (!table)
|
|
118
|
+
continue;
|
|
119
|
+
const key = `${rowIndex}:${columnIndex}`;
|
|
120
|
+
const text = asString(rec.text);
|
|
121
|
+
const nodeId = asString(rec.nodeId) || undefined;
|
|
122
|
+
const cellMap = cellsByTableNodeId.get(table.nodeId) ??
|
|
123
|
+
new Map();
|
|
124
|
+
const existing = cellMap.get(key);
|
|
125
|
+
cellMap.set(key, {
|
|
126
|
+
rowIndex,
|
|
127
|
+
columnIndex,
|
|
128
|
+
text: existing == null || text.length === 0
|
|
129
|
+
? (existing?.text ?? text)
|
|
130
|
+
: existing.text.length === 0
|
|
131
|
+
? text
|
|
132
|
+
: `${existing.text}\n${text}`,
|
|
133
|
+
nodeId: existing?.nodeId ?? nodeId,
|
|
134
|
+
});
|
|
135
|
+
cellsByTableNodeId.set(table.nodeId, cellMap);
|
|
136
|
+
}
|
|
137
|
+
for (const table of tables) {
|
|
138
|
+
const cellMap = cellsByTableNodeId.get(table.nodeId);
|
|
139
|
+
if (!cellMap)
|
|
140
|
+
continue;
|
|
141
|
+
table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
102
144
|
/**
|
|
103
145
|
* Build a deterministic snapshot of a document. The snapshot uses only
|
|
104
146
|
* read-mode operations from the generated contract — it never mutates state,
|
|
@@ -404,46 +446,7 @@ async function buildDocumentSnapshot(doc, options = {}) {
|
|
|
404
446
|
const extractFn = maybeMethod(doc, ['extract']);
|
|
405
447
|
if (tables.length > 0 && extractFn) {
|
|
406
448
|
const extractRaw = await safeCall(() => extractFn({}), null, recordError('extract'));
|
|
407
|
-
|
|
408
|
-
const extractBlocks = Array.isArray(extractRec?.blocks) ? extractRec.blocks : [];
|
|
409
|
-
const cellsByTableNodeId = new Map();
|
|
410
|
-
for (const block of extractBlocks) {
|
|
411
|
-
const rec = asRecord(block);
|
|
412
|
-
const tableContext = asRecord(rec?.tableContext);
|
|
413
|
-
if (!rec || !tableContext)
|
|
414
|
-
continue;
|
|
415
|
-
const tableOrdinal = asNumber(tableContext.tableOrdinal, -1);
|
|
416
|
-
const rowIndex = asNumber(tableContext.rowIndex, -1);
|
|
417
|
-
const columnIndex = asNumber(tableContext.columnIndex, -1);
|
|
418
|
-
if (tableOrdinal < 0 || rowIndex < 0 || columnIndex < 0)
|
|
419
|
-
continue;
|
|
420
|
-
const table = tables[tableOrdinal - precedingTableCount];
|
|
421
|
-
if (!table)
|
|
422
|
-
continue;
|
|
423
|
-
const key = `${rowIndex}:${columnIndex}`;
|
|
424
|
-
const text = asString(rec.text);
|
|
425
|
-
const nodeId = asString(rec.nodeId) || undefined;
|
|
426
|
-
const cellMap = cellsByTableNodeId.get(table.nodeId) ??
|
|
427
|
-
new Map();
|
|
428
|
-
const existing = cellMap.get(key);
|
|
429
|
-
cellMap.set(key, {
|
|
430
|
-
rowIndex,
|
|
431
|
-
columnIndex,
|
|
432
|
-
text: existing == null || text.length === 0
|
|
433
|
-
? (existing?.text ?? text)
|
|
434
|
-
: existing.text.length === 0
|
|
435
|
-
? text
|
|
436
|
-
: `${existing.text}\n${text}`,
|
|
437
|
-
nodeId: existing?.nodeId ?? nodeId,
|
|
438
|
-
});
|
|
439
|
-
cellsByTableNodeId.set(table.nodeId, cellMap);
|
|
440
|
-
}
|
|
441
|
-
for (const table of tables) {
|
|
442
|
-
const cellMap = cellsByTableNodeId.get(table.nodeId);
|
|
443
|
-
if (!cellMap)
|
|
444
|
-
continue;
|
|
445
|
-
table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
|
|
446
|
-
}
|
|
449
|
+
populateTableCellsFromExtract(tables, extractRaw, precedingTableCount);
|
|
447
450
|
}
|
|
448
451
|
// Optionally enrich table cells with per-run formatting (opt-in; one
|
|
449
452
|
// query.match per cell, bounded) so a reader can match a cell's pattern.
|
|
@@ -748,6 +751,144 @@ async function buildDocumentSnapshot(doc, options = {}) {
|
|
|
748
751
|
...(finds ? { finds } : {}),
|
|
749
752
|
};
|
|
750
753
|
}
|
|
754
|
+
class MutationSnapshotError extends Error {
|
|
755
|
+
code;
|
|
756
|
+
constructor(code, message) {
|
|
757
|
+
super(message);
|
|
758
|
+
this.code = code;
|
|
759
|
+
this.name = 'MutationSnapshotError';
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const MUTATION_BLOCK_PAGE_SIZE = 1000;
|
|
763
|
+
const MUTATION_BLOCK_PAGE_CONCURRENCY = 4;
|
|
764
|
+
const MUTATION_NON_TABLE_DOMAINS = [
|
|
765
|
+
'blocks',
|
|
766
|
+
'lists',
|
|
767
|
+
'comments',
|
|
768
|
+
'trackedChanges',
|
|
769
|
+
'sections',
|
|
770
|
+
'headerFooters',
|
|
771
|
+
'styles',
|
|
772
|
+
'contentControls',
|
|
773
|
+
'fields',
|
|
774
|
+
'hyperlinks',
|
|
775
|
+
'bookmarks',
|
|
776
|
+
'permissionRanges',
|
|
777
|
+
'images',
|
|
778
|
+
];
|
|
779
|
+
/**
|
|
780
|
+
* Build the complete block index used to authorize mutations. Inspection
|
|
781
|
+
* snapshots stay windowed; mutation selectors must not treat that presentation
|
|
782
|
+
* window as the end of the document. Reads remain bounded and the revision is
|
|
783
|
+
* checked before the combined snapshot is returned.
|
|
784
|
+
*/
|
|
785
|
+
async function buildMutationSnapshot(doc, options = {}) {
|
|
786
|
+
const requestedDomains = options.includeDomains == null || options.includeDomains.length === 0 ? null : new Set(options.includeDomains);
|
|
787
|
+
const includeTables = requestedDomains == null || requestedDomains.has('tables');
|
|
788
|
+
const firstDomains = requestedDomains == null
|
|
789
|
+
? MUTATION_NON_TABLE_DOMAINS
|
|
790
|
+
: [...requestedDomains].filter((domain) => domain !== 'tables').concat('blocks');
|
|
791
|
+
const first = await buildDocumentSnapshot(doc, {
|
|
792
|
+
includeDomains: firstDomains,
|
|
793
|
+
blockOffset: 0,
|
|
794
|
+
blockLimit: MUTATION_BLOCK_PAGE_SIZE,
|
|
795
|
+
});
|
|
796
|
+
assertMutationBlockRead(first, 0);
|
|
797
|
+
const total = first.counts.blocks;
|
|
798
|
+
if (first.blocks.length > total) {
|
|
799
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: document reports ${total} blocks but returned ${first.blocks.length}`);
|
|
800
|
+
}
|
|
801
|
+
if (first.blocks.length === 0 && total > 0) {
|
|
802
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: document reports ${total} blocks but returned none`);
|
|
803
|
+
}
|
|
804
|
+
const blocks = [...first.blocks];
|
|
805
|
+
const diagnostics = [...first.diagnostics];
|
|
806
|
+
const pageStride = first.blocks.length || MUTATION_BLOCK_PAGE_SIZE;
|
|
807
|
+
const offsets = first.blocks.length === total
|
|
808
|
+
? []
|
|
809
|
+
: Array.from({ length: Math.ceil((total - pageStride) / pageStride) }, (_, index) => pageStride * (index + 1));
|
|
810
|
+
for (let index = 0; index < offsets.length; index += MUTATION_BLOCK_PAGE_CONCURRENCY) {
|
|
811
|
+
const batchOffsets = offsets.slice(index, index + MUTATION_BLOCK_PAGE_CONCURRENCY);
|
|
812
|
+
const pages = await Promise.all(batchOffsets.map((blockOffset) => buildDocumentSnapshot(doc, {
|
|
813
|
+
includeDomains: ['blocks'],
|
|
814
|
+
blockOffset,
|
|
815
|
+
blockLimit: pageStride,
|
|
816
|
+
})));
|
|
817
|
+
for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
|
|
818
|
+
const page = pages[pageIndex];
|
|
819
|
+
const blockOffset = batchOffsets[pageIndex];
|
|
820
|
+
assertMutationBlockRead(page, blockOffset);
|
|
821
|
+
assertMutationSnapshotRevision(first.revision, page.revision);
|
|
822
|
+
if (page.blocks.length === 0) {
|
|
823
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: block pagination stopped at ${blockOffset} of ${total} blocks`);
|
|
824
|
+
}
|
|
825
|
+
blocks.push(...page.blocks);
|
|
826
|
+
diagnostics.push(...page.diagnostics);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (blocks.length !== total) {
|
|
830
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: expected ${total} blocks but materialized ${blocks.length}`);
|
|
831
|
+
}
|
|
832
|
+
const tables = includeTables ? await buildMutationTables(doc, blocks, diagnostics) : [];
|
|
833
|
+
const finalIdentity = await buildDocumentSnapshot(doc, { countsOnly: true });
|
|
834
|
+
assertMutationSnapshotRevision(first.revision, finalIdentity.revision);
|
|
835
|
+
return {
|
|
836
|
+
...first,
|
|
837
|
+
blocks,
|
|
838
|
+
tables,
|
|
839
|
+
diagnostics,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
async function buildMutationTables(doc, blocks, diagnostics) {
|
|
843
|
+
const tableBlocks = blocks.filter((block) => block.nodeType === 'table');
|
|
844
|
+
const tables = tableBlocks.map((block, index) => ({
|
|
845
|
+
nodeId: block.nodeId,
|
|
846
|
+
ordinal: index + 1,
|
|
847
|
+
rows: 0,
|
|
848
|
+
columns: 0,
|
|
849
|
+
cells: [],
|
|
850
|
+
}));
|
|
851
|
+
const getTable = maybeMethod(doc, ['tables', 'get']);
|
|
852
|
+
if (getTable) {
|
|
853
|
+
for (let index = 0; index < tables.length; index += MUTATION_BLOCK_PAGE_CONCURRENCY) {
|
|
854
|
+
const batch = tables.slice(index, index + MUTATION_BLOCK_PAGE_CONCURRENCY);
|
|
855
|
+
await Promise.all(batch.map(async (table) => {
|
|
856
|
+
try {
|
|
857
|
+
const raw = asRecord(await getTable({ nodeId: table.nodeId }));
|
|
858
|
+
table.rows = asNumber(raw?.rows);
|
|
859
|
+
table.columns = asNumber(raw?.columns);
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
diagnostics.push({
|
|
863
|
+
section: `tables.get:${table.nodeId}`,
|
|
864
|
+
message: error instanceof Error ? error.message : String(error),
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const extract = maybeMethod(doc, ['extract']);
|
|
871
|
+
if (tables.length > 0 && extract) {
|
|
872
|
+
try {
|
|
873
|
+
populateTableCellsFromExtract(tables, await extract({}));
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
diagnostics.push({ section: 'extract', message: error instanceof Error ? error.message : String(error) });
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return tables;
|
|
880
|
+
}
|
|
881
|
+
function assertMutationBlockRead(snapshot, blockOffset) {
|
|
882
|
+
const failure = snapshot.diagnostics.find((diagnostic) => diagnostic.section === 'blocks.list');
|
|
883
|
+
if (!failure)
|
|
884
|
+
return;
|
|
885
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: block page at offset ${blockOffset} failed: ${failure.message}`);
|
|
886
|
+
}
|
|
887
|
+
function assertMutationSnapshotRevision(expected, actual) {
|
|
888
|
+
if (expected === 'unknown' || actual === 'unknown' || expected === actual)
|
|
889
|
+
return;
|
|
890
|
+
throw new MutationSnapshotError('REVISION_CONFLICT', `document changed while resolving mutation selectors (${expected} -> ${actual}); retry the action`);
|
|
891
|
+
}
|
|
751
892
|
/**
|
|
752
893
|
* Structured ambiguity error returned when multiple candidates match a
|
|
753
894
|
* selector and the plan required uniqueness.
|
|
@@ -882,6 +1023,8 @@ function resolveSnapshotSelector(snapshot, selector) {
|
|
|
882
1023
|
}
|
|
883
1024
|
|
|
884
1025
|
exports.AmbiguousSelectorError = AmbiguousSelectorError;
|
|
1026
|
+
exports.MutationSnapshotError = MutationSnapshotError;
|
|
885
1027
|
exports.buildDocumentSnapshot = buildDocumentSnapshot;
|
|
1028
|
+
exports.buildMutationSnapshot = buildMutationSnapshot;
|
|
886
1029
|
exports.matchRunsForBlock = matchRunsForBlock;
|
|
887
1030
|
exports.resolveSnapshotSelector = resolveSnapshotSelector;
|
|
@@ -313,6 +313,17 @@ type SnapshotOptions = {
|
|
|
313
313
|
* rest of the snapshot remains usable.
|
|
314
314
|
*/
|
|
315
315
|
export declare function buildDocumentSnapshot(doc: BoundDocApi, options?: SnapshotOptions): Promise<DocumentSnapshot>;
|
|
316
|
+
export declare class MutationSnapshotError extends Error {
|
|
317
|
+
readonly code: 'SNAPSHOT_INCOMPLETE' | 'REVISION_CONFLICT';
|
|
318
|
+
constructor(code: 'SNAPSHOT_INCOMPLETE' | 'REVISION_CONFLICT', message: string);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Build the complete block index used to authorize mutations. Inspection
|
|
322
|
+
* snapshots stay windowed; mutation selectors must not treat that presentation
|
|
323
|
+
* window as the end of the document. Reads remain bounded and the revision is
|
|
324
|
+
* checked before the combined snapshot is returned.
|
|
325
|
+
*/
|
|
326
|
+
export declare function buildMutationSnapshot(doc: BoundDocApi, options?: Pick<SnapshotOptions, 'includeDomains'>): Promise<DocumentSnapshot>;
|
|
316
327
|
/**
|
|
317
328
|
* Structured ambiguity error returned when multiple candidates match a
|
|
318
329
|
* selector and the plan required uniqueness.
|
|
@@ -97,6 +97,48 @@ function includesDomain(requested, domain) {
|
|
|
97
97
|
function truncateBlockText(value, limit) {
|
|
98
98
|
return limit == null ? value : value.slice(0, limit);
|
|
99
99
|
}
|
|
100
|
+
function populateTableCellsFromExtract(tables, extractRaw, tableOrdinalBase = 0) {
|
|
101
|
+
const extractRec = asRecord(extractRaw);
|
|
102
|
+
const extractBlocks = Array.isArray(extractRec?.blocks) ? extractRec.blocks : [];
|
|
103
|
+
const cellsByTableNodeId = new Map();
|
|
104
|
+
for (const block of extractBlocks) {
|
|
105
|
+
const rec = asRecord(block);
|
|
106
|
+
const tableContext = asRecord(rec?.tableContext);
|
|
107
|
+
if (!rec || !tableContext)
|
|
108
|
+
continue;
|
|
109
|
+
const tableOrdinal = asNumber(tableContext.tableOrdinal, -1);
|
|
110
|
+
const rowIndex = asNumber(tableContext.rowIndex, -1);
|
|
111
|
+
const columnIndex = asNumber(tableContext.columnIndex, -1);
|
|
112
|
+
if (tableOrdinal < 0 || rowIndex < 0 || columnIndex < 0)
|
|
113
|
+
continue;
|
|
114
|
+
const table = tables[tableOrdinal - tableOrdinalBase];
|
|
115
|
+
if (!table)
|
|
116
|
+
continue;
|
|
117
|
+
const key = `${rowIndex}:${columnIndex}`;
|
|
118
|
+
const text = asString(rec.text);
|
|
119
|
+
const nodeId = asString(rec.nodeId) || undefined;
|
|
120
|
+
const cellMap = cellsByTableNodeId.get(table.nodeId) ??
|
|
121
|
+
new Map();
|
|
122
|
+
const existing = cellMap.get(key);
|
|
123
|
+
cellMap.set(key, {
|
|
124
|
+
rowIndex,
|
|
125
|
+
columnIndex,
|
|
126
|
+
text: existing == null || text.length === 0
|
|
127
|
+
? (existing?.text ?? text)
|
|
128
|
+
: existing.text.length === 0
|
|
129
|
+
? text
|
|
130
|
+
: `${existing.text}\n${text}`,
|
|
131
|
+
nodeId: existing?.nodeId ?? nodeId,
|
|
132
|
+
});
|
|
133
|
+
cellsByTableNodeId.set(table.nodeId, cellMap);
|
|
134
|
+
}
|
|
135
|
+
for (const table of tables) {
|
|
136
|
+
const cellMap = cellsByTableNodeId.get(table.nodeId);
|
|
137
|
+
if (!cellMap)
|
|
138
|
+
continue;
|
|
139
|
+
table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
100
142
|
/**
|
|
101
143
|
* Build a deterministic snapshot of a document. The snapshot uses only
|
|
102
144
|
* read-mode operations from the generated contract — it never mutates state,
|
|
@@ -402,46 +444,7 @@ export async function buildDocumentSnapshot(doc, options = {}) {
|
|
|
402
444
|
const extractFn = maybeMethod(doc, ['extract']);
|
|
403
445
|
if (tables.length > 0 && extractFn) {
|
|
404
446
|
const extractRaw = await safeCall(() => extractFn({}), null, recordError('extract'));
|
|
405
|
-
|
|
406
|
-
const extractBlocks = Array.isArray(extractRec?.blocks) ? extractRec.blocks : [];
|
|
407
|
-
const cellsByTableNodeId = new Map();
|
|
408
|
-
for (const block of extractBlocks) {
|
|
409
|
-
const rec = asRecord(block);
|
|
410
|
-
const tableContext = asRecord(rec?.tableContext);
|
|
411
|
-
if (!rec || !tableContext)
|
|
412
|
-
continue;
|
|
413
|
-
const tableOrdinal = asNumber(tableContext.tableOrdinal, -1);
|
|
414
|
-
const rowIndex = asNumber(tableContext.rowIndex, -1);
|
|
415
|
-
const columnIndex = asNumber(tableContext.columnIndex, -1);
|
|
416
|
-
if (tableOrdinal < 0 || rowIndex < 0 || columnIndex < 0)
|
|
417
|
-
continue;
|
|
418
|
-
const table = tables[tableOrdinal - precedingTableCount];
|
|
419
|
-
if (!table)
|
|
420
|
-
continue;
|
|
421
|
-
const key = `${rowIndex}:${columnIndex}`;
|
|
422
|
-
const text = asString(rec.text);
|
|
423
|
-
const nodeId = asString(rec.nodeId) || undefined;
|
|
424
|
-
const cellMap = cellsByTableNodeId.get(table.nodeId) ??
|
|
425
|
-
new Map();
|
|
426
|
-
const existing = cellMap.get(key);
|
|
427
|
-
cellMap.set(key, {
|
|
428
|
-
rowIndex,
|
|
429
|
-
columnIndex,
|
|
430
|
-
text: existing == null || text.length === 0
|
|
431
|
-
? (existing?.text ?? text)
|
|
432
|
-
: existing.text.length === 0
|
|
433
|
-
? text
|
|
434
|
-
: `${existing.text}\n${text}`,
|
|
435
|
-
nodeId: existing?.nodeId ?? nodeId,
|
|
436
|
-
});
|
|
437
|
-
cellsByTableNodeId.set(table.nodeId, cellMap);
|
|
438
|
-
}
|
|
439
|
-
for (const table of tables) {
|
|
440
|
-
const cellMap = cellsByTableNodeId.get(table.nodeId);
|
|
441
|
-
if (!cellMap)
|
|
442
|
-
continue;
|
|
443
|
-
table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
|
|
444
|
-
}
|
|
447
|
+
populateTableCellsFromExtract(tables, extractRaw, precedingTableCount);
|
|
445
448
|
}
|
|
446
449
|
// Optionally enrich table cells with per-run formatting (opt-in; one
|
|
447
450
|
// query.match per cell, bounded) so a reader can match a cell's pattern.
|
|
@@ -746,6 +749,144 @@ export async function buildDocumentSnapshot(doc, options = {}) {
|
|
|
746
749
|
...(finds ? { finds } : {}),
|
|
747
750
|
};
|
|
748
751
|
}
|
|
752
|
+
export class MutationSnapshotError extends Error {
|
|
753
|
+
code;
|
|
754
|
+
constructor(code, message) {
|
|
755
|
+
super(message);
|
|
756
|
+
this.code = code;
|
|
757
|
+
this.name = 'MutationSnapshotError';
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const MUTATION_BLOCK_PAGE_SIZE = 1000;
|
|
761
|
+
const MUTATION_BLOCK_PAGE_CONCURRENCY = 4;
|
|
762
|
+
const MUTATION_NON_TABLE_DOMAINS = [
|
|
763
|
+
'blocks',
|
|
764
|
+
'lists',
|
|
765
|
+
'comments',
|
|
766
|
+
'trackedChanges',
|
|
767
|
+
'sections',
|
|
768
|
+
'headerFooters',
|
|
769
|
+
'styles',
|
|
770
|
+
'contentControls',
|
|
771
|
+
'fields',
|
|
772
|
+
'hyperlinks',
|
|
773
|
+
'bookmarks',
|
|
774
|
+
'permissionRanges',
|
|
775
|
+
'images',
|
|
776
|
+
];
|
|
777
|
+
/**
|
|
778
|
+
* Build the complete block index used to authorize mutations. Inspection
|
|
779
|
+
* snapshots stay windowed; mutation selectors must not treat that presentation
|
|
780
|
+
* window as the end of the document. Reads remain bounded and the revision is
|
|
781
|
+
* checked before the combined snapshot is returned.
|
|
782
|
+
*/
|
|
783
|
+
export async function buildMutationSnapshot(doc, options = {}) {
|
|
784
|
+
const requestedDomains = options.includeDomains == null || options.includeDomains.length === 0 ? null : new Set(options.includeDomains);
|
|
785
|
+
const includeTables = requestedDomains == null || requestedDomains.has('tables');
|
|
786
|
+
const firstDomains = requestedDomains == null
|
|
787
|
+
? MUTATION_NON_TABLE_DOMAINS
|
|
788
|
+
: [...requestedDomains].filter((domain) => domain !== 'tables').concat('blocks');
|
|
789
|
+
const first = await buildDocumentSnapshot(doc, {
|
|
790
|
+
includeDomains: firstDomains,
|
|
791
|
+
blockOffset: 0,
|
|
792
|
+
blockLimit: MUTATION_BLOCK_PAGE_SIZE,
|
|
793
|
+
});
|
|
794
|
+
assertMutationBlockRead(first, 0);
|
|
795
|
+
const total = first.counts.blocks;
|
|
796
|
+
if (first.blocks.length > total) {
|
|
797
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: document reports ${total} blocks but returned ${first.blocks.length}`);
|
|
798
|
+
}
|
|
799
|
+
if (first.blocks.length === 0 && total > 0) {
|
|
800
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: document reports ${total} blocks but returned none`);
|
|
801
|
+
}
|
|
802
|
+
const blocks = [...first.blocks];
|
|
803
|
+
const diagnostics = [...first.diagnostics];
|
|
804
|
+
const pageStride = first.blocks.length || MUTATION_BLOCK_PAGE_SIZE;
|
|
805
|
+
const offsets = first.blocks.length === total
|
|
806
|
+
? []
|
|
807
|
+
: Array.from({ length: Math.ceil((total - pageStride) / pageStride) }, (_, index) => pageStride * (index + 1));
|
|
808
|
+
for (let index = 0; index < offsets.length; index += MUTATION_BLOCK_PAGE_CONCURRENCY) {
|
|
809
|
+
const batchOffsets = offsets.slice(index, index + MUTATION_BLOCK_PAGE_CONCURRENCY);
|
|
810
|
+
const pages = await Promise.all(batchOffsets.map((blockOffset) => buildDocumentSnapshot(doc, {
|
|
811
|
+
includeDomains: ['blocks'],
|
|
812
|
+
blockOffset,
|
|
813
|
+
blockLimit: pageStride,
|
|
814
|
+
})));
|
|
815
|
+
for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
|
|
816
|
+
const page = pages[pageIndex];
|
|
817
|
+
const blockOffset = batchOffsets[pageIndex];
|
|
818
|
+
assertMutationBlockRead(page, blockOffset);
|
|
819
|
+
assertMutationSnapshotRevision(first.revision, page.revision);
|
|
820
|
+
if (page.blocks.length === 0) {
|
|
821
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: block pagination stopped at ${blockOffset} of ${total} blocks`);
|
|
822
|
+
}
|
|
823
|
+
blocks.push(...page.blocks);
|
|
824
|
+
diagnostics.push(...page.diagnostics);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (blocks.length !== total) {
|
|
828
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: expected ${total} blocks but materialized ${blocks.length}`);
|
|
829
|
+
}
|
|
830
|
+
const tables = includeTables ? await buildMutationTables(doc, blocks, diagnostics) : [];
|
|
831
|
+
const finalIdentity = await buildDocumentSnapshot(doc, { countsOnly: true });
|
|
832
|
+
assertMutationSnapshotRevision(first.revision, finalIdentity.revision);
|
|
833
|
+
return {
|
|
834
|
+
...first,
|
|
835
|
+
blocks,
|
|
836
|
+
tables,
|
|
837
|
+
diagnostics,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
async function buildMutationTables(doc, blocks, diagnostics) {
|
|
841
|
+
const tableBlocks = blocks.filter((block) => block.nodeType === 'table');
|
|
842
|
+
const tables = tableBlocks.map((block, index) => ({
|
|
843
|
+
nodeId: block.nodeId,
|
|
844
|
+
ordinal: index + 1,
|
|
845
|
+
rows: 0,
|
|
846
|
+
columns: 0,
|
|
847
|
+
cells: [],
|
|
848
|
+
}));
|
|
849
|
+
const getTable = maybeMethod(doc, ['tables', 'get']);
|
|
850
|
+
if (getTable) {
|
|
851
|
+
for (let index = 0; index < tables.length; index += MUTATION_BLOCK_PAGE_CONCURRENCY) {
|
|
852
|
+
const batch = tables.slice(index, index + MUTATION_BLOCK_PAGE_CONCURRENCY);
|
|
853
|
+
await Promise.all(batch.map(async (table) => {
|
|
854
|
+
try {
|
|
855
|
+
const raw = asRecord(await getTable({ nodeId: table.nodeId }));
|
|
856
|
+
table.rows = asNumber(raw?.rows);
|
|
857
|
+
table.columns = asNumber(raw?.columns);
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
diagnostics.push({
|
|
861
|
+
section: `tables.get:${table.nodeId}`,
|
|
862
|
+
message: error instanceof Error ? error.message : String(error),
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
}));
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const extract = maybeMethod(doc, ['extract']);
|
|
869
|
+
if (tables.length > 0 && extract) {
|
|
870
|
+
try {
|
|
871
|
+
populateTableCellsFromExtract(tables, await extract({}));
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
diagnostics.push({ section: 'extract', message: error instanceof Error ? error.message : String(error) });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return tables;
|
|
878
|
+
}
|
|
879
|
+
function assertMutationBlockRead(snapshot, blockOffset) {
|
|
880
|
+
const failure = snapshot.diagnostics.find((diagnostic) => diagnostic.section === 'blocks.list');
|
|
881
|
+
if (!failure)
|
|
882
|
+
return;
|
|
883
|
+
throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: block page at offset ${blockOffset} failed: ${failure.message}`);
|
|
884
|
+
}
|
|
885
|
+
function assertMutationSnapshotRevision(expected, actual) {
|
|
886
|
+
if (expected === 'unknown' || actual === 'unknown' || expected === actual)
|
|
887
|
+
return;
|
|
888
|
+
throw new MutationSnapshotError('REVISION_CONFLICT', `document changed while resolving mutation selectors (${expected} -> ${actual}); retry the action`);
|
|
889
|
+
}
|
|
749
890
|
/**
|
|
750
891
|
* Structured ambiguity error returned when multiple candidates match a
|
|
751
892
|
* selector and the plan required uniqueness.
|
package/dist/agent/runtime.cjs
CHANGED
|
@@ -227,8 +227,8 @@ function computeDeltaChecks(pre, post, checks, saveReopen) {
|
|
|
227
227
|
else if (check.kind === 'comment-count-delta') {
|
|
228
228
|
results.push({
|
|
229
229
|
check,
|
|
230
|
-
passed: post.comments
|
|
231
|
-
detail: `pre=${pre.comments
|
|
230
|
+
passed: post.counts.comments - pre.counts.comments === check.delta,
|
|
231
|
+
detail: `pre=${pre.counts.comments} post=${post.counts.comments}`,
|
|
232
232
|
});
|
|
233
233
|
}
|
|
234
234
|
else if (check.kind === 'tracked-change-count-delta') {
|
|
@@ -313,7 +313,7 @@ async function trySaveReopen(doc, checks) {
|
|
|
313
313
|
await saveAny.call(doc, {});
|
|
314
314
|
// Rebuild a fresh snapshot after save. Host-level true reopen still needs
|
|
315
315
|
// a new document handle, which this runtime cannot force on its own.
|
|
316
|
-
const fresh = await docSnapshot.
|
|
316
|
+
const fresh = await docSnapshot.buildMutationSnapshot(doc);
|
|
317
317
|
for (const check of checks) {
|
|
318
318
|
if (check.kind === 'save-reopen-text-contains') {
|
|
319
319
|
const found = fresh.blocks.some((b) => b.text.includes(check.text));
|
|
@@ -350,7 +350,23 @@ async function agentApply(doc, args) {
|
|
|
350
350
|
errors: validation.errors.map((e) => ({ code: e.code, message: e.message })),
|
|
351
351
|
};
|
|
352
352
|
}
|
|
353
|
-
|
|
353
|
+
let preSnapshot;
|
|
354
|
+
try {
|
|
355
|
+
preSnapshot = await docSnapshot.buildMutationSnapshot(doc);
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (!(error instanceof docSnapshot.MutationSnapshotError))
|
|
359
|
+
throw error;
|
|
360
|
+
return {
|
|
361
|
+
status: 'failed',
|
|
362
|
+
intent: plan.intent,
|
|
363
|
+
preSnapshot: { revision: 'unknown', counts: emptyCounts() },
|
|
364
|
+
selectedTargets: [],
|
|
365
|
+
executedOperations: [],
|
|
366
|
+
verification: [],
|
|
367
|
+
errors: [{ code: error.code, message: error.message, recovery: { kind: 'retry' } }],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
354
370
|
const selectedTargets = [];
|
|
355
371
|
const executedOperations = [];
|
|
356
372
|
const bindings = new Map();
|
|
@@ -415,7 +431,23 @@ async function agentApply(doc, args) {
|
|
|
415
431
|
errors: [{ code: 'APPLY_FAILED', message }],
|
|
416
432
|
};
|
|
417
433
|
}
|
|
418
|
-
|
|
434
|
+
let postSnapshot;
|
|
435
|
+
try {
|
|
436
|
+
postSnapshot = await docSnapshot.buildMutationSnapshot(doc);
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
if (!(error instanceof docSnapshot.MutationSnapshotError))
|
|
440
|
+
throw error;
|
|
441
|
+
return {
|
|
442
|
+
status: 'failed',
|
|
443
|
+
intent: plan.intent,
|
|
444
|
+
preSnapshot: { revision: preSnapshot.revision, counts: preSnapshot.counts },
|
|
445
|
+
selectedTargets,
|
|
446
|
+
executedOperations,
|
|
447
|
+
verification: [],
|
|
448
|
+
errors: [{ code: error.code, message: error.message, recovery: { kind: 'reinspect' } }],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
419
451
|
const verifyStep = plan.steps.find((s) => s.kind === 'verify');
|
|
420
452
|
let saveReopen;
|
|
421
453
|
const shouldSaveReopen = (verifyStep?.kind === 'verify' && (verifyStep.saveReopen || verificationNeedsSaveReopen(verifyStep.checks))) ||
|
|
@@ -437,7 +469,23 @@ async function agentApply(doc, args) {
|
|
|
437
469
|
};
|
|
438
470
|
}
|
|
439
471
|
async function agentVerify(doc, args) {
|
|
440
|
-
|
|
472
|
+
let snapshot;
|
|
473
|
+
try {
|
|
474
|
+
snapshot = await docSnapshot.buildMutationSnapshot(doc);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
if (!(error instanceof docSnapshot.MutationSnapshotError))
|
|
478
|
+
throw error;
|
|
479
|
+
return {
|
|
480
|
+
status: 'failed',
|
|
481
|
+
intent: 'verify',
|
|
482
|
+
preSnapshot: { revision: 'unknown', counts: emptyCounts() },
|
|
483
|
+
selectedTargets: [],
|
|
484
|
+
executedOperations: [],
|
|
485
|
+
verification: [],
|
|
486
|
+
errors: [{ code: error.code, message: error.message, recovery: { kind: 'retry' } }],
|
|
487
|
+
};
|
|
488
|
+
}
|
|
441
489
|
let saveReopen;
|
|
442
490
|
if (args.saveReopen || verificationNeedsSaveReopen(args.checks)) {
|
|
443
491
|
saveReopen = await trySaveReopen(doc, args.checks);
|