@pi-unipi/background-tasks 2.16.0 → 2.17.0
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/README.md +21 -27
- package/package.json +3 -4
- package/src/cards.ts +76 -0
- package/src/child-process.ts +1 -1
- package/src/config.ts +0 -42
- package/src/context-visible-conversation-v2.ts +1 -1
- package/src/delegate/artifacts.ts +1 -1
- package/src/delegate/launch.ts +17 -30
- package/src/delegate/result-package.ts +1 -1
- package/src/delegate/runner.ts +1 -20
- package/src/delegate/seed.ts +1 -1
- package/src/delegate-extension.ts +16 -168
- package/src/index.ts +53 -25
- package/src/json-utils.ts +56 -0
- package/src/package-assets.ts +51 -0
- package/src/registry.ts +8 -459
- package/src/task-manager.ts +13 -2
- package/src/tools.ts +4 -189
- package/src/types.ts +17 -70
- package/extensions/anthropic-attribution.ts +0 -1
- package/extensions/fusion-child.ts +0 -1
- package/src/anthropic-attribution-path.ts +0 -21
- package/src/anthropic-attribution.ts +0 -1983
- package/src/attested-pi-run.ts +0 -612
- package/src/fixtures/fusion-golden-bytes.json +0 -310
- package/src/fixtures/fusion-validate-golden-bytes.json +0 -282
- package/src/fusion/artifacts.ts +0 -967
- package/src/fusion/budget.ts +0 -1162
- package/src/fusion/child-protocol.ts +0 -305
- package/src/fusion/claude-cache.ts +0 -207
- package/src/fusion/clean-context.ts +0 -91
- package/src/fusion/config.ts +0 -449
- package/src/fusion/context.ts +0 -265
- package/src/fusion/evaluation.ts +0 -800
- package/src/fusion/orchestrator.ts +0 -1288
- package/src/fusion/output-contract.ts +0 -34
- package/src/fusion/pi-child.ts +0 -2373
- package/src/fusion/prompts.ts +0 -345
- package/src/fusion/result-package.ts +0 -959
- package/src/fusion/source-policy.ts +0 -257
- package/src/fusion/types.ts +0 -1139
- package/src/fusion/web-fetch.ts +0 -1060
- package/src/fusion/workflows.ts +0 -184
- package/src/fusion-child-extension.ts +0 -1052
- package/src/fusion-extension.ts +0 -1293
- package/src/ui/fusion-model-selector.ts +0 -322
|
@@ -1,959 +0,0 @@
|
|
|
1
|
-
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { canonicalJson, sha256Buffer } from '../attested-pi-run.js';
|
|
4
|
-
import { parseJsonText, type JsonObject } from '../types.js';
|
|
5
|
-
import {
|
|
6
|
-
FUSION_FAILURE_SUMMARY_ATTEMPT_CAP,
|
|
7
|
-
FUSION_FAILURE_SUMMARY_EVIDENCE_CAP,
|
|
8
|
-
FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES,
|
|
9
|
-
FUSION_FAILURE_SUMMARY_MAX_BYTES,
|
|
10
|
-
assertFusionArtifactBasename,
|
|
11
|
-
buildFusionRunProgress,
|
|
12
|
-
} from './artifacts.js';
|
|
13
|
-
import {
|
|
14
|
-
FUSION_COMMITTED_RESULT_SCHEMA_VERSION,
|
|
15
|
-
FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
|
|
16
|
-
FUSION_LEGACY_MANIFEST_SCHEMA_VERSION,
|
|
17
|
-
FUSION_MANIFEST_SCHEMA_VERSION,
|
|
18
|
-
FUSION_RESULT_SCHEMA_VERSION,
|
|
19
|
-
FusionError,
|
|
20
|
-
type FusionArtifactRef,
|
|
21
|
-
type FusionFailureAttemptMetadata,
|
|
22
|
-
type FusionFailureEvidenceArtifact,
|
|
23
|
-
type FusionFailureList,
|
|
24
|
-
type FusionFailureResultView,
|
|
25
|
-
type FusionFailureSummaryV1,
|
|
26
|
-
type FusionResultDetails,
|
|
27
|
-
type FusionRunResult,
|
|
28
|
-
type FusionUsage,
|
|
29
|
-
type FusionWorkflowId,
|
|
30
|
-
type FusionRunProgress,
|
|
31
|
-
type FusionSource,
|
|
32
|
-
type FusionStage,
|
|
33
|
-
} from './types.js';
|
|
34
|
-
|
|
35
|
-
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
36
|
-
|
|
37
|
-
function isRecord(value: unknown): value is JsonObject {
|
|
38
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function fail(message: string, artifactDir: string): never {
|
|
42
|
-
throw new FusionError(`fusion committed result invalid: ${message}`, {
|
|
43
|
-
code: 'artifact_error',
|
|
44
|
-
childCreated: true,
|
|
45
|
-
artifactDir,
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function assertOnlyKeys(
|
|
50
|
-
value: JsonObject,
|
|
51
|
-
allowed: readonly string[],
|
|
52
|
-
label: string,
|
|
53
|
-
artifactDir: string,
|
|
54
|
-
): void {
|
|
55
|
-
const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
56
|
-
if (unexpected.length > 0)
|
|
57
|
-
fail(`${label} contains unexpected keys: ${unexpected.join(', ')}`, artifactDir);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function artifactRef(value: unknown, label: string, artifactDir: string): FusionArtifactRef {
|
|
61
|
-
if (!isRecord(value)) fail(`${label} must be an object`, artifactDir);
|
|
62
|
-
assertOnlyKeys(value, ['path', 'byte_length', 'sha256'], label, artifactDir);
|
|
63
|
-
const path = value['path'];
|
|
64
|
-
const byteLength = value['byte_length'];
|
|
65
|
-
const sha256 = value['sha256'];
|
|
66
|
-
if (typeof path !== 'string') fail(`${label}.path is invalid`, artifactDir);
|
|
67
|
-
try {
|
|
68
|
-
assertFusionArtifactBasename(path);
|
|
69
|
-
} catch {
|
|
70
|
-
fail(`${label}.path is invalid`, artifactDir);
|
|
71
|
-
}
|
|
72
|
-
if (!Number.isSafeInteger(byteLength) || Number(byteLength) < 0) {
|
|
73
|
-
fail(`${label}.byte_length is invalid`, artifactDir);
|
|
74
|
-
}
|
|
75
|
-
if (typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256)) {
|
|
76
|
-
fail(`${label}.sha256 is invalid`, artifactDir);
|
|
77
|
-
}
|
|
78
|
-
return { path, byte_length: Number(byteLength), sha256 };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function usage(value: unknown, artifactDir: string): FusionUsage {
|
|
82
|
-
if (!isRecord(value)) fail('details.usage must be an object', artifactDir);
|
|
83
|
-
assertOnlyKeys(
|
|
84
|
-
value,
|
|
85
|
-
[
|
|
86
|
-
'input',
|
|
87
|
-
'output',
|
|
88
|
-
'cacheRead',
|
|
89
|
-
'cacheWrite',
|
|
90
|
-
'cacheWrite1h',
|
|
91
|
-
'reasoning',
|
|
92
|
-
'totalTokens',
|
|
93
|
-
'cost',
|
|
94
|
-
],
|
|
95
|
-
'details.usage',
|
|
96
|
-
artifactDir,
|
|
97
|
-
);
|
|
98
|
-
const cost = value['cost'];
|
|
99
|
-
if (!isRecord(cost)) fail('details.usage.cost must be an object', artifactDir);
|
|
100
|
-
assertOnlyKeys(
|
|
101
|
-
cost,
|
|
102
|
-
['input', 'output', 'cacheRead', 'cacheWrite', 'total'],
|
|
103
|
-
'details.usage.cost',
|
|
104
|
-
artifactDir,
|
|
105
|
-
);
|
|
106
|
-
const finiteNonnegative = (entry: unknown, label: string): number => {
|
|
107
|
-
if (typeof entry !== 'number' || !Number.isFinite(entry) || entry < 0)
|
|
108
|
-
fail(`${label} is invalid`, artifactDir);
|
|
109
|
-
return entry;
|
|
110
|
-
};
|
|
111
|
-
const output = finiteNonnegative(value['output'], 'details.usage.output');
|
|
112
|
-
const cacheWrite = finiteNonnegative(value['cacheWrite'], 'details.usage.cacheWrite');
|
|
113
|
-
const cacheWrite1h =
|
|
114
|
-
value['cacheWrite1h'] === undefined
|
|
115
|
-
? undefined
|
|
116
|
-
: finiteNonnegative(value['cacheWrite1h'], 'details.usage.cacheWrite1h');
|
|
117
|
-
const reasoning =
|
|
118
|
-
value['reasoning'] === undefined
|
|
119
|
-
? undefined
|
|
120
|
-
: finiteNonnegative(value['reasoning'], 'details.usage.reasoning');
|
|
121
|
-
if (cacheWrite1h !== undefined && cacheWrite1h > cacheWrite) {
|
|
122
|
-
fail('details.usage.cacheWrite1h must not exceed cacheWrite', artifactDir);
|
|
123
|
-
}
|
|
124
|
-
if (reasoning !== undefined && reasoning > output) {
|
|
125
|
-
fail('details.usage.reasoning must not exceed output', artifactDir);
|
|
126
|
-
}
|
|
127
|
-
return {
|
|
128
|
-
input: finiteNonnegative(value['input'], 'details.usage.input'),
|
|
129
|
-
output,
|
|
130
|
-
cacheRead: finiteNonnegative(value['cacheRead'], 'details.usage.cacheRead'),
|
|
131
|
-
cacheWrite,
|
|
132
|
-
...(cacheWrite1h === undefined ? {} : { cacheWrite1h }),
|
|
133
|
-
...(reasoning === undefined ? {} : { reasoning }),
|
|
134
|
-
totalTokens: finiteNonnegative(value['totalTokens'], 'details.usage.totalTokens'),
|
|
135
|
-
cost: {
|
|
136
|
-
input: finiteNonnegative(cost['input'], 'details.usage.cost.input'),
|
|
137
|
-
output: finiteNonnegative(cost['output'], 'details.usage.cost.output'),
|
|
138
|
-
cacheRead: finiteNonnegative(cost['cacheRead'], 'details.usage.cost.cacheRead'),
|
|
139
|
-
cacheWrite: finiteNonnegative(cost['cacheWrite'], 'details.usage.cost.cacheWrite'),
|
|
140
|
-
total: finiteNonnegative(cost['total'], 'details.usage.cost.total'),
|
|
141
|
-
},
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function resultDetails(
|
|
146
|
-
value: unknown,
|
|
147
|
-
expected: { runId: string; workflow: FusionWorkflowId; artifactDir: string },
|
|
148
|
-
): FusionResultDetails {
|
|
149
|
-
if (!isRecord(value)) fail('details must be an object', expected.artifactDir);
|
|
150
|
-
assertOnlyKeys(
|
|
151
|
-
value,
|
|
152
|
-
[
|
|
153
|
-
'schema_version',
|
|
154
|
-
'run_id',
|
|
155
|
-
'workflow',
|
|
156
|
-
'source',
|
|
157
|
-
'status',
|
|
158
|
-
'context',
|
|
159
|
-
'tool_policy',
|
|
160
|
-
'artifact_dir',
|
|
161
|
-
'models',
|
|
162
|
-
'evaluator_attempts',
|
|
163
|
-
'usage',
|
|
164
|
-
'budget',
|
|
165
|
-
],
|
|
166
|
-
'details',
|
|
167
|
-
expected.artifactDir,
|
|
168
|
-
);
|
|
169
|
-
if (value['schema_version'] !== FUSION_RESULT_SCHEMA_VERSION)
|
|
170
|
-
fail('details schema version mismatch', expected.artifactDir);
|
|
171
|
-
if (value['run_id'] !== expected.runId) fail('details run id mismatch', expected.artifactDir);
|
|
172
|
-
if (value['workflow'] !== expected.workflow)
|
|
173
|
-
fail('details workflow mismatch', expected.artifactDir);
|
|
174
|
-
if (value['source'] !== 'command' && value['source'] !== 'tool')
|
|
175
|
-
fail('details source is invalid', expected.artifactDir);
|
|
176
|
-
if (value['status'] !== 'completed')
|
|
177
|
-
fail('details status is not completed', expected.artifactDir);
|
|
178
|
-
if (value['artifact_dir'] !== expected.artifactDir)
|
|
179
|
-
fail('details artifact directory mismatch', expected.artifactDir);
|
|
180
|
-
const context = value['context'];
|
|
181
|
-
const toolPolicy = value['tool_policy'];
|
|
182
|
-
const models = value['models'];
|
|
183
|
-
const budget = value['budget'];
|
|
184
|
-
if (!isRecord(context) || !isRecord(toolPolicy) || !isRecord(models) || !isRecord(budget)) {
|
|
185
|
-
fail('details nested contract is malformed', expected.artifactDir);
|
|
186
|
-
}
|
|
187
|
-
assertOnlyKeys(context, ['kind', 'policy_id'], 'details.context', expected.artifactDir);
|
|
188
|
-
if (
|
|
189
|
-
(context['kind'] !== 'session_projection' && context['kind'] !== 'clean_task') ||
|
|
190
|
-
typeof context['policy_id'] !== 'string'
|
|
191
|
-
) {
|
|
192
|
-
fail('details.context is invalid', expected.artifactDir);
|
|
193
|
-
}
|
|
194
|
-
assertOnlyKeys(
|
|
195
|
-
toolPolicy,
|
|
196
|
-
['candidate_tools', 'evaluation_tools', 'merge_tools'],
|
|
197
|
-
'details.tool_policy',
|
|
198
|
-
expected.artifactDir,
|
|
199
|
-
);
|
|
200
|
-
const stringArray = (entry: unknown): entry is string[] =>
|
|
201
|
-
Array.isArray(entry) && entry.every((item) => typeof item === 'string');
|
|
202
|
-
if (
|
|
203
|
-
!stringArray(toolPolicy['candidate_tools']) ||
|
|
204
|
-
!Array.isArray(toolPolicy['evaluation_tools']) ||
|
|
205
|
-
toolPolicy['evaluation_tools'].length !== 0 ||
|
|
206
|
-
!Array.isArray(toolPolicy['merge_tools']) ||
|
|
207
|
-
toolPolicy['merge_tools'].length !== 0
|
|
208
|
-
) {
|
|
209
|
-
fail('details.tool_policy is invalid', expected.artifactDir);
|
|
210
|
-
}
|
|
211
|
-
assertOnlyKeys(
|
|
212
|
-
models,
|
|
213
|
-
['candidates', 'evaluator', 'merger', 'thinking_level'],
|
|
214
|
-
'details.models',
|
|
215
|
-
expected.artifactDir,
|
|
216
|
-
);
|
|
217
|
-
if (
|
|
218
|
-
!stringArray(models['candidates']) ||
|
|
219
|
-
models['candidates'].length !== 3 ||
|
|
220
|
-
typeof models['evaluator'] !== 'string' ||
|
|
221
|
-
typeof models['merger'] !== 'string' ||
|
|
222
|
-
typeof models['thinking_level'] !== 'string'
|
|
223
|
-
) {
|
|
224
|
-
fail('details.models is invalid', expected.artifactDir);
|
|
225
|
-
}
|
|
226
|
-
assertOnlyKeys(
|
|
227
|
-
budget,
|
|
228
|
-
[
|
|
229
|
-
'policy_id',
|
|
230
|
-
'calibration_version',
|
|
231
|
-
'route_table',
|
|
232
|
-
'rate_sources',
|
|
233
|
-
'unknown_provider_warnings',
|
|
234
|
-
'calibration_warnings',
|
|
235
|
-
],
|
|
236
|
-
'details.budget',
|
|
237
|
-
expected.artifactDir,
|
|
238
|
-
);
|
|
239
|
-
if (
|
|
240
|
-
typeof budget['policy_id'] !== 'string' ||
|
|
241
|
-
typeof budget['calibration_version'] !== 'string' ||
|
|
242
|
-
!Array.isArray(budget['route_table']) ||
|
|
243
|
-
!Array.isArray(budget['rate_sources']) ||
|
|
244
|
-
!stringArray(budget['unknown_provider_warnings']) ||
|
|
245
|
-
!Array.isArray(budget['calibration_warnings'])
|
|
246
|
-
) {
|
|
247
|
-
fail('details.budget is invalid', expected.artifactDir);
|
|
248
|
-
}
|
|
249
|
-
if (
|
|
250
|
-
!Number.isSafeInteger(value['evaluator_attempts']) ||
|
|
251
|
-
![1, 2].includes(Number(value['evaluator_attempts']))
|
|
252
|
-
) {
|
|
253
|
-
fail('details evaluator_attempts is invalid', expected.artifactDir);
|
|
254
|
-
}
|
|
255
|
-
const checkedUsage = usage(value['usage'], expected.artifactDir);
|
|
256
|
-
const candidates = models['candidates'];
|
|
257
|
-
if (!stringArray(candidates) || candidates.length !== 3)
|
|
258
|
-
fail('details.models candidates are invalid', expected.artifactDir);
|
|
259
|
-
const candidate1 = candidates[0];
|
|
260
|
-
const candidate2 = candidates[1];
|
|
261
|
-
const candidate3 = candidates[2];
|
|
262
|
-
if (candidate1 === undefined || candidate2 === undefined || candidate3 === undefined) {
|
|
263
|
-
fail('details.models candidates are incomplete', expected.artifactDir);
|
|
264
|
-
}
|
|
265
|
-
const source = value['source'];
|
|
266
|
-
const contextKind = context['kind'];
|
|
267
|
-
return {
|
|
268
|
-
schema_version: FUSION_RESULT_SCHEMA_VERSION,
|
|
269
|
-
run_id: expected.runId,
|
|
270
|
-
workflow: expected.workflow,
|
|
271
|
-
source,
|
|
272
|
-
status: 'completed',
|
|
273
|
-
context: { kind: contextKind, policy_id: context['policy_id'] },
|
|
274
|
-
tool_policy: {
|
|
275
|
-
candidate_tools: [...toolPolicy['candidate_tools']],
|
|
276
|
-
evaluation_tools: [],
|
|
277
|
-
merge_tools: [],
|
|
278
|
-
},
|
|
279
|
-
artifact_dir: expected.artifactDir,
|
|
280
|
-
models: {
|
|
281
|
-
candidates: [candidate1, candidate2, candidate3],
|
|
282
|
-
evaluator: models['evaluator'],
|
|
283
|
-
merger: models['merger'],
|
|
284
|
-
thinking_level: models['thinking_level'],
|
|
285
|
-
},
|
|
286
|
-
evaluator_attempts: Number(value['evaluator_attempts']),
|
|
287
|
-
usage: checkedUsage,
|
|
288
|
-
budget: {
|
|
289
|
-
policy_id: budget['policy_id'],
|
|
290
|
-
calibration_version: budget['calibration_version'],
|
|
291
|
-
route_table: budget['route_table'] as FusionResultDetails['budget']['route_table'],
|
|
292
|
-
rate_sources: budget['rate_sources'] as FusionResultDetails['budget']['rate_sources'],
|
|
293
|
-
unknown_provider_warnings: [...budget['unknown_provider_warnings']],
|
|
294
|
-
calibration_warnings: budget[
|
|
295
|
-
'calibration_warnings'
|
|
296
|
-
] as FusionResultDetails['budget']['calibration_warnings'],
|
|
297
|
-
},
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function sameRef(left: FusionArtifactRef, right: FusionArtifactRef): boolean {
|
|
302
|
-
return (
|
|
303
|
-
left.path === right.path &&
|
|
304
|
-
left.byte_length === right.byte_length &&
|
|
305
|
-
left.sha256 === right.sha256
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function readUtf8(
|
|
310
|
-
path: string,
|
|
311
|
-
label: string,
|
|
312
|
-
artifactDir: string,
|
|
313
|
-
): Promise<{ bytes: Buffer; text: string }> {
|
|
314
|
-
let bytes: Buffer;
|
|
315
|
-
try {
|
|
316
|
-
bytes = await readFile(path);
|
|
317
|
-
} catch (error) {
|
|
318
|
-
fail(
|
|
319
|
-
`${label} is unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
320
|
-
artifactDir,
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
let text: string;
|
|
324
|
-
try {
|
|
325
|
-
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
326
|
-
} catch {
|
|
327
|
-
fail(`${label} is not well-formed UTF-8`, artifactDir);
|
|
328
|
-
}
|
|
329
|
-
return { bytes, text };
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/** Failure retrieval has an additional bounded, no-symlink evidence-file policy. */
|
|
333
|
-
async function readFailureUtf8(
|
|
334
|
-
path: string,
|
|
335
|
-
label: string,
|
|
336
|
-
artifactDir: string,
|
|
337
|
-
maxBytes: number,
|
|
338
|
-
): Promise<{ bytes: Buffer; text: string }> {
|
|
339
|
-
try {
|
|
340
|
-
const metadata = await lstat(path);
|
|
341
|
-
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
342
|
-
fail(`${label} is not a regular artifact file`, artifactDir);
|
|
343
|
-
}
|
|
344
|
-
if (metadata.size > maxBytes) fail(`${label} exceeds its bounded artifact size`, artifactDir);
|
|
345
|
-
} catch (error) {
|
|
346
|
-
if (error instanceof FusionError) throw error;
|
|
347
|
-
fail(
|
|
348
|
-
`${label} is unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
349
|
-
artifactDir,
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
const file = await readUtf8(path, label, artifactDir);
|
|
353
|
-
if (file.bytes.length > maxBytes) fail(`${label} exceeds its bounded artifact size`, artifactDir);
|
|
354
|
-
return file;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
export interface ReadFusionCommittedResultOptions {
|
|
358
|
-
artifactDirAbs: string;
|
|
359
|
-
artifactDir: string;
|
|
360
|
-
runId: string;
|
|
361
|
-
workflow: FusionWorkflowId;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/** Verify the manifest-bound Fusion commit before returning merged bytes. */
|
|
365
|
-
export async function readFusionCommittedResult(
|
|
366
|
-
options: ReadFusionCommittedResultOptions,
|
|
367
|
-
): Promise<FusionRunResult> {
|
|
368
|
-
const manifestFile = await readUtf8(
|
|
369
|
-
join(options.artifactDirAbs, 'manifest.json'),
|
|
370
|
-
'manifest.json',
|
|
371
|
-
options.artifactDir,
|
|
372
|
-
);
|
|
373
|
-
let manifestValue: unknown;
|
|
374
|
-
try {
|
|
375
|
-
manifestValue = parseJsonText(manifestFile.text);
|
|
376
|
-
} catch (error) {
|
|
377
|
-
fail(
|
|
378
|
-
`manifest.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
379
|
-
options.artifactDir,
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
if (!isRecord(manifestValue)) fail('manifest.json must be an object', options.artifactDir);
|
|
383
|
-
if (manifestValue['schema_version'] !== FUSION_MANIFEST_SCHEMA_VERSION)
|
|
384
|
-
fail('manifest schema version mismatch', options.artifactDir);
|
|
385
|
-
if (manifestValue['run_id'] !== options.runId || manifestValue['workflow'] !== options.workflow)
|
|
386
|
-
fail('manifest identity mismatch', options.artifactDir);
|
|
387
|
-
if (manifestValue['state'] !== 'completed')
|
|
388
|
-
fail('manifest is not committed', options.artifactDir);
|
|
389
|
-
const artifacts = manifestValue['artifacts'];
|
|
390
|
-
if (!isRecord(artifacts)) fail('manifest artifacts map is invalid', options.artifactDir);
|
|
391
|
-
const manifestMerged = artifactRef(
|
|
392
|
-
artifacts['merged.md'],
|
|
393
|
-
'manifest artifacts merged.md',
|
|
394
|
-
options.artifactDir,
|
|
395
|
-
);
|
|
396
|
-
const manifestResult = artifactRef(
|
|
397
|
-
artifacts['result.json'],
|
|
398
|
-
'manifest artifacts result.json',
|
|
399
|
-
options.artifactDir,
|
|
400
|
-
);
|
|
401
|
-
if (manifestMerged.path !== 'merged.md' || manifestResult.path !== 'result.json')
|
|
402
|
-
fail('manifest fixed artifact paths are invalid', options.artifactDir);
|
|
403
|
-
|
|
404
|
-
const resultFile = await readUtf8(
|
|
405
|
-
join(options.artifactDirAbs, 'result.json'),
|
|
406
|
-
'result.json',
|
|
407
|
-
options.artifactDir,
|
|
408
|
-
);
|
|
409
|
-
if (
|
|
410
|
-
resultFile.bytes.length !== manifestResult.byte_length ||
|
|
411
|
-
sha256Buffer(resultFile.bytes) !== manifestResult.sha256
|
|
412
|
-
) {
|
|
413
|
-
fail('result.json does not match its manifest hash and length', options.artifactDir);
|
|
414
|
-
}
|
|
415
|
-
let resultValue: unknown;
|
|
416
|
-
try {
|
|
417
|
-
resultValue = parseJsonText(resultFile.text);
|
|
418
|
-
} catch (error) {
|
|
419
|
-
fail(
|
|
420
|
-
`result.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
421
|
-
options.artifactDir,
|
|
422
|
-
);
|
|
423
|
-
}
|
|
424
|
-
if (!isRecord(resultValue)) fail('result.json must be an object', options.artifactDir);
|
|
425
|
-
assertOnlyKeys(
|
|
426
|
-
resultValue,
|
|
427
|
-
['schema_version', 'run_id', 'merged', 'details'],
|
|
428
|
-
'result.json',
|
|
429
|
-
options.artifactDir,
|
|
430
|
-
);
|
|
431
|
-
if (
|
|
432
|
-
resultValue['schema_version'] !== FUSION_COMMITTED_RESULT_SCHEMA_VERSION ||
|
|
433
|
-
resultValue['run_id'] !== options.runId
|
|
434
|
-
) {
|
|
435
|
-
fail('result.json identity mismatch', options.artifactDir);
|
|
436
|
-
}
|
|
437
|
-
const committedMerged = artifactRef(
|
|
438
|
-
resultValue['merged'],
|
|
439
|
-
'result.json merged',
|
|
440
|
-
options.artifactDir,
|
|
441
|
-
);
|
|
442
|
-
if (!sameRef(committedMerged, manifestMerged))
|
|
443
|
-
fail('result.json merged reference does not match manifest', options.artifactDir);
|
|
444
|
-
const details = resultDetails(resultValue['details'], options);
|
|
445
|
-
|
|
446
|
-
const mergedFile = await readUtf8(
|
|
447
|
-
join(options.artifactDirAbs, 'merged.md'),
|
|
448
|
-
'merged.md',
|
|
449
|
-
options.artifactDir,
|
|
450
|
-
);
|
|
451
|
-
if (
|
|
452
|
-
mergedFile.bytes.length !== committedMerged.byte_length ||
|
|
453
|
-
sha256Buffer(mergedFile.bytes) !== committedMerged.sha256
|
|
454
|
-
) {
|
|
455
|
-
fail('merged.md does not match its committed hash and length', options.artifactDir);
|
|
456
|
-
}
|
|
457
|
-
return { mergedText: mergedFile.text, details };
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// The public tool adds a small task envelope and text receipt around this view.
|
|
461
|
-
// Keep the verified details below 8 KiB even after that model-visible envelope.
|
|
462
|
-
const FAILURE_VIEW_MAX_BYTES = 6 * 1024;
|
|
463
|
-
const FAILURE_CODES = new Set([
|
|
464
|
-
'config_invalid', 'config_conflict', 'model_unavailable', 'context_capture_failed',
|
|
465
|
-
'context_policy_unsupported_block', 'prompt_budget_exceeded_forecast',
|
|
466
|
-
'prompt_budget_exceeded_measured', 'model_capacity_unknown', 'child_spawn_failed',
|
|
467
|
-
'child_stdin_failed', 'child_event_invalid', 'child_exit_failed',
|
|
468
|
-
'child_runtime_limit_exceeded', 'child_runtime_payload_invalid',
|
|
469
|
-
'child_cache_policy_invalid', 'child_timeout', 'child_output_cap', 'child_cancelled',
|
|
470
|
-
'evaluation_invalid', 'artifact_error', 'state_transition_invalid', 'orchestration_failed',
|
|
471
|
-
]);
|
|
472
|
-
const FAILURE_REMEDIATION_IDS = new Set([
|
|
473
|
-
'inspect_manifest_bound_evidence', 'inspect_terminal_error', 'split_or_reduce_work',
|
|
474
|
-
'retry_same_route_after_operator_review',
|
|
475
|
-
]);
|
|
476
|
-
const FAILURE_CLASSIFICATIONS = new Set([
|
|
477
|
-
'complete_stage_output', 'partial_stage_output', 'oversized_original',
|
|
478
|
-
'empty_rejected_output', 'evidence_only',
|
|
479
|
-
]);
|
|
480
|
-
|
|
481
|
-
interface TrustedFailureManifest {
|
|
482
|
-
schemaVersion: string;
|
|
483
|
-
source: FusionSource;
|
|
484
|
-
state: 'failed' | 'cancelled';
|
|
485
|
-
usage: FusionUsage;
|
|
486
|
-
artifacts: Readonly<Record<string, FusionArtifactRef>>;
|
|
487
|
-
attempts: readonly FusionFailureAttemptMetadata[];
|
|
488
|
-
classifications: Readonly<Record<string, FusionFailureEvidenceArtifact['classification']>>;
|
|
489
|
-
error?: string | undefined;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function failureUnavailable(
|
|
493
|
-
state: 'failed' | 'cancelled',
|
|
494
|
-
status: 'unavailable' | 'integrity_failed',
|
|
495
|
-
): FusionFailureResultView {
|
|
496
|
-
return {
|
|
497
|
-
schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
|
|
498
|
-
summary_status: status,
|
|
499
|
-
terminal_state: state,
|
|
500
|
-
answer: { present: false, reason: 'run_did_not_commit' },
|
|
501
|
-
summary_unavailable_reason: status === 'unavailable' ? 'manifest_untrusted' : 'summary_integrity_failed',
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function failureString(value: unknown, label: string): string {
|
|
506
|
-
if (typeof value !== 'string') throw new Error(`${label} must be a string`);
|
|
507
|
-
return value;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function failureInteger(value: unknown, label: string): number {
|
|
511
|
-
if (!Number.isSafeInteger(value) || Number(value) < 0)
|
|
512
|
-
throw new Error(`${label} must be a nonnegative integer`);
|
|
513
|
-
return Number(value);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
function compareFailureText(left: string, right: string): number {
|
|
517
|
-
return left < right ? -1 : left > right ? 1 : 0;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function failureStage(value: unknown, label: string): FusionStage {
|
|
521
|
-
if (value === 'candidate' || value === 'evaluation' || value === 'merge') return value;
|
|
522
|
-
throw new Error(`${label} is invalid`);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
function failureUsage(value: unknown, artifactDir: string): FusionUsage {
|
|
526
|
-
return usage(value, artifactDir);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function failureRef(value: unknown, label: string, artifactDir: string): FusionArtifactRef {
|
|
530
|
-
return artifactRef(value, label, artifactDir);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function sameFailureRef(left: FusionArtifactRef, right: FusionArtifactRef): boolean {
|
|
534
|
-
return left.path === right.path && left.byte_length === right.byte_length && left.sha256 === right.sha256;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function trustedFailureManifest(
|
|
538
|
-
value: unknown,
|
|
539
|
-
options: ReadFusionCommittedResultOptions,
|
|
540
|
-
): TrustedFailureManifest {
|
|
541
|
-
if (!isRecord(value)) throw new Error('manifest must be an object');
|
|
542
|
-
const schemaVersion = value['schema_version'];
|
|
543
|
-
if (schemaVersion !== FUSION_MANIFEST_SCHEMA_VERSION && schemaVersion !== FUSION_LEGACY_MANIFEST_SCHEMA_VERSION)
|
|
544
|
-
throw new Error('manifest schema version mismatch');
|
|
545
|
-
if (value['run_id'] !== options.runId || value['workflow'] !== options.workflow)
|
|
546
|
-
throw new Error('manifest identity mismatch');
|
|
547
|
-
const state = value['state'];
|
|
548
|
-
if (state !== 'failed' && state !== 'cancelled') throw new Error('manifest is not failed or cancelled');
|
|
549
|
-
const source = value['source'];
|
|
550
|
-
if (source !== 'command' && source !== 'tool') throw new Error('manifest source is invalid');
|
|
551
|
-
const artifactsValue = value['artifacts'];
|
|
552
|
-
if (!isRecord(artifactsValue)) throw new Error('manifest artifacts are invalid');
|
|
553
|
-
const artifacts: Record<string, FusionArtifactRef> = {};
|
|
554
|
-
for (const [name, ref] of Object.entries(artifactsValue)) {
|
|
555
|
-
assertFusionArtifactBasename(name);
|
|
556
|
-
const checked = failureRef(ref, `manifest artifact ${name}`, options.artifactDir);
|
|
557
|
-
if (checked.path !== name) throw new Error('manifest artifact key/ref divergence');
|
|
558
|
-
artifacts[name] = checked;
|
|
559
|
-
}
|
|
560
|
-
const attemptsValue = value['attempts'];
|
|
561
|
-
if (!Array.isArray(attemptsValue)) throw new Error('manifest attempts are invalid');
|
|
562
|
-
const attempts: FusionFailureAttemptMetadata[] = [];
|
|
563
|
-
const classifications: Record<string, FusionFailureEvidenceArtifact['classification']> = {};
|
|
564
|
-
const attemptArtifact = (entry: unknown, label: string): string | undefined => {
|
|
565
|
-
if (entry === undefined) return undefined;
|
|
566
|
-
const name = failureString(entry, label);
|
|
567
|
-
assertFusionArtifactBasename(name);
|
|
568
|
-
if (artifacts[name] === undefined) throw new Error(`${label} is not manifest-bound`);
|
|
569
|
-
return name;
|
|
570
|
-
};
|
|
571
|
-
for (const attemptValue of attemptsValue) {
|
|
572
|
-
if (!isRecord(attemptValue)) throw new Error('manifest attempt is invalid');
|
|
573
|
-
const metadata = failureAttempt({
|
|
574
|
-
stage: attemptValue['stage'], slot: attemptValue['slot'], attempt: attemptValue['attempt'],
|
|
575
|
-
status: attemptValue['status'], child_created: attemptValue['child_created'],
|
|
576
|
-
});
|
|
577
|
-
attempts.push(metadata);
|
|
578
|
-
const response = attemptArtifact(attemptValue['response_path'], 'manifest response_path');
|
|
579
|
-
if (response !== undefined) {
|
|
580
|
-
const responseRef = artifacts[response];
|
|
581
|
-
if (responseRef === undefined) throw new Error('manifest response_path is not manifest-bound');
|
|
582
|
-
classifications[response] =
|
|
583
|
-
responseRef.byte_length === 0 && metadata.status !== 'completed'
|
|
584
|
-
? 'empty_rejected_output'
|
|
585
|
-
: 'complete_stage_output';
|
|
586
|
-
}
|
|
587
|
-
const partial = attemptArtifact(
|
|
588
|
-
attemptValue['partial_response_path'],
|
|
589
|
-
'manifest partial_response_path',
|
|
590
|
-
);
|
|
591
|
-
if (partial !== undefined) classifications[partial] = 'partial_stage_output';
|
|
592
|
-
const recovery = attemptValue['output_recovery'];
|
|
593
|
-
if (recovery !== undefined) {
|
|
594
|
-
if (!isRecord(recovery)) throw new Error('manifest output_recovery is invalid');
|
|
595
|
-
const original = attemptArtifact(
|
|
596
|
-
recovery['original_response_path'],
|
|
597
|
-
'manifest output_recovery.original_response_path',
|
|
598
|
-
);
|
|
599
|
-
if (original === undefined) throw new Error('manifest output recovery has no original response');
|
|
600
|
-
classifications[original] = 'oversized_original';
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
attempts.sort((left, right) =>
|
|
604
|
-
compareFailureText(left.stage, right.stage) ||
|
|
605
|
-
(left.slot ?? 0) - (right.slot ?? 0) ||
|
|
606
|
-
left.attempt - right.attempt,
|
|
607
|
-
);
|
|
608
|
-
const manifestUsage = failureUsage(value['usage'], options.artifactDir);
|
|
609
|
-
const error = value['error'];
|
|
610
|
-
if (error !== undefined && typeof error !== 'string') throw new Error('manifest error is invalid');
|
|
611
|
-
return { schemaVersion, source, state, usage: manifestUsage, artifacts, attempts, classifications, ...(error === undefined ? {} : { error }) };
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function failureMessage(value: unknown): FusionFailureSummaryV1['failure']['message'] {
|
|
615
|
-
if (!isRecord(value)) throw new Error('failure message is invalid');
|
|
616
|
-
assertOnlyKeys(value, ['byte_length', 'sha256', 'inline_message', 'omission_reason'], 'failure message', 'failure-summary.json');
|
|
617
|
-
const byteLength = failureInteger(value['byte_length'], 'failure message byte_length');
|
|
618
|
-
const sha256 = failureString(value['sha256'], 'failure message sha256');
|
|
619
|
-
if (!SHA256_PATTERN.test(sha256)) throw new Error('failure message sha256 is invalid');
|
|
620
|
-
const inline = value['inline_message'];
|
|
621
|
-
const omission = value['omission_reason'];
|
|
622
|
-
if ((inline === undefined) === (omission === undefined)) throw new Error('failure message must have exactly one representation');
|
|
623
|
-
if (inline !== undefined) {
|
|
624
|
-
if (
|
|
625
|
-
typeof inline !== 'string' ||
|
|
626
|
-
byteLength > FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES ||
|
|
627
|
-
Buffer.byteLength(inline, 'utf8') !== byteLength ||
|
|
628
|
-
sha256Buffer(Buffer.from(inline, 'utf8')) !== sha256
|
|
629
|
-
) {
|
|
630
|
-
throw new Error('failure inline message does not match its metadata');
|
|
631
|
-
}
|
|
632
|
-
return { byte_length: byteLength, sha256, inline_message: inline };
|
|
633
|
-
}
|
|
634
|
-
if (omission !== 'exceeds_inline_message_bytes_cap')
|
|
635
|
-
throw new Error('failure message omission reason is invalid');
|
|
636
|
-
return { byte_length: byteLength, sha256, omission_reason: omission };
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
function failureList<T>(
|
|
640
|
-
value: unknown,
|
|
641
|
-
label: string,
|
|
642
|
-
cap: number,
|
|
643
|
-
parseEntry: (entry: unknown) => T,
|
|
644
|
-
): FusionFailureList<T> {
|
|
645
|
-
if (!isRecord(value)) throw new Error(`${label} is invalid`);
|
|
646
|
-
assertOnlyKeys(value, ['listed', 'omitted_count'], label, 'failure-summary.json');
|
|
647
|
-
if (!Array.isArray(value['listed']) || value['listed'].length > cap)
|
|
648
|
-
throw new Error(`${label}.listed is invalid`);
|
|
649
|
-
return {
|
|
650
|
-
listed: value['listed'].map(parseEntry),
|
|
651
|
-
omitted_count: failureInteger(value['omitted_count'], `${label}.omitted_count`),
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function failureAttempt(value: unknown): FusionFailureAttemptMetadata {
|
|
656
|
-
if (!isRecord(value)) throw new Error('failure attempt is invalid');
|
|
657
|
-
assertOnlyKeys(value, ['stage', 'slot', 'attempt', 'status', 'child_created'], 'failure attempt', 'failure-summary.json');
|
|
658
|
-
const stage = failureStage(value['stage'], 'failure attempt stage');
|
|
659
|
-
const slot = value['slot'];
|
|
660
|
-
if (slot !== undefined && slot !== 1 && slot !== 2 && slot !== 3)
|
|
661
|
-
throw new Error('failure attempt slot is invalid');
|
|
662
|
-
if ((stage === 'candidate') !== (slot !== undefined))
|
|
663
|
-
throw new Error('failure attempt stage/slot is inconsistent');
|
|
664
|
-
const status = value['status'];
|
|
665
|
-
if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') throw new Error('failure attempt status is invalid');
|
|
666
|
-
if (typeof value['child_created'] !== 'boolean') throw new Error('failure attempt child_created is invalid');
|
|
667
|
-
const attempt = failureInteger(value['attempt'], 'failure attempt number');
|
|
668
|
-
if (attempt === 0) throw new Error('failure attempt number must be positive');
|
|
669
|
-
return { stage, ...(slot === undefined ? {} : { slot }), attempt, status, child_created: value['child_created'] };
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function failureEvidence(
|
|
673
|
-
value: unknown,
|
|
674
|
-
manifest: TrustedFailureManifest,
|
|
675
|
-
artifactDir: string,
|
|
676
|
-
): FusionFailureEvidenceArtifact {
|
|
677
|
-
if (!isRecord(value)) throw new Error('failure evidence row is invalid');
|
|
678
|
-
assertOnlyKeys(value, ['name', 'classification', 'ref'], 'failure evidence row', artifactDir);
|
|
679
|
-
const name = failureString(value['name'], 'failure evidence name');
|
|
680
|
-
assertFusionArtifactBasename(name);
|
|
681
|
-
const classification = value['classification'];
|
|
682
|
-
if (typeof classification !== 'string' || !FAILURE_CLASSIFICATIONS.has(classification))
|
|
683
|
-
throw new Error('failure evidence classification is invalid');
|
|
684
|
-
const ref = failureRef(value['ref'], 'failure evidence ref', artifactDir);
|
|
685
|
-
const manifestRef = manifest.artifacts[name];
|
|
686
|
-
if (manifestRef === undefined || !sameFailureRef(ref, manifestRef))
|
|
687
|
-
throw new Error('failure evidence ref diverges from manifest');
|
|
688
|
-
const expectedClassification = manifest.classifications[name] ?? 'evidence_only';
|
|
689
|
-
if (classification !== expectedClassification) throw new Error('failure evidence classification diverges from manifest');
|
|
690
|
-
return { name, classification: expectedClassification, ref };
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
function failureProgress(value: unknown, manifest: TrustedFailureManifest, artifactDir: string): FusionRunProgress {
|
|
694
|
-
if (!isRecord(value)) throw new Error('failure progress is invalid');
|
|
695
|
-
assertOnlyKeys(value, ['manifest_state', 'candidates', 'evaluation', 'merge', 'usage_so_far'], 'failure progress', artifactDir);
|
|
696
|
-
if (value['manifest_state'] !== manifest.state) throw new Error('failure progress state diverges from manifest');
|
|
697
|
-
const stage = (entry: unknown, label: string, candidates: boolean): FusionRunProgress['candidates'] => {
|
|
698
|
-
if (!isRecord(entry)) throw new Error(`${label} is invalid`);
|
|
699
|
-
assertOnlyKeys(entry, ['status', 'attempts_recorded', 'children_created', 'children_completed', 'children_failed', 'children_cancelled', 'not_started_slots'], label, artifactDir);
|
|
700
|
-
const status = entry['status'];
|
|
701
|
-
if (status !== 'not_started' && status !== 'incomplete' && status !== 'completed') throw new Error(`${label}.status is invalid`);
|
|
702
|
-
const notStarted = entry['not_started_slots'];
|
|
703
|
-
if (candidates ? !Number.isSafeInteger(notStarted) || Number(notStarted) < 0 || Number(notStarted) > 3 : notStarted !== undefined)
|
|
704
|
-
throw new Error(`${label}.not_started_slots is invalid`);
|
|
705
|
-
return {
|
|
706
|
-
status,
|
|
707
|
-
attempts_recorded: failureInteger(entry['attempts_recorded'], `${label}.attempts_recorded`),
|
|
708
|
-
children_created: failureInteger(entry['children_created'], `${label}.children_created`),
|
|
709
|
-
children_completed: failureInteger(entry['children_completed'], `${label}.children_completed`),
|
|
710
|
-
children_failed: failureInteger(entry['children_failed'], `${label}.children_failed`),
|
|
711
|
-
children_cancelled: failureInteger(entry['children_cancelled'], `${label}.children_cancelled`),
|
|
712
|
-
...(candidates ? { not_started_slots: Number(notStarted) } : {}),
|
|
713
|
-
};
|
|
714
|
-
};
|
|
715
|
-
const usageSoFar = failureUsage(value['usage_so_far'], artifactDir);
|
|
716
|
-
if (canonicalJson(usageSoFar) !== canonicalJson(manifest.usage)) throw new Error('failure progress usage diverges from manifest');
|
|
717
|
-
return { manifest_state: manifest.state, candidates: stage(value['candidates'], 'failure candidates', true), evaluation: stage(value['evaluation'], 'failure evaluation', false), merge: stage(value['merge'], 'failure merge', false), usage_so_far: usageSoFar };
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function parseFailureSummary(
|
|
721
|
-
value: unknown,
|
|
722
|
-
manifest: TrustedFailureManifest,
|
|
723
|
-
options: ReadFusionCommittedResultOptions,
|
|
724
|
-
): FusionFailureSummaryV1 {
|
|
725
|
-
if (!isRecord(value)) throw new Error('failure summary must be an object');
|
|
726
|
-
assertOnlyKeys(value, ['schema_version', 'run_id', 'workflow', 'source', 'terminal_state', 'created_at', 'answer', 'failure', 'progress', 'usage_so_far', 'attempts', 'evidence_artifacts', 'remediation_ids'], 'failure summary', options.artifactDir);
|
|
727
|
-
if (value['schema_version'] !== FUSION_FAILURE_SUMMARY_SCHEMA_VERSION || value['run_id'] !== options.runId || value['workflow'] !== options.workflow || value['source'] !== manifest.source || value['terminal_state'] !== manifest.state || typeof value['created_at'] !== 'string')
|
|
728
|
-
throw new Error('failure summary identity is invalid');
|
|
729
|
-
const answer = value['answer'];
|
|
730
|
-
if (!isRecord(answer) || answer['present'] !== false || answer['reason'] !== 'run_did_not_commit') throw new Error('failure summary answer assertion is invalid');
|
|
731
|
-
const failure = value['failure'];
|
|
732
|
-
if (!isRecord(failure)) throw new Error('failure summary failure metadata is invalid');
|
|
733
|
-
assertOnlyKeys(failure, ['code', 'stage', 'slot', 'attempt', 'child_created', 'message'], 'failure metadata', options.artifactDir);
|
|
734
|
-
const code = failure['code'];
|
|
735
|
-
if (code !== null && (typeof code !== 'string' || !FAILURE_CODES.has(code))) throw new Error('failure code is invalid');
|
|
736
|
-
const stageValue = failure['stage'];
|
|
737
|
-
const stage = stageValue === undefined ? undefined : failureStage(stageValue, 'failure stage');
|
|
738
|
-
const slot = failure['slot'];
|
|
739
|
-
if (slot !== undefined && slot !== 1 && slot !== 2 && slot !== 3) throw new Error('failure slot is invalid');
|
|
740
|
-
if (failure['attempt'] !== undefined) failureInteger(failure['attempt'], 'failure attempt');
|
|
741
|
-
if (typeof failure['child_created'] !== 'boolean') throw new Error('failure child_created is invalid');
|
|
742
|
-
const progress = failureProgress(value['progress'], manifest, options.artifactDir);
|
|
743
|
-
const expectedProgress = buildFusionRunProgress(manifest);
|
|
744
|
-
if (canonicalJson(progress) !== canonicalJson(expectedProgress))
|
|
745
|
-
throw new Error('failure progress diverges from durable attempts');
|
|
746
|
-
const usageSoFar = failureUsage(value['usage_so_far'], options.artifactDir);
|
|
747
|
-
if (canonicalJson(usageSoFar) !== canonicalJson(manifest.usage))
|
|
748
|
-
throw new Error('summary usage diverges from manifest');
|
|
749
|
-
const attempts = failureList(
|
|
750
|
-
value['attempts'],
|
|
751
|
-
'failure attempts',
|
|
752
|
-
FUSION_FAILURE_SUMMARY_ATTEMPT_CAP,
|
|
753
|
-
failureAttempt,
|
|
754
|
-
);
|
|
755
|
-
const expectedAttempts = manifest.attempts;
|
|
756
|
-
const expectedAttemptListedCount = Math.min(
|
|
757
|
-
expectedAttempts.length,
|
|
758
|
-
FUSION_FAILURE_SUMMARY_ATTEMPT_CAP,
|
|
759
|
-
);
|
|
760
|
-
if (
|
|
761
|
-
attempts.listed.length !== expectedAttemptListedCount ||
|
|
762
|
-
attempts.omitted_count !== expectedAttempts.length - expectedAttemptListedCount ||
|
|
763
|
-
canonicalJson(attempts.listed) !==
|
|
764
|
-
canonicalJson(
|
|
765
|
-
expectedAttempts.filter((_attempt, index) => index < expectedAttemptListedCount),
|
|
766
|
-
)
|
|
767
|
-
) {
|
|
768
|
-
throw new Error('failure attempt metadata diverges from manifest');
|
|
769
|
-
}
|
|
770
|
-
const evidence = failureList(
|
|
771
|
-
value['evidence_artifacts'],
|
|
772
|
-
'failure evidence artifacts',
|
|
773
|
-
FUSION_FAILURE_SUMMARY_EVIDENCE_CAP,
|
|
774
|
-
(entry) => failureEvidence(entry, manifest, options.artifactDir),
|
|
775
|
-
);
|
|
776
|
-
const expectedEvidence = Object.entries(manifest.artifacts)
|
|
777
|
-
.filter(([name]) => name !== 'failure-summary.json')
|
|
778
|
-
.map(([name, ref]) => ({ name, classification: manifest.classifications[name] ?? 'evidence_only', ref }))
|
|
779
|
-
.sort((left, right) => compareFailureText(left.name, right.name));
|
|
780
|
-
const expectedEvidenceListedCount = Math.min(
|
|
781
|
-
expectedEvidence.length,
|
|
782
|
-
FUSION_FAILURE_SUMMARY_EVIDENCE_CAP,
|
|
783
|
-
);
|
|
784
|
-
if (
|
|
785
|
-
evidence.listed.length !== expectedEvidenceListedCount ||
|
|
786
|
-
evidence.omitted_count !== expectedEvidence.length - expectedEvidenceListedCount ||
|
|
787
|
-
canonicalJson(evidence.listed) !==
|
|
788
|
-
canonicalJson(
|
|
789
|
-
expectedEvidence.filter((_evidence, index) => index < expectedEvidenceListedCount),
|
|
790
|
-
)
|
|
791
|
-
) {
|
|
792
|
-
throw new Error('failure evidence metadata diverges from manifest');
|
|
793
|
-
}
|
|
794
|
-
const remediationIds = value['remediation_ids'];
|
|
795
|
-
const expectedRemediationIds = [
|
|
796
|
-
'inspect_manifest_bound_evidence',
|
|
797
|
-
'inspect_terminal_error',
|
|
798
|
-
'split_or_reduce_work',
|
|
799
|
-
'retry_same_route_after_operator_review',
|
|
800
|
-
];
|
|
801
|
-
if (
|
|
802
|
-
!Array.isArray(remediationIds) ||
|
|
803
|
-
!remediationIds.every((id) => typeof id === 'string' && FAILURE_REMEDIATION_IDS.has(id)) ||
|
|
804
|
-
canonicalJson(remediationIds) !== canonicalJson(expectedRemediationIds)
|
|
805
|
-
) {
|
|
806
|
-
throw new Error('failure remediation ids are invalid');
|
|
807
|
-
}
|
|
808
|
-
const terminalMessage = failureMessage(failure['message']);
|
|
809
|
-
if (manifest.error === undefined) throw new Error('manifest terminal error is unavailable');
|
|
810
|
-
const manifestErrorBytes = Buffer.from(manifest.error, 'utf8');
|
|
811
|
-
const expectedMessage = {
|
|
812
|
-
byte_length: manifestErrorBytes.length,
|
|
813
|
-
sha256: sha256Buffer(manifestErrorBytes),
|
|
814
|
-
...(manifestErrorBytes.length <= FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES
|
|
815
|
-
? { inline_message: manifest.error }
|
|
816
|
-
: { omission_reason: 'exceeds_inline_message_bytes_cap' as const }),
|
|
817
|
-
};
|
|
818
|
-
if (canonicalJson(terminalMessage) !== canonicalJson(expectedMessage))
|
|
819
|
-
throw new Error('failure message diverges from manifest terminal error');
|
|
820
|
-
return {
|
|
821
|
-
schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION, run_id: options.runId, workflow: options.workflow,
|
|
822
|
-
source: manifest.source, terminal_state: manifest.state, created_at: value['created_at'],
|
|
823
|
-
answer: { present: false, reason: 'run_did_not_commit' },
|
|
824
|
-
failure: { code: code as FusionFailureSummaryV1['failure']['code'], ...(stage === undefined ? {} : { stage }), ...(slot === undefined ? {} : { slot }), ...(failure['attempt'] === undefined ? {} : { attempt: Number(failure['attempt']) }), child_created: failure['child_created'], message: terminalMessage },
|
|
825
|
-
progress, usage_so_far: usageSoFar, attempts, evidence_artifacts: evidence,
|
|
826
|
-
remediation_ids: [...remediationIds] as FusionFailureSummaryV1['remediation_ids'],
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
interface FailureViewSource {
|
|
831
|
-
terminal_state: Exclude<FusionFailureResultView['terminal_state'], 'completed'>;
|
|
832
|
-
failure?: FusionFailureResultView['failure'] | undefined;
|
|
833
|
-
progress: FusionRunProgress;
|
|
834
|
-
usage_so_far: FusionUsage;
|
|
835
|
-
attempts: FusionFailureList<FusionFailureAttemptMetadata>;
|
|
836
|
-
evidence_artifacts: FusionFailureList<FusionFailureEvidenceArtifact>;
|
|
837
|
-
remediation_ids: FusionFailureSummaryV1['remediation_ids'];
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
function boundedFailureView(
|
|
841
|
-
source: FailureViewSource,
|
|
842
|
-
status: FusionFailureResultView['summary_status'],
|
|
843
|
-
summaryRef?: FusionArtifactRef,
|
|
844
|
-
): FusionFailureResultView {
|
|
845
|
-
const attempts = { listed: [...source.attempts.listed], omitted_count: source.attempts.omitted_count };
|
|
846
|
-
const evidence = { listed: [...source.evidence_artifacts.listed], omitted_count: source.evidence_artifacts.omitted_count };
|
|
847
|
-
const failure = source.failure;
|
|
848
|
-
const view: FusionFailureResultView = {
|
|
849
|
-
schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
|
|
850
|
-
summary_status: status,
|
|
851
|
-
terminal_state: source.terminal_state,
|
|
852
|
-
answer: { present: false, reason: 'run_did_not_commit' },
|
|
853
|
-
...(failure === undefined
|
|
854
|
-
? {}
|
|
855
|
-
: { failure: { ...failure, message: { ...failure.message } } }),
|
|
856
|
-
progress: source.progress,
|
|
857
|
-
usage_so_far: source.usage_so_far,
|
|
858
|
-
attempts,
|
|
859
|
-
evidence_artifacts: evidence,
|
|
860
|
-
remediation_ids: source.remediation_ids,
|
|
861
|
-
...(summaryRef === undefined ? {} : { failure_summary_ref: summaryRef }),
|
|
862
|
-
};
|
|
863
|
-
const fits = (): boolean => Buffer.byteLength(canonicalJson(view), 'utf8') <= FAILURE_VIEW_MAX_BYTES;
|
|
864
|
-
if (!fits() && view.failure?.message.inline_message !== undefined) {
|
|
865
|
-
const message = view.failure.message;
|
|
866
|
-
view.failure = {
|
|
867
|
-
...view.failure,
|
|
868
|
-
message: {
|
|
869
|
-
byte_length: message.byte_length,
|
|
870
|
-
sha256: message.sha256,
|
|
871
|
-
omission_reason: 'result_view_byte_budget',
|
|
872
|
-
},
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
while (!fits() && evidence.listed.length > 0) { evidence.listed.pop(); evidence.omitted_count += 1; }
|
|
876
|
-
while (!fits() && attempts.listed.length > 0) { attempts.listed.pop(); attempts.omitted_count += 1; }
|
|
877
|
-
if (!fits()) throw new Error('failure result view exceeds its byte budget without a safe whole-section omission');
|
|
878
|
-
return view;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
function legacyFailureSource(
|
|
882
|
-
manifest: TrustedFailureManifest,
|
|
883
|
-
): FailureViewSource {
|
|
884
|
-
const message =
|
|
885
|
-
manifest.error === undefined
|
|
886
|
-
? undefined
|
|
887
|
-
: (() => {
|
|
888
|
-
const bytes = Buffer.from(manifest.error, 'utf8');
|
|
889
|
-
return bytes.length <= FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES
|
|
890
|
-
? {
|
|
891
|
-
byte_length: bytes.length,
|
|
892
|
-
sha256: sha256Buffer(bytes),
|
|
893
|
-
inline_message: manifest.error,
|
|
894
|
-
}
|
|
895
|
-
: {
|
|
896
|
-
byte_length: bytes.length,
|
|
897
|
-
sha256: sha256Buffer(bytes),
|
|
898
|
-
omission_reason: 'exceeds_inline_message_bytes_cap' as const,
|
|
899
|
-
};
|
|
900
|
-
})();
|
|
901
|
-
const evidence = Object.entries(manifest.artifacts)
|
|
902
|
-
.map(([name, ref]) => ({
|
|
903
|
-
name,
|
|
904
|
-
classification: manifest.classifications[name] ?? 'evidence_only',
|
|
905
|
-
ref: { ...ref },
|
|
906
|
-
}))
|
|
907
|
-
.sort((left, right) => compareFailureText(left.name, right.name));
|
|
908
|
-
const attempts = manifest.attempts;
|
|
909
|
-
return {
|
|
910
|
-
terminal_state: manifest.state,
|
|
911
|
-
...(message === undefined ? {} : { failure: { message } }),
|
|
912
|
-
progress: buildFusionRunProgress(manifest),
|
|
913
|
-
usage_so_far: manifest.usage,
|
|
914
|
-
attempts: {
|
|
915
|
-
listed: attempts.filter((_attempt, index) => index < FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
|
|
916
|
-
omitted_count: attempts.length - Math.min(attempts.length, FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
|
|
917
|
-
},
|
|
918
|
-
evidence_artifacts: {
|
|
919
|
-
listed: evidence.filter((_evidence, index) => index < FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
|
|
920
|
-
omitted_count: evidence.length - Math.min(evidence.length, FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
|
|
921
|
-
},
|
|
922
|
-
remediation_ids: ['inspect_manifest_bound_evidence', 'inspect_terminal_error'],
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
/** Read only terminal failure metadata; it never reads stage-output bodies. */
|
|
927
|
-
export async function readFusionFailureResult(
|
|
928
|
-
options: ReadFusionCommittedResultOptions,
|
|
929
|
-
): Promise<FusionFailureResultView> {
|
|
930
|
-
let manifest: TrustedFailureManifest;
|
|
931
|
-
try {
|
|
932
|
-
const file = await readUtf8(join(options.artifactDirAbs, 'manifest.json'), 'manifest.json', options.artifactDir);
|
|
933
|
-
manifest = trustedFailureManifest(parseJsonText(file.text), options);
|
|
934
|
-
} catch {
|
|
935
|
-
return failureUnavailable('failed', 'unavailable');
|
|
936
|
-
}
|
|
937
|
-
const summaryRef = manifest.artifacts['failure-summary.json'];
|
|
938
|
-
if (summaryRef === undefined) {
|
|
939
|
-
return boundedFailureView(legacyFailureSource(manifest), 'legacy_manifest_only');
|
|
940
|
-
}
|
|
941
|
-
if (manifest.schemaVersion !== FUSION_MANIFEST_SCHEMA_VERSION || summaryRef.path !== 'failure-summary.json')
|
|
942
|
-
return failureUnavailable(manifest.state, 'integrity_failed');
|
|
943
|
-
try {
|
|
944
|
-
if (summaryRef.byte_length > FUSION_FAILURE_SUMMARY_MAX_BYTES)
|
|
945
|
-
throw new Error('failure summary exceeds its bounded artifact size');
|
|
946
|
-
const file = await readFailureUtf8(
|
|
947
|
-
join(options.artifactDirAbs, summaryRef.path),
|
|
948
|
-
'failure-summary.json',
|
|
949
|
-
options.artifactDir,
|
|
950
|
-
FUSION_FAILURE_SUMMARY_MAX_BYTES,
|
|
951
|
-
);
|
|
952
|
-
if (file.bytes.length !== summaryRef.byte_length || sha256Buffer(file.bytes) !== summaryRef.sha256)
|
|
953
|
-
throw new Error('failure summary hash/length mismatch');
|
|
954
|
-
const summary = parseFailureSummary(parseJsonText(file.text), manifest, options);
|
|
955
|
-
return boundedFailureView(summary, 'verified', summaryRef);
|
|
956
|
-
} catch {
|
|
957
|
-
return failureUnavailable(manifest.state, 'integrity_failed');
|
|
958
|
-
}
|
|
959
|
-
}
|