Skip to content

Commit abfd81b

Browse files
authored
fix(document-api): plan-engine reliability fixes and error diagnostics (#2185)
1 parent 2897246 commit abfd81b

29 files changed

+3489
-124
lines changed

apps/cli/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ dist
1616
# local debug fixture captures
1717
src/__tests__/fixtures-cli-debug*/
1818

19+
# CLI test fixtures (generated at runtime)
20+
src/__tests__/fixtures-cli/
21+
1922
# native build artifacts
2023
artifacts/
2124

apps/cli/src/__tests__/lib/error-mapping.test.ts

Lines changed: 250 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from 'bun:test';
2-
import { mapInvokeError } from '../../lib/error-mapping';
2+
import { mapInvokeError, mapFailedReceipt } from '../../lib/error-mapping';
3+
import { CliError } from '../../lib/errors';
34

45
describe('mapInvokeError', () => {
56
test('maps blocks.delete INVALID_INPUT errors to INVALID_ARGUMENT', () => {
@@ -14,3 +15,251 @@ describe('mapInvokeError', () => {
1415
expect(mapped.details).toEqual({ operationId: 'blocks.delete', details: { field: 'target' } });
1516
});
1617
});
18+
19+
// ---------------------------------------------------------------------------
20+
// T8: Plan-engine error code passthrough in CLI error mapping
21+
// ---------------------------------------------------------------------------
22+
23+
describe('mapInvokeError: plan-engine error passthrough', () => {
24+
const operationId = 'mutations.apply' as any;
25+
26+
test('REVISION_MISMATCH preserves code and structured details', () => {
27+
const error = Object.assign(new Error('REVISION_MISMATCH — stale ref'), {
28+
code: 'REVISION_MISMATCH',
29+
details: {
30+
refRevision: '0',
31+
currentRevision: '2',
32+
refStability: 'ephemeral',
33+
remediation: 'Re-run query.match() to obtain a fresh ref.',
34+
},
35+
});
36+
37+
const result = mapInvokeError(operationId, error);
38+
39+
expect(result).toBeInstanceOf(CliError);
40+
expect(result.code).toBe('REVISION_MISMATCH');
41+
expect(result.details).toMatchObject({
42+
operationId,
43+
details: {
44+
refRevision: '0',
45+
currentRevision: '2',
46+
refStability: 'ephemeral',
47+
remediation: expect.any(String),
48+
},
49+
});
50+
});
51+
52+
test('PLAN_CONFLICT_OVERLAP preserves code and matrix details', () => {
53+
const error = Object.assign(new Error('overlap'), {
54+
code: 'PLAN_CONFLICT_OVERLAP',
55+
details: {
56+
stepIdA: 'step-1',
57+
stepIdB: 'step-2',
58+
opKeyA: 'format.apply',
59+
opKeyB: 'text.rewrite',
60+
matrixVerdict: 'reject',
61+
matrixKey: 'format.apply::text.rewrite::same_target',
62+
},
63+
});
64+
65+
const result = mapInvokeError(operationId, error);
66+
67+
expect(result.code).toBe('PLAN_CONFLICT_OVERLAP');
68+
expect(result.details).toMatchObject({
69+
details: {
70+
stepIdA: 'step-1',
71+
stepIdB: 'step-2',
72+
matrixVerdict: 'reject',
73+
},
74+
});
75+
});
76+
77+
test('DOCUMENT_IDENTITY_CONFLICT preserves code and remediation', () => {
78+
const error = Object.assign(new Error('duplicate IDs'), {
79+
code: 'DOCUMENT_IDENTITY_CONFLICT',
80+
details: {
81+
duplicateBlockIds: ['p3', 'p7'],
82+
blockCount: 2,
83+
remediation: 'Re-import the document.',
84+
},
85+
});
86+
87+
const result = mapInvokeError(operationId, error);
88+
89+
expect(result.code).toBe('DOCUMENT_IDENTITY_CONFLICT');
90+
expect(result.details).toMatchObject({
91+
details: {
92+
duplicateBlockIds: ['p3', 'p7'],
93+
remediation: expect.any(String),
94+
},
95+
});
96+
});
97+
98+
test('REVISION_CHANGED_SINCE_COMPILE preserves code and details', () => {
99+
const error = Object.assign(new Error('drift'), {
100+
code: 'REVISION_CHANGED_SINCE_COMPILE',
101+
details: {
102+
compiledRevision: '3',
103+
currentRevision: '5',
104+
remediation: 'Re-compile the plan.',
105+
},
106+
});
107+
108+
const result = mapInvokeError(operationId, error);
109+
110+
expect(result.code).toBe('REVISION_CHANGED_SINCE_COMPILE');
111+
expect(result.details).toMatchObject({
112+
details: {
113+
compiledRevision: '3',
114+
currentRevision: '5',
115+
},
116+
});
117+
});
118+
119+
test('INVALID_INSERTION_CONTEXT preserves code and details', () => {
120+
const error = Object.assign(new Error('bad context'), {
121+
code: 'INVALID_INSERTION_CONTEXT',
122+
details: {
123+
stepIndex: 0,
124+
operation: 'create.heading',
125+
parentType: 'table_cell',
126+
},
127+
});
128+
129+
const result = mapInvokeError(operationId, error);
130+
131+
expect(result.code).toBe('INVALID_INSERTION_CONTEXT');
132+
expect(result.details).toMatchObject({
133+
details: {
134+
stepIndex: 0,
135+
parentType: 'table_cell',
136+
},
137+
});
138+
});
139+
140+
test('unknown error codes still fall through to COMMAND_FAILED', () => {
141+
const error = Object.assign(new Error('something weird'), {
142+
code: 'TOTALLY_UNKNOWN_CODE',
143+
details: { foo: 'bar' },
144+
});
145+
146+
const result = mapInvokeError(operationId, error);
147+
148+
expect(result.code).toBe('COMMAND_FAILED');
149+
});
150+
151+
test('valid ref (no error) baseline — CliError passes through', () => {
152+
const error = new CliError('COMMAND_FAILED', 'already a CliError');
153+
154+
const result = mapInvokeError(operationId, error);
155+
156+
expect(result).toBe(error);
157+
expect(result.code).toBe('COMMAND_FAILED');
158+
});
159+
160+
test('large revision gap stale ref still includes all structured details', () => {
161+
const error = Object.assign(new Error('REVISION_MISMATCH'), {
162+
code: 'REVISION_MISMATCH',
163+
details: {
164+
refRevision: '0',
165+
currentRevision: '50',
166+
refStability: 'ephemeral',
167+
remediation: 'Re-run query.match()',
168+
},
169+
});
170+
171+
const result = mapInvokeError(operationId, error);
172+
173+
expect(result.code).toBe('REVISION_MISMATCH');
174+
expect(result.details).toMatchObject({
175+
details: {
176+
refRevision: '0',
177+
currentRevision: '50',
178+
refStability: 'ephemeral',
179+
remediation: expect.any(String),
180+
},
181+
});
182+
});
183+
});
184+
185+
// ---------------------------------------------------------------------------
186+
// T8 extension: mapFailedReceipt — plan-engine code passthrough + envelope
187+
// ---------------------------------------------------------------------------
188+
189+
describe('mapFailedReceipt: plan-engine code passthrough', () => {
190+
const operationId = 'insert' as any;
191+
192+
test('returns null for successful receipts', () => {
193+
expect(mapFailedReceipt(operationId, { success: true })).toBeNull();
194+
});
195+
196+
test('returns null for non-receipt values', () => {
197+
expect(mapFailedReceipt(operationId, 'not a receipt')).toBeNull();
198+
expect(mapFailedReceipt(operationId, null)).toBeNull();
199+
expect(mapFailedReceipt(operationId, 42)).toBeNull();
200+
});
201+
202+
test('returns COMMAND_FAILED when failure has no code', () => {
203+
const result = mapFailedReceipt(operationId, { success: false });
204+
expect(result).toBeInstanceOf(CliError);
205+
expect(result!.code).toBe('COMMAND_FAILED');
206+
});
207+
208+
test('plan-engine code MATCH_NOT_FOUND passes through with structured details', () => {
209+
const receipt = {
210+
success: false,
211+
failure: {
212+
code: 'MATCH_NOT_FOUND',
213+
message: 'No match found for selector',
214+
details: { selectorType: 'text', selectorPattern: 'foo', candidateCount: 0 },
215+
},
216+
};
217+
218+
const result = mapFailedReceipt(operationId, receipt);
219+
expect(result).toBeInstanceOf(CliError);
220+
expect(result!.code).toBe('MATCH_NOT_FOUND');
221+
expect(result!.details).toMatchObject({
222+
operationId,
223+
failure: { code: 'MATCH_NOT_FOUND', details: { selectorType: 'text' } },
224+
});
225+
});
226+
227+
test('plan-engine code PRECONDITION_FAILED passes through', () => {
228+
const receipt = {
229+
success: false,
230+
failure: { code: 'PRECONDITION_FAILED', message: 'Assert failed' },
231+
};
232+
233+
const result = mapFailedReceipt(operationId, receipt);
234+
expect(result!.code).toBe('PRECONDITION_FAILED');
235+
});
236+
237+
test('plan-engine code REVISION_MISMATCH passes through', () => {
238+
const receipt = {
239+
success: false,
240+
failure: {
241+
code: 'REVISION_MISMATCH',
242+
message: 'stale ref',
243+
details: { refRevision: '0', currentRevision: '3' },
244+
},
245+
};
246+
247+
const result = mapFailedReceipt(operationId, receipt);
248+
expect(result!.code).toBe('REVISION_MISMATCH');
249+
expect(result!.details).toMatchObject({
250+
failure: { details: { refRevision: '0', currentRevision: '3' } },
251+
});
252+
});
253+
254+
test('non-plan-engine failure codes go through per-family normalization', () => {
255+
const receipt = {
256+
success: false,
257+
failure: { code: 'NO_OP', message: 'no change' },
258+
};
259+
260+
const result = mapFailedReceipt(operationId, receipt);
261+
// NO_OP is not a plan-engine passthrough code, so it normalizes
262+
expect(result).toBeInstanceOf(CliError);
263+
expect(result!.code).not.toBe('NO_OP');
264+
});
265+
});

apps/cli/src/lib/error-mapping.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import type { CliExposedOperationId } from '../cli/operation-set.js';
1313
import { OPERATION_FAMILY, type OperationFamily } from '../cli/operation-hints.js';
14-
import { CliError, type AdapterLikeError } from './errors.js';
14+
import { CliError, type AdapterLikeError, type CliErrorCode } from './errors.js';
1515

1616
// ---------------------------------------------------------------------------
1717
// Error code extraction
@@ -101,6 +101,10 @@ function mapTextMutationError(operationId: CliExposedOperationId, error: unknown
101101
const message = extractErrorMessage(error);
102102
const details = extractErrorDetails(error);
103103

104+
// Plan-engine errors pass through with original code and structured details
105+
const planEngineError = tryMapPlanEngineError(operationId, error, code);
106+
if (planEngineError) return planEngineError;
107+
104108
if (code === 'TARGET_NOT_FOUND') {
105109
return new CliError('TARGET_NOT_FOUND', message, { operationId, details });
106110
}
@@ -125,6 +129,10 @@ function mapCreateError(operationId: CliExposedOperationId, error: unknown, code
125129
const message = extractErrorMessage(error);
126130
const details = extractErrorDetails(error);
127131

132+
// Plan-engine errors pass through with original code and structured details
133+
const planEngineError = tryMapPlanEngineError(operationId, error, code);
134+
if (planEngineError) return planEngineError;
135+
128136
if (code === 'TARGET_NOT_FOUND') {
129137
return new CliError('TARGET_NOT_FOUND', message, { operationId, details });
130138
}
@@ -193,6 +201,46 @@ function mapQueryError(operationId: CliExposedOperationId, error: unknown, code:
193201
return new CliError('COMMAND_FAILED', message, { operationId, details });
194202
}
195203

204+
// ---------------------------------------------------------------------------
205+
// Plan-engine error codes — pass through with original code and details
206+
// ---------------------------------------------------------------------------
207+
208+
/**
209+
* Plan-engine error codes that must be preserved verbatim in CLI output.
210+
* These carry structured details (refRevision, matrixVerdict, remediation, etc.)
211+
* that consumers depend on for programmatic triage.
212+
*/
213+
const PLAN_ENGINE_PASSTHROUGH_CODES: ReadonlySet<CliErrorCode> = new Set<CliErrorCode>([
214+
'REVISION_MISMATCH',
215+
'REVISION_CHANGED_SINCE_COMPILE',
216+
'PLAN_CONFLICT_OVERLAP',
217+
'DOCUMENT_IDENTITY_CONFLICT',
218+
'INVALID_INSERTION_CONTEXT',
219+
'INVALID_INPUT',
220+
'INVALID_STEP_COMBINATION',
221+
'MATCH_NOT_FOUND',
222+
'PRECONDITION_FAILED',
223+
'CROSS_BLOCK_MATCH',
224+
'SPAN_FRAGMENTED',
225+
]);
226+
227+
/**
228+
* If the error code is a known plan-engine code, pass it through with
229+
* original code and all structured details preserved.
230+
* Returns null if the code is not a plan-engine passthrough code.
231+
*/
232+
function tryMapPlanEngineError(
233+
operationId: CliExposedOperationId,
234+
error: unknown,
235+
code: string | undefined,
236+
): CliError | null {
237+
if (!code || !(PLAN_ENGINE_PASSTHROUGH_CODES as ReadonlySet<string>).has(code)) return null;
238+
return new CliError(code as CliErrorCode, extractErrorMessage(error), {
239+
operationId,
240+
details: extractErrorDetails(error),
241+
});
242+
}
243+
196244
// ---------------------------------------------------------------------------
197245
// Per-family error mappers (dispatch by family)
198246
// ---------------------------------------------------------------------------
@@ -208,7 +256,11 @@ const FAMILY_MAPPERS: Record<
208256
create: mapCreateError,
209257
blocks: mapBlocksError,
210258
query: mapQueryError,
211-
general: (operationId, error) => {
259+
general: (operationId, error, code) => {
260+
// Plan-engine errors pass through with original code and structured details
261+
const planEngineError = tryMapPlanEngineError(operationId, error, code);
262+
if (planEngineError) return planEngineError;
263+
212264
if (error instanceof CliError) return error;
213265
return new CliError('COMMAND_FAILED', extractErrorMessage(error), { operationId });
214266
},
@@ -265,6 +317,11 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk
265317
const failureCode = failure.code;
266318
const failureMessage = failure.message ?? `${operationId}: operation failed.`;
267319

320+
// Plan-engine codes pass through with original code and structured details
321+
if (failureCode && (PLAN_ENGINE_PASSTHROUGH_CODES as ReadonlySet<string>).has(failureCode)) {
322+
return new CliError(failureCode as CliErrorCode, failureMessage, { operationId, failure });
323+
}
324+
268325
// Track-changes family
269326
if (family === 'trackChanges') {
270327
if (failureCode === 'TRACK_CHANGE_COMMAND_UNAVAILABLE') {

apps/cli/src/lib/errors.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,18 @@ export type CliErrorCode =
2525
| 'TRACK_CHANGE_COMMAND_UNAVAILABLE'
2626
| 'TRACK_CHANGE_CONFLICT'
2727
| 'COMMAND_FAILED'
28-
| 'TIMEOUT';
28+
| 'TIMEOUT'
29+
// Plan-engine error codes — passed through from document-api adapters
30+
| 'REVISION_CHANGED_SINCE_COMPILE'
31+
| 'PLAN_CONFLICT_OVERLAP'
32+
| 'DOCUMENT_IDENTITY_CONFLICT'
33+
| 'INVALID_INSERTION_CONTEXT'
34+
| 'INVALID_INPUT'
35+
| 'INVALID_STEP_COMBINATION'
36+
| 'MATCH_NOT_FOUND'
37+
| 'PRECONDITION_FAILED'
38+
| 'CROSS_BLOCK_MATCH'
39+
| 'SPAN_FRAGMENTED';
2940

3041
/**
3142
* Intersection type for errors thrown by document-api adapter operations.

apps/docs/document-api/reference/_generated-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,5 +132,5 @@
132132
}
133133
],
134134
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
135-
"sourceHash": "f1dc053325c2b7209ee7484bcbf22071c725163fb5ab3f23a71ce1f6f7328896"
135+
"sourceHash": "87c87f3fdb162eb43656a1971fd5f93fec1c0e76cc1f9a9ac22b585f1e4b0b28"
136136
}

0 commit comments

Comments
 (0)