replay-self-healing-cli 0.1.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/ERROR_CODES.md +68 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/replay.mjs +2 -0
- package/package.json +43 -0
- package/schemas/replay-artifact.schema.json +107 -0
- package/schemas/runner-output.schema.json +108 -0
- package/src/cli.mjs +1680 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,1680 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs, { constants as fsConstants } from 'node:fs';
|
|
4
|
+
import fsp from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
|
|
8
|
+
const SCHEMA_VERSION = '1.0.0';
|
|
9
|
+
const KNOWN_COMMANDS = new Set(['capture', 'heal', 'help', 'report', 'validate']);
|
|
10
|
+
const DEFAULT_EVENT_ALIASES = {
|
|
11
|
+
completed: 'run_completed',
|
|
12
|
+
failed: 'run_failed',
|
|
13
|
+
message: 'assistant_message',
|
|
14
|
+
tool_end: 'tool_completed',
|
|
15
|
+
tool_finish: 'tool_completed',
|
|
16
|
+
tool_start: 'tool_started'
|
|
17
|
+
};
|
|
18
|
+
const RUNNER_DISCOVERY_CANDIDATES = [
|
|
19
|
+
'.replay/runner.mjs',
|
|
20
|
+
'.replay/runner.js',
|
|
21
|
+
'.replay/runner.cjs',
|
|
22
|
+
'.replay/runner'
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
class ReplayCliError extends Error {
|
|
26
|
+
constructor({
|
|
27
|
+
code,
|
|
28
|
+
details,
|
|
29
|
+
example,
|
|
30
|
+
expected,
|
|
31
|
+
howToFix,
|
|
32
|
+
message,
|
|
33
|
+
path: errorPath,
|
|
34
|
+
received
|
|
35
|
+
}) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'ReplayCliError';
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.details = details;
|
|
40
|
+
this.example = example;
|
|
41
|
+
this.expected = expected;
|
|
42
|
+
this.howToFix = howToFix;
|
|
43
|
+
this.path = errorPath;
|
|
44
|
+
this.received = received;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isObject(value) {
|
|
49
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeJsonParse(content, contextCode = 'E_INVALID_JSON') {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(content);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new ReplayCliError({
|
|
57
|
+
code: contextCode,
|
|
58
|
+
message: 'Invalid JSON content.',
|
|
59
|
+
details: error?.message,
|
|
60
|
+
received: content.slice(0, 500),
|
|
61
|
+
howToFix: 'Fix the JSON syntax and try again.'
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function compactDate() {
|
|
67
|
+
return new Date().toISOString().slice(0, 10);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isoNow() {
|
|
71
|
+
return new Date().toISOString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function randomShortId() {
|
|
75
|
+
return crypto.randomBytes(4).toString('hex');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sanitizePrompt(prompt) {
|
|
79
|
+
return String(prompt)
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
82
|
+
.replace(/^-+|-+$/g, '')
|
|
83
|
+
.slice(0, 40);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function outputJson(data) {
|
|
87
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function outputError(error) {
|
|
91
|
+
if (error instanceof ReplayCliError) {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
`${JSON.stringify(
|
|
94
|
+
{
|
|
95
|
+
error: {
|
|
96
|
+
code: error.code,
|
|
97
|
+
details: error.details,
|
|
98
|
+
example: error.example,
|
|
99
|
+
expected: error.expected,
|
|
100
|
+
howToFix: error.howToFix,
|
|
101
|
+
message: error.message,
|
|
102
|
+
path: error.path,
|
|
103
|
+
received: error.received
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
null,
|
|
107
|
+
2
|
|
108
|
+
)}\n`
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.stderr.write(
|
|
115
|
+
`${JSON.stringify(
|
|
116
|
+
{
|
|
117
|
+
error: {
|
|
118
|
+
code: 'E_UNHANDLED',
|
|
119
|
+
message: error?.message || 'Unexpected failure',
|
|
120
|
+
details: error?.stack
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
null,
|
|
124
|
+
2
|
|
125
|
+
)}\n`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseArgs(argv) {
|
|
130
|
+
const args = [...argv];
|
|
131
|
+
let command = 'capture';
|
|
132
|
+
|
|
133
|
+
if (args[0] && KNOWN_COMMANDS.has(args[0])) {
|
|
134
|
+
command = args.shift();
|
|
135
|
+
} else if (args[0] === '-h' || args[0] === '--help') {
|
|
136
|
+
command = 'help';
|
|
137
|
+
args.shift();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const options = {
|
|
141
|
+
heal: true,
|
|
142
|
+
json: true,
|
|
143
|
+
timeoutMs: 120000
|
|
144
|
+
};
|
|
145
|
+
const positionals = [];
|
|
146
|
+
|
|
147
|
+
for (let index = 0; index < args.length; index++) {
|
|
148
|
+
const token = args[index];
|
|
149
|
+
|
|
150
|
+
if (!token.startsWith('--')) {
|
|
151
|
+
positionals.push(token);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (token === '--help') {
|
|
156
|
+
options.help = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (token === '--no-heal') {
|
|
161
|
+
options.heal = false;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (token === '--json') {
|
|
166
|
+
options.json = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (token === '--runner') {
|
|
171
|
+
options.runner = args[++index];
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (token === '--out') {
|
|
176
|
+
options.out = args[++index];
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (token === '--context') {
|
|
181
|
+
options.context = args[++index];
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (token === '--context-file') {
|
|
186
|
+
options.contextFile = args[++index];
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (token === '--id') {
|
|
191
|
+
options.id = args[++index];
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (token === '--in') {
|
|
196
|
+
options.in = args[++index];
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (token === '--timeout-ms') {
|
|
201
|
+
options.timeoutMs = Number(args[++index]);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw new ReplayCliError({
|
|
206
|
+
code: 'E_UNKNOWN_FLAG',
|
|
207
|
+
message: `Unknown flag: ${token}`,
|
|
208
|
+
howToFix: 'Use `replay help` to see supported flags.'
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
|
|
213
|
+
throw new ReplayCliError({
|
|
214
|
+
code: 'E_TIMEOUT_INVALID',
|
|
215
|
+
message: '--timeout-ms must be a positive integer.',
|
|
216
|
+
path: '--timeout-ms',
|
|
217
|
+
expected: 'positive number',
|
|
218
|
+
received: options.timeoutMs
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { command, options, positionals };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function printHelp() {
|
|
226
|
+
outputJson({
|
|
227
|
+
commands: {
|
|
228
|
+
capture: 'Capture one prompt into raw + healed replay artifacts (default command).',
|
|
229
|
+
heal: 'Re-heal replay artifacts from a file or directory.',
|
|
230
|
+
report: 'Generate summary metrics from replay artifact files.',
|
|
231
|
+
validate: 'Validate replay artifact files.'
|
|
232
|
+
},
|
|
233
|
+
examples: [
|
|
234
|
+
'replay "What are my top holdings?"',
|
|
235
|
+
'echo "What changed today?" | replay',
|
|
236
|
+
'replay capture "Summarize my risk" --timeout-ms 90000',
|
|
237
|
+
'replay validate --in .replay/artifacts',
|
|
238
|
+
'replay heal --in .replay/artifacts --out .replay/healed',
|
|
239
|
+
'replay report --in .replay/artifacts'
|
|
240
|
+
],
|
|
241
|
+
notes: [
|
|
242
|
+
'No --runner required by default. Runner discovery order: REPLAY_RUNNER env, .replay/runner.mjs, .replay/runner.js, .replay/runner.cjs, .replay/runner, package.json script replay:runner.',
|
|
243
|
+
'No --out required by default. Output defaults to .replay/artifacts/YYYY-MM-DD.',
|
|
244
|
+
'This utility has zero dependencies and uses only Node.js built-in modules.'
|
|
245
|
+
]
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function ensureDir(dirPath) {
|
|
250
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function readPromptFromStdin() {
|
|
254
|
+
if (process.stdin.isTTY) {
|
|
255
|
+
return '';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const chunks = [];
|
|
259
|
+
|
|
260
|
+
for await (const chunk of process.stdin) {
|
|
261
|
+
chunks.push(chunk);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return Buffer.concat(chunks).toString('utf-8').trim();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function resolvePrompt(positionals) {
|
|
268
|
+
const directPrompt = positionals.join(' ').trim();
|
|
269
|
+
|
|
270
|
+
if (directPrompt) {
|
|
271
|
+
return directPrompt;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const stdinPrompt = await readPromptFromStdin();
|
|
275
|
+
|
|
276
|
+
if (stdinPrompt) {
|
|
277
|
+
return stdinPrompt;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
throw new ReplayCliError({
|
|
281
|
+
code: 'E_PROMPT_REQUIRED',
|
|
282
|
+
message: 'No prompt was provided.',
|
|
283
|
+
path: '$.prompt',
|
|
284
|
+
expected: 'non-empty string',
|
|
285
|
+
received: '',
|
|
286
|
+
howToFix: 'Pass a prompt as an argument or pipe one via stdin.',
|
|
287
|
+
example: 'replay "What are my top 5 holdings?"'
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function resolveContext({ context, contextFile }) {
|
|
292
|
+
if (context && contextFile) {
|
|
293
|
+
throw new ReplayCliError({
|
|
294
|
+
code: 'E_CONTEXT_CONFLICT',
|
|
295
|
+
message: 'Use only one of --context or --context-file.',
|
|
296
|
+
howToFix: 'Provide inline JSON with --context OR a file path with --context-file.'
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (context) {
|
|
301
|
+
const parsed = safeJsonParse(context, 'E_CONTEXT_JSON_INVALID');
|
|
302
|
+
|
|
303
|
+
if (!isObject(parsed)) {
|
|
304
|
+
throw new ReplayCliError({
|
|
305
|
+
code: 'E_CONTEXT_SHAPE_INVALID',
|
|
306
|
+
message: 'Context must be a JSON object.',
|
|
307
|
+
expected: 'object',
|
|
308
|
+
received: typeof parsed,
|
|
309
|
+
path: '--context'
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return parsed;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (contextFile) {
|
|
317
|
+
const absolutePath = path.resolve(process.cwd(), contextFile);
|
|
318
|
+
let content;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
content = await fsp.readFile(absolutePath, 'utf-8');
|
|
322
|
+
} catch {
|
|
323
|
+
throw new ReplayCliError({
|
|
324
|
+
code: 'E_CONTEXT_FILE_NOT_FOUND',
|
|
325
|
+
message: `Context file does not exist: ${absolutePath}`,
|
|
326
|
+
path: '--context-file',
|
|
327
|
+
howToFix: 'Provide an existing JSON file path.'
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const parsed = safeJsonParse(content, 'E_CONTEXT_JSON_INVALID');
|
|
332
|
+
|
|
333
|
+
if (!isObject(parsed)) {
|
|
334
|
+
throw new ReplayCliError({
|
|
335
|
+
code: 'E_CONTEXT_SHAPE_INVALID',
|
|
336
|
+
message: 'Context file content must be a JSON object.',
|
|
337
|
+
expected: 'object',
|
|
338
|
+
received: typeof parsed,
|
|
339
|
+
path: '--context-file'
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return parsed;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function discoverRunner(cwd, explicitRunner) {
|
|
350
|
+
const sources = [];
|
|
351
|
+
|
|
352
|
+
if (explicitRunner) {
|
|
353
|
+
const runner = await toRunnerSpec(explicitRunner, cwd, 'flag');
|
|
354
|
+
return { runner, sources };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (process.env.REPLAY_RUNNER) {
|
|
358
|
+
const runner = await toRunnerSpec(process.env.REPLAY_RUNNER, cwd, 'env');
|
|
359
|
+
return { runner, sources };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const candidate of RUNNER_DISCOVERY_CANDIDATES) {
|
|
363
|
+
const absolute = path.resolve(cwd, candidate);
|
|
364
|
+
sources.push(absolute);
|
|
365
|
+
|
|
366
|
+
if (await exists(absolute)) {
|
|
367
|
+
const runner = await toRunnerSpec(absolute, cwd, 'file');
|
|
368
|
+
return { runner, sources };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const packageJsonPath = path.resolve(cwd, 'package.json');
|
|
373
|
+
if (await exists(packageJsonPath)) {
|
|
374
|
+
const packageJson = safeJsonParse(
|
|
375
|
+
await fsp.readFile(packageJsonPath, 'utf-8'),
|
|
376
|
+
'E_PACKAGE_JSON_INVALID'
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
if (isObject(packageJson?.scripts) && packageJson.scripts['replay:runner']) {
|
|
380
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
runner: {
|
|
384
|
+
args: ['run', '--silent', 'replay:runner', '--'],
|
|
385
|
+
command: npmCommand,
|
|
386
|
+
display: 'npm run --silent replay:runner --',
|
|
387
|
+
source: 'package.json#scripts.replay:runner',
|
|
388
|
+
type: 'npm-script'
|
|
389
|
+
},
|
|
390
|
+
sources
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
throw new ReplayCliError({
|
|
396
|
+
code: 'E_RUNNER_NOT_FOUND',
|
|
397
|
+
message: 'No runner was discovered.',
|
|
398
|
+
details: {
|
|
399
|
+
checked: sources,
|
|
400
|
+
envVar: 'REPLAY_RUNNER',
|
|
401
|
+
packageScript: 'replay:runner'
|
|
402
|
+
},
|
|
403
|
+
howToFix:
|
|
404
|
+
'Create .replay/runner.mjs, set REPLAY_RUNNER, or add package.json script replay:runner.',
|
|
405
|
+
example: 'mkdir -p .replay && cp examples/runner.mjs .replay/runner.mjs'
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function toRunnerSpec(value, cwd, source) {
|
|
410
|
+
const resolved = path.isAbsolute(value) ? value : path.resolve(cwd, value);
|
|
411
|
+
|
|
412
|
+
if (!(await exists(resolved))) {
|
|
413
|
+
throw new ReplayCliError({
|
|
414
|
+
code: 'E_RUNNER_PATH_NOT_FOUND',
|
|
415
|
+
message: `Runner path does not exist: ${resolved}`,
|
|
416
|
+
path: '$.runner',
|
|
417
|
+
howToFix: 'Point --runner or REPLAY_RUNNER to an existing file.'
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const ext = path.extname(resolved);
|
|
422
|
+
|
|
423
|
+
if (ext === '.ts') {
|
|
424
|
+
throw new ReplayCliError({
|
|
425
|
+
code: 'E_RUNNER_UNSUPPORTED_EXTENSION',
|
|
426
|
+
message: 'TypeScript runner files are not supported without a runtime wrapper.',
|
|
427
|
+
expected: '.mjs, .js, .cjs, or executable file',
|
|
428
|
+
received: ext,
|
|
429
|
+
howToFix:
|
|
430
|
+
'Compile the runner to JavaScript, use .mjs, or wire a replay:runner package script.'
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (['.mjs', '.js', '.cjs'].includes(ext)) {
|
|
435
|
+
return {
|
|
436
|
+
args: [resolved],
|
|
437
|
+
command: process.execPath,
|
|
438
|
+
display: `${process.execPath} ${resolved}`,
|
|
439
|
+
source,
|
|
440
|
+
type: 'node-file'
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
await fsp.access(resolved, fsConstants.X_OK);
|
|
446
|
+
} catch {
|
|
447
|
+
throw new ReplayCliError({
|
|
448
|
+
code: 'E_RUNNER_NOT_EXECUTABLE',
|
|
449
|
+
message: `Runner is not executable: ${resolved}`,
|
|
450
|
+
path: '$.runner',
|
|
451
|
+
howToFix:
|
|
452
|
+
'Make the file executable (`chmod +x`) or use a .mjs/.js/.cjs runner file.'
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
args: [],
|
|
458
|
+
command: resolved,
|
|
459
|
+
display: resolved,
|
|
460
|
+
source,
|
|
461
|
+
type: 'executable'
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function exists(targetPath) {
|
|
466
|
+
try {
|
|
467
|
+
await fsp.access(targetPath, fsConstants.F_OK);
|
|
468
|
+
return true;
|
|
469
|
+
} catch {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function runRunner({ payload, runner, timeoutMs }) {
|
|
475
|
+
return new Promise((resolve, reject) => {
|
|
476
|
+
const startedAt = Date.now();
|
|
477
|
+
const child = spawn(runner.command, runner.args, {
|
|
478
|
+
cwd: process.cwd(),
|
|
479
|
+
env: process.env,
|
|
480
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
let stdout = '';
|
|
484
|
+
let stderr = '';
|
|
485
|
+
let timedOut = false;
|
|
486
|
+
|
|
487
|
+
const timeout = setTimeout(() => {
|
|
488
|
+
timedOut = true;
|
|
489
|
+
child.kill('SIGTERM');
|
|
490
|
+
|
|
491
|
+
setTimeout(() => {
|
|
492
|
+
if (!child.killed) {
|
|
493
|
+
child.kill('SIGKILL');
|
|
494
|
+
}
|
|
495
|
+
}, 3000);
|
|
496
|
+
}, timeoutMs);
|
|
497
|
+
|
|
498
|
+
child.stdout.on('data', (chunk) => {
|
|
499
|
+
stdout += chunk.toString('utf-8');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
child.stderr.on('data', (chunk) => {
|
|
503
|
+
stderr += chunk.toString('utf-8');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
child.on('error', (error) => {
|
|
507
|
+
clearTimeout(timeout);
|
|
508
|
+
reject(
|
|
509
|
+
new ReplayCliError({
|
|
510
|
+
code: 'E_RUNNER_START_FAILED',
|
|
511
|
+
message: `Runner process failed to start: ${runner.display}`,
|
|
512
|
+
details: error?.message,
|
|
513
|
+
howToFix: 'Verify the runner path and execution permissions.'
|
|
514
|
+
})
|
|
515
|
+
);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
child.on('close', (exitCode, signal) => {
|
|
519
|
+
clearTimeout(timeout);
|
|
520
|
+
resolve({
|
|
521
|
+
durationMs: Date.now() - startedAt,
|
|
522
|
+
exitCode,
|
|
523
|
+
signal,
|
|
524
|
+
stderr,
|
|
525
|
+
stdout,
|
|
526
|
+
timedOut
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
531
|
+
child.stdin.end();
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function recoverJsonObject(text) {
|
|
536
|
+
const value = text.trim();
|
|
537
|
+
|
|
538
|
+
if (!value) {
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const candidates = [];
|
|
543
|
+
let depth = 0;
|
|
544
|
+
let inString = false;
|
|
545
|
+
let escaped = false;
|
|
546
|
+
let start = -1;
|
|
547
|
+
|
|
548
|
+
for (let index = 0; index < value.length; index++) {
|
|
549
|
+
const char = value[index];
|
|
550
|
+
|
|
551
|
+
if (escaped) {
|
|
552
|
+
escaped = false;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (char === '\\') {
|
|
557
|
+
escaped = true;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (char === '"') {
|
|
562
|
+
inString = !inString;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (inString) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (char === '{') {
|
|
571
|
+
if (depth === 0) {
|
|
572
|
+
start = index;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
depth += 1;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (char === '}') {
|
|
580
|
+
depth -= 1;
|
|
581
|
+
|
|
582
|
+
if (depth === 0 && start >= 0) {
|
|
583
|
+
candidates.push(value.slice(start, index + 1));
|
|
584
|
+
start = -1;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return candidates[candidates.length - 1];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function parseRunnerOutput(rawStdout) {
|
|
593
|
+
const trimmed = rawStdout.trim();
|
|
594
|
+
|
|
595
|
+
if (!trimmed) {
|
|
596
|
+
throw new ReplayCliError({
|
|
597
|
+
code: 'E_RUNNER_EMPTY_STDOUT',
|
|
598
|
+
message: 'Runner wrote no stdout output.',
|
|
599
|
+
howToFix: 'Runner must return one JSON object on stdout.'
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
return JSON.parse(trimmed);
|
|
605
|
+
} catch {
|
|
606
|
+
const recovered = recoverJsonObject(trimmed);
|
|
607
|
+
|
|
608
|
+
if (!recovered) {
|
|
609
|
+
throw new ReplayCliError({
|
|
610
|
+
code: 'E_RUNNER_INVALID_JSON',
|
|
611
|
+
message: 'Runner stdout is not valid JSON.',
|
|
612
|
+
received: trimmed.slice(0, 1000),
|
|
613
|
+
howToFix: 'Print exactly one JSON object to stdout.',
|
|
614
|
+
example:
|
|
615
|
+
'{"status":"ok","response":"...","events":[{"type":"run_started"}]}'
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
return JSON.parse(recovered);
|
|
621
|
+
} catch {
|
|
622
|
+
throw new ReplayCliError({
|
|
623
|
+
code: 'E_RUNNER_INVALID_JSON',
|
|
624
|
+
message: 'Runner stdout contains JSON-like text but parsing failed.',
|
|
625
|
+
received: recovered.slice(0, 1000),
|
|
626
|
+
howToFix: 'Ensure the JSON object is syntactically valid.'
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function ensureArray(value) {
|
|
633
|
+
return Array.isArray(value) ? value : [];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function extractResponseFromEvents(events) {
|
|
637
|
+
const assistantEvent = [...events]
|
|
638
|
+
.reverse()
|
|
639
|
+
.find((event) => event?.type === 'assistant_message');
|
|
640
|
+
|
|
641
|
+
const payload = assistantEvent?.payload;
|
|
642
|
+
|
|
643
|
+
if (!isObject(payload)) {
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (typeof payload.response === 'string') {
|
|
648
|
+
return payload.response;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (typeof payload.message === 'string') {
|
|
652
|
+
return payload.message;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (typeof payload.text === 'string') {
|
|
656
|
+
return payload.text;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function normalizeRunnerResult(runnerResult, runnerStderr) {
|
|
663
|
+
if (!isObject(runnerResult)) {
|
|
664
|
+
throw new ReplayCliError({
|
|
665
|
+
code: 'E_RUNNER_OUTPUT_SHAPE',
|
|
666
|
+
message: 'Runner output must be a JSON object.',
|
|
667
|
+
expected: 'object',
|
|
668
|
+
received: typeof runnerResult
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const normalizationIssues = [];
|
|
673
|
+
|
|
674
|
+
let status = runnerResult.status;
|
|
675
|
+
|
|
676
|
+
if (status !== 'ok' && status !== 'error') {
|
|
677
|
+
if (isObject(runnerResult.error)) {
|
|
678
|
+
status = 'error';
|
|
679
|
+
normalizationIssues.push({
|
|
680
|
+
code: 'W_STATUS_INFERRED_FROM_ERROR',
|
|
681
|
+
message: 'Status was missing/invalid and was inferred as error.'
|
|
682
|
+
});
|
|
683
|
+
} else if (typeof runnerResult.response === 'string') {
|
|
684
|
+
status = 'ok';
|
|
685
|
+
normalizationIssues.push({
|
|
686
|
+
code: 'W_STATUS_INFERRED_FROM_RESPONSE',
|
|
687
|
+
message: 'Status was missing/invalid and was inferred as ok.'
|
|
688
|
+
});
|
|
689
|
+
} else {
|
|
690
|
+
throw new ReplayCliError({
|
|
691
|
+
code: 'E_RUNNER_STATUS_INVALID',
|
|
692
|
+
message: 'Runner output field `status` must be `ok` or `error`.',
|
|
693
|
+
path: '$.status',
|
|
694
|
+
expected: '"ok" | "error"',
|
|
695
|
+
received: runnerResult.status,
|
|
696
|
+
howToFix:
|
|
697
|
+
'Return `status: "ok"` for success or `status: "error"` for failures.'
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
let events = runnerResult.events;
|
|
703
|
+
if (events === undefined) {
|
|
704
|
+
events = [];
|
|
705
|
+
normalizationIssues.push({
|
|
706
|
+
code: 'W_EVENTS_DEFAULTED_EMPTY',
|
|
707
|
+
message: 'Missing events field. Defaulted to [].'
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (!Array.isArray(events)) {
|
|
712
|
+
throw new ReplayCliError({
|
|
713
|
+
code: 'E_RUNNER_EVENTS_INVALID',
|
|
714
|
+
message: 'Runner output field `events` must be an array.',
|
|
715
|
+
path: '$.events',
|
|
716
|
+
expected: 'array',
|
|
717
|
+
received: typeof events,
|
|
718
|
+
howToFix: 'Return `events: []` when there are no events.'
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let response = runnerResult.response;
|
|
723
|
+
|
|
724
|
+
if (status === 'ok') {
|
|
725
|
+
if (typeof response !== 'string') {
|
|
726
|
+
response = extractResponseFromEvents(events);
|
|
727
|
+
|
|
728
|
+
if (typeof response === 'string') {
|
|
729
|
+
normalizationIssues.push({
|
|
730
|
+
code: 'W_RESPONSE_INFERRED_FROM_EVENTS',
|
|
731
|
+
message: 'Response was inferred from assistant_message event payload.'
|
|
732
|
+
});
|
|
733
|
+
} else {
|
|
734
|
+
throw new ReplayCliError({
|
|
735
|
+
code: 'E_RUNNER_RESPONSE_MISSING',
|
|
736
|
+
message: 'Runner output requires `response` string when status is `ok`.',
|
|
737
|
+
path: '$.response',
|
|
738
|
+
expected: 'string',
|
|
739
|
+
received: typeof runnerResult.response,
|
|
740
|
+
howToFix: 'Set `response` to the final assistant message text.'
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
let error = runnerResult.error;
|
|
747
|
+
|
|
748
|
+
if (status === 'error') {
|
|
749
|
+
if (!isObject(error)) {
|
|
750
|
+
error = {
|
|
751
|
+
code: 'RUNNER_ERROR_UNSPECIFIED',
|
|
752
|
+
message: runnerStderr?.trim() || 'Runner returned status=error without error details.',
|
|
753
|
+
retryable: false
|
|
754
|
+
};
|
|
755
|
+
normalizationIssues.push({
|
|
756
|
+
code: 'W_ERROR_OBJECT_DEFAULTED',
|
|
757
|
+
message: 'Error object missing. A default error payload was generated.'
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (typeof error.message !== 'string' || error.message.length === 0) {
|
|
762
|
+
error.message = 'Runner returned status=error without a message.';
|
|
763
|
+
normalizationIssues.push({
|
|
764
|
+
code: 'W_ERROR_MESSAGE_DEFAULTED',
|
|
765
|
+
message: 'Error message was missing. A default message was generated.'
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (typeof error.code !== 'string' || error.code.length === 0) {
|
|
770
|
+
error.code = 'RUNNER_ERROR';
|
|
771
|
+
normalizationIssues.push({
|
|
772
|
+
code: 'W_ERROR_CODE_DEFAULTED',
|
|
773
|
+
message: 'Error code was missing. Defaulted to RUNNER_ERROR.'
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
let sources = runnerResult.sources;
|
|
779
|
+
if (!Array.isArray(sources)) {
|
|
780
|
+
sources = [];
|
|
781
|
+
if (runnerResult.sources !== undefined) {
|
|
782
|
+
normalizationIssues.push({
|
|
783
|
+
code: 'W_SOURCES_DEFAULTED_EMPTY',
|
|
784
|
+
message: 'sources was not an array. Defaulted to [].'
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
let usage = runnerResult.usage;
|
|
790
|
+
if (!isObject(usage)) {
|
|
791
|
+
usage = {};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
error,
|
|
796
|
+
events,
|
|
797
|
+
framework:
|
|
798
|
+
typeof runnerResult.framework === 'string' ? runnerResult.framework : 'custom',
|
|
799
|
+
normalizationIssues,
|
|
800
|
+
response,
|
|
801
|
+
sources: sources.filter((source) => {
|
|
802
|
+
return typeof source === 'string' && source.length > 0;
|
|
803
|
+
}),
|
|
804
|
+
status,
|
|
805
|
+
usage: {
|
|
806
|
+
costUsd: typeof usage.costUsd === 'number' ? usage.costUsd : undefined,
|
|
807
|
+
inputTokens:
|
|
808
|
+
typeof usage.inputTokens === 'number' ? usage.inputTokens : undefined,
|
|
809
|
+
outputTokens:
|
|
810
|
+
typeof usage.outputTokens === 'number' ? usage.outputTokens : undefined
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function buildRawArtifact({ execution, payload, runner }) {
|
|
816
|
+
return {
|
|
817
|
+
artifactType: 'replay_raw_capture',
|
|
818
|
+
id: payload.runId,
|
|
819
|
+
payload,
|
|
820
|
+
process: {
|
|
821
|
+
durationMs: execution.durationMs,
|
|
822
|
+
exitCode: execution.exitCode,
|
|
823
|
+
signal: execution.signal,
|
|
824
|
+
stderr: execution.stderr,
|
|
825
|
+
timedOut: execution.timedOut
|
|
826
|
+
},
|
|
827
|
+
runner,
|
|
828
|
+
schemaVersion: SCHEMA_VERSION,
|
|
829
|
+
stdout: execution.stdout,
|
|
830
|
+
timestamp: isoNow()
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function baseReplayArtifact({ normalizedResult, payload, rawExecution, runner }) {
|
|
835
|
+
return {
|
|
836
|
+
artifactType: 'replay',
|
|
837
|
+
createdAt: isoNow(),
|
|
838
|
+
error: normalizedResult.status === 'error' ? normalizedResult.error : undefined,
|
|
839
|
+
events: normalizedResult.events,
|
|
840
|
+
framework: normalizedResult.framework,
|
|
841
|
+
id: payload.runId,
|
|
842
|
+
prompt: payload.prompt,
|
|
843
|
+
query: payload.query,
|
|
844
|
+
runner: {
|
|
845
|
+
command: runner.display,
|
|
846
|
+
source: runner.source,
|
|
847
|
+
type: runner.type
|
|
848
|
+
},
|
|
849
|
+
schemaVersion: SCHEMA_VERSION,
|
|
850
|
+
sources: normalizedResult.sources,
|
|
851
|
+
status: normalizedResult.status,
|
|
852
|
+
timestamp: payload.timestamp,
|
|
853
|
+
usage: normalizedResult.usage,
|
|
854
|
+
response: normalizedResult.status === 'ok' ? normalizedResult.response : '',
|
|
855
|
+
runtime: {
|
|
856
|
+
runnerDurationMs: rawExecution.durationMs
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function normalizeEventType(type, aliasMap) {
|
|
862
|
+
if (typeof type !== 'string') {
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return aliasMap[type] || type;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function normalizeEventTimestamp(value, fallback) {
|
|
870
|
+
if (typeof value !== 'string') {
|
|
871
|
+
return fallback;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const parsed = Date.parse(value);
|
|
875
|
+
|
|
876
|
+
if (Number.isNaN(parsed)) {
|
|
877
|
+
return fallback;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return new Date(parsed).toISOString();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function stableStringify(value) {
|
|
884
|
+
if (!isObject(value) && !Array.isArray(value)) {
|
|
885
|
+
return JSON.stringify(value);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return JSON.stringify(value, Object.keys(value).sort());
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function healReplayArtifact(artifact, rules) {
|
|
892
|
+
const healed = structuredClone(artifact);
|
|
893
|
+
const healLog = [];
|
|
894
|
+
const aliasMap = {
|
|
895
|
+
...DEFAULT_EVENT_ALIASES,
|
|
896
|
+
...(isObject(rules?.eventAliases) ? rules.eventAliases : {})
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
if (!Array.isArray(healed.events)) {
|
|
900
|
+
healed.events = [];
|
|
901
|
+
healLog.push({
|
|
902
|
+
code: 'H_EVENTS_DEFAULTED_EMPTY',
|
|
903
|
+
message: 'Top-level events was not an array. Defaulted to [].',
|
|
904
|
+
path: '$.events'
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const normalizedEvents = [];
|
|
909
|
+
|
|
910
|
+
for (const [index, event] of healed.events.entries()) {
|
|
911
|
+
if (!isObject(event)) {
|
|
912
|
+
healLog.push({
|
|
913
|
+
code: 'H_EVENT_DROPPED_NON_OBJECT',
|
|
914
|
+
message: `Dropped events[${index}] because it was not an object.`,
|
|
915
|
+
path: `$.events[${index}]`
|
|
916
|
+
});
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const normalizedType = normalizeEventType(event.type, aliasMap);
|
|
921
|
+
|
|
922
|
+
if (!normalizedType) {
|
|
923
|
+
healLog.push({
|
|
924
|
+
code: 'H_EVENT_DROPPED_MISSING_TYPE',
|
|
925
|
+
message: `Dropped events[${index}] because type was missing/invalid.`,
|
|
926
|
+
path: `$.events[${index}].type`
|
|
927
|
+
});
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const normalizedEvent = {
|
|
932
|
+
payload: isObject(event.payload) ? event.payload : {},
|
|
933
|
+
runId:
|
|
934
|
+
typeof event.runId === 'string' && event.runId.length > 0
|
|
935
|
+
? event.runId
|
|
936
|
+
: healed.id,
|
|
937
|
+
seq:
|
|
938
|
+
Number.isInteger(event.seq) && event.seq > 0
|
|
939
|
+
? event.seq
|
|
940
|
+
: normalizedEvents.length + 1,
|
|
941
|
+
timestamp: normalizeEventTimestamp(event.timestamp, healed.timestamp),
|
|
942
|
+
type: normalizedType
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
if (!isObject(event.payload) && event.payload !== undefined) {
|
|
946
|
+
healLog.push({
|
|
947
|
+
code: 'H_EVENT_PAYLOAD_DEFAULTED_OBJECT',
|
|
948
|
+
message: `events[${index}].payload was not an object. Defaulted to {}.`,
|
|
949
|
+
path: `$.events[${index}].payload`
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (event.type !== normalizedType) {
|
|
954
|
+
healLog.push({
|
|
955
|
+
code: 'H_EVENT_TYPE_ALIASED',
|
|
956
|
+
message: `Mapped event type ${event.type} -> ${normalizedType}.`,
|
|
957
|
+
path: `$.events[${index}].type`
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
normalizedEvents.push(normalizedEvent);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
normalizedEvents.sort((left, right) => {
|
|
965
|
+
if (left.seq !== right.seq) {
|
|
966
|
+
return left.seq - right.seq;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return Date.parse(left.timestamp) - Date.parse(right.timestamp);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
const dedupedEvents = [];
|
|
973
|
+
|
|
974
|
+
for (const event of normalizedEvents) {
|
|
975
|
+
const previous = dedupedEvents.at(-1);
|
|
976
|
+
|
|
977
|
+
if (
|
|
978
|
+
previous &&
|
|
979
|
+
previous.type === event.type &&
|
|
980
|
+
previous.runId === event.runId &&
|
|
981
|
+
previous.timestamp === event.timestamp &&
|
|
982
|
+
stableStringify(previous.payload) === stableStringify(event.payload)
|
|
983
|
+
) {
|
|
984
|
+
healLog.push({
|
|
985
|
+
code: 'H_EVENT_DEDUPED',
|
|
986
|
+
message: `Deduplicated repeated event type ${event.type}.`,
|
|
987
|
+
path: '$.events'
|
|
988
|
+
});
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
dedupedEvents.push(event);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
healed.events = dedupedEvents;
|
|
996
|
+
|
|
997
|
+
const hasRunStarted = healed.events.some((event) => {
|
|
998
|
+
return event.type === 'run_started';
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
if (!hasRunStarted) {
|
|
1002
|
+
healed.events.unshift({
|
|
1003
|
+
payload: {},
|
|
1004
|
+
runId: healed.id,
|
|
1005
|
+
seq: 1,
|
|
1006
|
+
timestamp: healed.timestamp,
|
|
1007
|
+
type: 'run_started'
|
|
1008
|
+
});
|
|
1009
|
+
healLog.push({
|
|
1010
|
+
code: 'H_RUN_STARTED_INSERTED',
|
|
1011
|
+
message: 'Inserted synthetic run_started event.',
|
|
1012
|
+
path: '$.events'
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const hasAssistantMessage = healed.events.some((event) => {
|
|
1017
|
+
return event.type === 'assistant_message';
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
if (!hasAssistantMessage && healed.status === 'ok' && healed.response) {
|
|
1021
|
+
healed.events.push({
|
|
1022
|
+
payload: {
|
|
1023
|
+
response: healed.response
|
|
1024
|
+
},
|
|
1025
|
+
runId: healed.id,
|
|
1026
|
+
seq: healed.events.length + 1,
|
|
1027
|
+
timestamp: healed.timestamp,
|
|
1028
|
+
type: 'assistant_message'
|
|
1029
|
+
});
|
|
1030
|
+
healLog.push({
|
|
1031
|
+
code: 'H_ASSISTANT_MESSAGE_INSERTED',
|
|
1032
|
+
message: 'Inserted synthetic assistant_message event from final response.',
|
|
1033
|
+
path: '$.events'
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const hasTerminalEvent = healed.events.some((event) => {
|
|
1038
|
+
return event.type === 'run_completed' || event.type === 'run_failed';
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
if (!hasTerminalEvent) {
|
|
1042
|
+
healed.events.push({
|
|
1043
|
+
payload: {},
|
|
1044
|
+
runId: healed.id,
|
|
1045
|
+
seq: healed.events.length + 1,
|
|
1046
|
+
timestamp: healed.timestamp,
|
|
1047
|
+
type: healed.status === 'error' ? 'run_failed' : 'run_completed'
|
|
1048
|
+
});
|
|
1049
|
+
healLog.push({
|
|
1050
|
+
code: 'H_TERMINAL_EVENT_INSERTED',
|
|
1051
|
+
message: 'Inserted missing terminal event.',
|
|
1052
|
+
path: '$.events'
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
for (let index = 0; index < healed.events.length; index++) {
|
|
1057
|
+
const nextSeq = index + 1;
|
|
1058
|
+
|
|
1059
|
+
if (healed.events[index].seq !== nextSeq) {
|
|
1060
|
+
healLog.push({
|
|
1061
|
+
code: 'H_SEQ_RENUMBERED',
|
|
1062
|
+
message: `Renumbered event sequence ${healed.events[index].seq} -> ${nextSeq}.`,
|
|
1063
|
+
path: `$.events[${index}].seq`
|
|
1064
|
+
});
|
|
1065
|
+
healed.events[index].seq = nextSeq;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
healed.toolCalls = healed.events
|
|
1070
|
+
.filter((event) => {
|
|
1071
|
+
return event.type === 'tool_completed';
|
|
1072
|
+
})
|
|
1073
|
+
.map((event) => {
|
|
1074
|
+
const payload = event.payload || {};
|
|
1075
|
+
|
|
1076
|
+
return {
|
|
1077
|
+
durationMs:
|
|
1078
|
+
typeof payload.durationMs === 'number' && payload.durationMs >= 0
|
|
1079
|
+
? payload.durationMs
|
|
1080
|
+
: 0,
|
|
1081
|
+
status: payload.status === 'error' ? 'error' : 'ok',
|
|
1082
|
+
toolName:
|
|
1083
|
+
typeof payload.toolName === 'string' && payload.toolName.length > 0
|
|
1084
|
+
? payload.toolName
|
|
1085
|
+
: typeof payload.name === 'string' && payload.name.length > 0
|
|
1086
|
+
? payload.name
|
|
1087
|
+
: 'unknown_tool'
|
|
1088
|
+
};
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
const runStartedEvent = healed.events.find((event) => {
|
|
1092
|
+
return event.type === 'run_started';
|
|
1093
|
+
});
|
|
1094
|
+
const assistantMessageEvent = healed.events.find((event) => {
|
|
1095
|
+
return event.type === 'assistant_message';
|
|
1096
|
+
});
|
|
1097
|
+
const terminalEvent = [...healed.events].reverse().find((event) => {
|
|
1098
|
+
return event.type === 'run_completed' || event.type === 'run_failed';
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
const runStartMs = Date.parse(runStartedEvent?.timestamp || healed.timestamp);
|
|
1102
|
+
const runEndMs = Date.parse(terminalEvent?.timestamp || healed.timestamp);
|
|
1103
|
+
|
|
1104
|
+
healed.timings = {
|
|
1105
|
+
assistantMessageTimestamp: assistantMessageEvent?.timestamp,
|
|
1106
|
+
endToEndLatencyMs:
|
|
1107
|
+
Number.isFinite(runStartMs) && Number.isFinite(runEndMs)
|
|
1108
|
+
? Math.max(0, runEndMs - runStartMs)
|
|
1109
|
+
: healed.runtime?.runnerDurationMs,
|
|
1110
|
+
runCompletedTimestamp: terminalEvent?.timestamp,
|
|
1111
|
+
runStartedTimestamp: runStartedEvent?.timestamp
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
healed.healing = {
|
|
1115
|
+
issues: healLog,
|
|
1116
|
+
issuesCount: healLog.length,
|
|
1117
|
+
rulesVersion: rules?.version || 'default'
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
return {
|
|
1121
|
+
healLog,
|
|
1122
|
+
healed
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function validateReplayArtifact(artifact, filePath) {
|
|
1127
|
+
const errors = [];
|
|
1128
|
+
|
|
1129
|
+
if (!isObject(artifact)) {
|
|
1130
|
+
errors.push({
|
|
1131
|
+
code: 'E_ARTIFACT_SHAPE_INVALID',
|
|
1132
|
+
message: 'Artifact must be a JSON object.',
|
|
1133
|
+
path: '$',
|
|
1134
|
+
expected: 'object',
|
|
1135
|
+
received: typeof artifact
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
return errors;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (artifact.artifactType !== 'replay') {
|
|
1142
|
+
errors.push({
|
|
1143
|
+
code: 'E_ARTIFACT_TYPE_INVALID',
|
|
1144
|
+
message: 'artifactType must be "replay".',
|
|
1145
|
+
path: '$.artifactType',
|
|
1146
|
+
expected: '"replay"',
|
|
1147
|
+
received: artifact.artifactType
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (artifact.schemaVersion !== SCHEMA_VERSION) {
|
|
1152
|
+
errors.push({
|
|
1153
|
+
code: 'E_SCHEMA_VERSION_UNSUPPORTED',
|
|
1154
|
+
message: `schemaVersion must be ${SCHEMA_VERSION}.`,
|
|
1155
|
+
path: '$.schemaVersion',
|
|
1156
|
+
expected: SCHEMA_VERSION,
|
|
1157
|
+
received: artifact.schemaVersion
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (typeof artifact.id !== 'string' || artifact.id.length === 0) {
|
|
1162
|
+
errors.push({
|
|
1163
|
+
code: 'E_ARTIFACT_ID_INVALID',
|
|
1164
|
+
message: 'id must be a non-empty string.',
|
|
1165
|
+
path: '$.id',
|
|
1166
|
+
expected: 'non-empty string',
|
|
1167
|
+
received: artifact.id
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (artifact.status !== 'ok' && artifact.status !== 'error') {
|
|
1172
|
+
errors.push({
|
|
1173
|
+
code: 'E_ARTIFACT_STATUS_INVALID',
|
|
1174
|
+
message: 'status must be "ok" or "error".',
|
|
1175
|
+
path: '$.status',
|
|
1176
|
+
expected: '"ok" | "error"',
|
|
1177
|
+
received: artifact.status
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (!Array.isArray(artifact.events)) {
|
|
1182
|
+
errors.push({
|
|
1183
|
+
code: 'E_ARTIFACT_EVENTS_INVALID',
|
|
1184
|
+
message: 'events must be an array.',
|
|
1185
|
+
path: '$.events',
|
|
1186
|
+
expected: 'array',
|
|
1187
|
+
received: typeof artifact.events
|
|
1188
|
+
});
|
|
1189
|
+
} else {
|
|
1190
|
+
for (const [index, event] of artifact.events.entries()) {
|
|
1191
|
+
if (!isObject(event)) {
|
|
1192
|
+
errors.push({
|
|
1193
|
+
code: 'E_ARTIFACT_EVENT_INVALID',
|
|
1194
|
+
message: 'Each event must be an object.',
|
|
1195
|
+
path: `$.events[${index}]`,
|
|
1196
|
+
expected: 'object',
|
|
1197
|
+
received: typeof event
|
|
1198
|
+
});
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (typeof event.type !== 'string' || event.type.length === 0) {
|
|
1203
|
+
errors.push({
|
|
1204
|
+
code: 'E_ARTIFACT_EVENT_TYPE_INVALID',
|
|
1205
|
+
message: 'Each event.type must be a non-empty string.',
|
|
1206
|
+
path: `$.events[${index}].type`,
|
|
1207
|
+
expected: 'non-empty string',
|
|
1208
|
+
received: event.type
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
if (!Number.isInteger(event.seq) || event.seq <= 0) {
|
|
1213
|
+
errors.push({
|
|
1214
|
+
code: 'E_ARTIFACT_EVENT_SEQ_INVALID',
|
|
1215
|
+
message: 'Each event.seq must be a positive integer.',
|
|
1216
|
+
path: `$.events[${index}].seq`,
|
|
1217
|
+
expected: 'positive integer',
|
|
1218
|
+
received: event.seq
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (typeof event.timestamp !== 'string' || Number.isNaN(Date.parse(event.timestamp))) {
|
|
1223
|
+
errors.push({
|
|
1224
|
+
code: 'E_ARTIFACT_EVENT_TIMESTAMP_INVALID',
|
|
1225
|
+
message: 'Each event.timestamp must be a valid ISO-8601 string.',
|
|
1226
|
+
path: `$.events[${index}].timestamp`,
|
|
1227
|
+
expected: 'ISO-8601 timestamp string',
|
|
1228
|
+
received: event.timestamp
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (errors.length > 0) {
|
|
1235
|
+
return errors.map((error) => {
|
|
1236
|
+
return {
|
|
1237
|
+
...error,
|
|
1238
|
+
file: filePath
|
|
1239
|
+
};
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return [];
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
async function writeJsonFile(targetPath, data) {
|
|
1247
|
+
await ensureDir(path.dirname(targetPath));
|
|
1248
|
+
await fsp.writeFile(targetPath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function defaultOutputDirectory(cwd) {
|
|
1252
|
+
return path.resolve(cwd, '.replay', 'artifacts', compactDate());
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function artifactStem(runId, prompt) {
|
|
1256
|
+
const promptPart = sanitizePrompt(prompt) || 'prompt';
|
|
1257
|
+
const now = isoNow().replace(/[:.]/g, '-');
|
|
1258
|
+
return `${now}_${promptPart}_${runId.slice(-8)}`;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
async function readHealRules(cwd) {
|
|
1262
|
+
const rulesPath = path.resolve(cwd, '.replay', 'heal.rules.json');
|
|
1263
|
+
|
|
1264
|
+
if (!(await exists(rulesPath))) {
|
|
1265
|
+
return {
|
|
1266
|
+
rules: {
|
|
1267
|
+
eventAliases: DEFAULT_EVENT_ALIASES,
|
|
1268
|
+
version: 'default'
|
|
1269
|
+
},
|
|
1270
|
+
source: 'default'
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const raw = await fsp.readFile(rulesPath, 'utf-8');
|
|
1275
|
+
const parsed = safeJsonParse(raw, 'E_HEAL_RULES_INVALID_JSON');
|
|
1276
|
+
|
|
1277
|
+
if (!isObject(parsed)) {
|
|
1278
|
+
throw new ReplayCliError({
|
|
1279
|
+
code: 'E_HEAL_RULES_SHAPE_INVALID',
|
|
1280
|
+
message: '.replay/heal.rules.json must be a JSON object.',
|
|
1281
|
+
path: '.replay/heal.rules.json'
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
return {
|
|
1286
|
+
rules: {
|
|
1287
|
+
eventAliases: isObject(parsed.eventAliases)
|
|
1288
|
+
? parsed.eventAliases
|
|
1289
|
+
: DEFAULT_EVENT_ALIASES,
|
|
1290
|
+
version: typeof parsed.version === 'string' ? parsed.version : 'custom'
|
|
1291
|
+
},
|
|
1292
|
+
source: rulesPath
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
async function collectJsonFiles(inputPath) {
|
|
1297
|
+
const absolutePath = path.resolve(process.cwd(), inputPath);
|
|
1298
|
+
|
|
1299
|
+
if (!(await exists(absolutePath))) {
|
|
1300
|
+
throw new ReplayCliError({
|
|
1301
|
+
code: 'E_INPUT_PATH_NOT_FOUND',
|
|
1302
|
+
message: `Input path does not exist: ${absolutePath}`,
|
|
1303
|
+
path: '--in',
|
|
1304
|
+
howToFix: 'Provide an existing file or directory path.'
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const stat = await fsp.stat(absolutePath);
|
|
1309
|
+
|
|
1310
|
+
if (stat.isFile()) {
|
|
1311
|
+
if (!absolutePath.endsWith('.json')) {
|
|
1312
|
+
throw new ReplayCliError({
|
|
1313
|
+
code: 'E_INPUT_FILE_NOT_JSON',
|
|
1314
|
+
message: 'Input file must end with .json.',
|
|
1315
|
+
path: '--in',
|
|
1316
|
+
received: absolutePath
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
return [absolutePath];
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (!stat.isDirectory()) {
|
|
1324
|
+
throw new ReplayCliError({
|
|
1325
|
+
code: 'E_INPUT_NOT_FILE_OR_DIR',
|
|
1326
|
+
message: 'Input must be a file or directory.',
|
|
1327
|
+
path: '--in',
|
|
1328
|
+
received: absolutePath
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const files = [];
|
|
1333
|
+
|
|
1334
|
+
async function walk(directory) {
|
|
1335
|
+
const entries = await fsp.readdir(directory, { withFileTypes: true });
|
|
1336
|
+
|
|
1337
|
+
for (const entry of entries) {
|
|
1338
|
+
const absolute = path.resolve(directory, entry.name);
|
|
1339
|
+
|
|
1340
|
+
if (entry.isDirectory()) {
|
|
1341
|
+
await walk(absolute);
|
|
1342
|
+
} else if (entry.isFile() && absolute.endsWith('.json')) {
|
|
1343
|
+
files.push(absolute);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
await walk(absolutePath);
|
|
1349
|
+
|
|
1350
|
+
return files.sort();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
async function captureCommand({ options, positionals }) {
|
|
1354
|
+
const cwd = process.cwd();
|
|
1355
|
+
const prompt = await resolvePrompt(positionals);
|
|
1356
|
+
const context = await resolveContext(options);
|
|
1357
|
+
const { runner } = await discoverRunner(cwd, options.runner);
|
|
1358
|
+
const { rules, source: healRulesSource } = await readHealRules(cwd);
|
|
1359
|
+
|
|
1360
|
+
const outputDir = options.out
|
|
1361
|
+
? path.resolve(cwd, options.out)
|
|
1362
|
+
: defaultOutputDirectory(cwd);
|
|
1363
|
+
|
|
1364
|
+
await ensureDir(outputDir);
|
|
1365
|
+
|
|
1366
|
+
const runId = options.id || `run_${Date.now()}_${randomShortId()}`;
|
|
1367
|
+
const payload = {
|
|
1368
|
+
context,
|
|
1369
|
+
prompt,
|
|
1370
|
+
query: prompt,
|
|
1371
|
+
runId,
|
|
1372
|
+
timestamp: isoNow()
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
const execution = await runRunner({
|
|
1376
|
+
payload,
|
|
1377
|
+
runner,
|
|
1378
|
+
timeoutMs: options.timeoutMs
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
const stem = artifactStem(runId, prompt);
|
|
1382
|
+
const rawPath = path.resolve(outputDir, `${stem}.raw.json`);
|
|
1383
|
+
const rawArtifact = buildRawArtifact({
|
|
1384
|
+
execution,
|
|
1385
|
+
payload,
|
|
1386
|
+
runner
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
await writeJsonFile(rawPath, rawArtifact);
|
|
1390
|
+
|
|
1391
|
+
if (execution.timedOut) {
|
|
1392
|
+
throw new ReplayCliError({
|
|
1393
|
+
code: 'E_RUNNER_TIMEOUT',
|
|
1394
|
+
message: `Runner exceeded timeout (${options.timeoutMs}ms).`,
|
|
1395
|
+
details: {
|
|
1396
|
+
rawArtifact: rawPath,
|
|
1397
|
+
timeoutMs: options.timeoutMs
|
|
1398
|
+
},
|
|
1399
|
+
howToFix:
|
|
1400
|
+
'Increase --timeout-ms, optimize your runner, or return partial error output earlier.'
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if (execution.exitCode !== 0) {
|
|
1405
|
+
throw new ReplayCliError({
|
|
1406
|
+
code: 'E_RUNNER_EXIT_NON_ZERO',
|
|
1407
|
+
message: `Runner exited with non-zero code: ${execution.exitCode}`,
|
|
1408
|
+
details: {
|
|
1409
|
+
exitCode: execution.exitCode,
|
|
1410
|
+
rawArtifact: rawPath,
|
|
1411
|
+
stderr: execution.stderr.slice(-2000)
|
|
1412
|
+
},
|
|
1413
|
+
howToFix: 'Fix the runner process error and retry the command.'
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const parsedOutput = parseRunnerOutput(execution.stdout);
|
|
1418
|
+
const normalizedResult = normalizeRunnerResult(parsedOutput, execution.stderr);
|
|
1419
|
+
const replayArtifact = baseReplayArtifact({
|
|
1420
|
+
normalizedResult,
|
|
1421
|
+
payload,
|
|
1422
|
+
rawExecution: execution,
|
|
1423
|
+
runner
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
const preHealPath = path.resolve(outputDir, `${stem}.artifact.json`);
|
|
1427
|
+
await writeJsonFile(preHealPath, replayArtifact);
|
|
1428
|
+
|
|
1429
|
+
let healedResult = {
|
|
1430
|
+
healLog: [],
|
|
1431
|
+
healed: replayArtifact
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
if (options.heal) {
|
|
1435
|
+
healedResult = healReplayArtifact(replayArtifact, rules);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const healedPath = path.resolve(outputDir, `${stem}.healed.json`);
|
|
1439
|
+
const healLogPath = path.resolve(outputDir, `${stem}.heal-log.json`);
|
|
1440
|
+
await writeJsonFile(healedPath, healedResult.healed);
|
|
1441
|
+
await writeJsonFile(healLogPath, {
|
|
1442
|
+
artifactId: runId,
|
|
1443
|
+
generatedAt: isoNow(),
|
|
1444
|
+
healRulesSource,
|
|
1445
|
+
issues: healedResult.healLog,
|
|
1446
|
+
schemaVersion: SCHEMA_VERSION
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
const validationErrors = validateReplayArtifact(healedResult.healed, healedPath);
|
|
1450
|
+
|
|
1451
|
+
if (validationErrors.length > 0) {
|
|
1452
|
+
throw new ReplayCliError({
|
|
1453
|
+
code: 'E_HEALED_ARTIFACT_INVALID',
|
|
1454
|
+
message: 'Healed artifact failed validation.',
|
|
1455
|
+
details: {
|
|
1456
|
+
errors: validationErrors,
|
|
1457
|
+
healedPath
|
|
1458
|
+
},
|
|
1459
|
+
howToFix: 'Update runner output to include valid status/response/events fields.'
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
outputJson({
|
|
1464
|
+
artifactId: runId,
|
|
1465
|
+
command: 'capture',
|
|
1466
|
+
healIssues: healedResult.healLog,
|
|
1467
|
+
normalizationIssues: normalizedResult.normalizationIssues,
|
|
1468
|
+
ok: true,
|
|
1469
|
+
paths: {
|
|
1470
|
+
artifact: preHealPath,
|
|
1471
|
+
healLog: healLogPath,
|
|
1472
|
+
healed: healedPath,
|
|
1473
|
+
raw: rawPath
|
|
1474
|
+
},
|
|
1475
|
+
status: normalizedResult.status
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
async function validateCommand({ options }) {
|
|
1480
|
+
const inputPath = options.in || '.replay/artifacts';
|
|
1481
|
+
const files = await collectJsonFiles(inputPath);
|
|
1482
|
+
const report = {
|
|
1483
|
+
checked: 0,
|
|
1484
|
+
command: 'validate',
|
|
1485
|
+
errors: [],
|
|
1486
|
+
invalid: 0,
|
|
1487
|
+
ok: true,
|
|
1488
|
+
scanned: files.length,
|
|
1489
|
+
skipped: 0,
|
|
1490
|
+
valid: 0
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
for (const file of files) {
|
|
1494
|
+
const content = await fsp.readFile(file, 'utf-8');
|
|
1495
|
+
const parsed = safeJsonParse(content, 'E_ARTIFACT_JSON_INVALID');
|
|
1496
|
+
|
|
1497
|
+
if (parsed?.artifactType !== 'replay') {
|
|
1498
|
+
report.skipped += 1;
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
report.checked += 1;
|
|
1503
|
+
|
|
1504
|
+
const validationErrors = validateReplayArtifact(parsed, file);
|
|
1505
|
+
|
|
1506
|
+
if (validationErrors.length > 0) {
|
|
1507
|
+
report.invalid += 1;
|
|
1508
|
+
report.errors.push(...validationErrors);
|
|
1509
|
+
} else {
|
|
1510
|
+
report.valid += 1;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
report.ok = report.invalid === 0;
|
|
1515
|
+
outputJson(report);
|
|
1516
|
+
|
|
1517
|
+
if (!report.ok) {
|
|
1518
|
+
process.exitCode = 1;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
async function healCommand({ options }) {
|
|
1523
|
+
const cwd = process.cwd();
|
|
1524
|
+
const inputPath = options.in || '.replay/artifacts';
|
|
1525
|
+
const outDir = path.resolve(cwd, options.out || '.replay/healed');
|
|
1526
|
+
const files = await collectJsonFiles(inputPath);
|
|
1527
|
+
const { rules, source: healRulesSource } = await readHealRules(cwd);
|
|
1528
|
+
|
|
1529
|
+
await ensureDir(outDir);
|
|
1530
|
+
|
|
1531
|
+
const report = {
|
|
1532
|
+
command: 'heal',
|
|
1533
|
+
files: [],
|
|
1534
|
+
healedCount: 0,
|
|
1535
|
+
ok: true,
|
|
1536
|
+
outDir,
|
|
1537
|
+
sourceCount: files.length
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1540
|
+
for (const file of files) {
|
|
1541
|
+
const content = await fsp.readFile(file, 'utf-8');
|
|
1542
|
+
const parsed = safeJsonParse(content, 'E_ARTIFACT_JSON_INVALID');
|
|
1543
|
+
|
|
1544
|
+
if (parsed?.artifactType !== 'replay') {
|
|
1545
|
+
report.files.push({
|
|
1546
|
+
file,
|
|
1547
|
+
skipped: true,
|
|
1548
|
+
reason: 'artifactType is not replay'
|
|
1549
|
+
});
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const { healed, healLog } = healReplayArtifact(parsed, rules);
|
|
1554
|
+
const outputFile = path.resolve(outDir, path.basename(file));
|
|
1555
|
+
const healLogFile = path.resolve(
|
|
1556
|
+
outDir,
|
|
1557
|
+
path.basename(file, '.json') + '.heal-log.json'
|
|
1558
|
+
);
|
|
1559
|
+
|
|
1560
|
+
await writeJsonFile(outputFile, healed);
|
|
1561
|
+
await writeJsonFile(healLogFile, {
|
|
1562
|
+
artifactId: healed.id,
|
|
1563
|
+
generatedAt: isoNow(),
|
|
1564
|
+
healRulesSource,
|
|
1565
|
+
issues: healLog,
|
|
1566
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1567
|
+
sourceFile: file
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
report.files.push({
|
|
1571
|
+
file,
|
|
1572
|
+
healIssues: healLog.length,
|
|
1573
|
+
healedFile: outputFile,
|
|
1574
|
+
healLogFile,
|
|
1575
|
+
skipped: false
|
|
1576
|
+
});
|
|
1577
|
+
report.healedCount += 1;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
outputJson(report);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
async function reportCommand({ options }) {
|
|
1584
|
+
const inputPath = options.in || '.replay/artifacts';
|
|
1585
|
+
const files = await collectJsonFiles(inputPath);
|
|
1586
|
+
|
|
1587
|
+
const replayFiles = [];
|
|
1588
|
+
for (const file of files) {
|
|
1589
|
+
const content = await fsp.readFile(file, 'utf-8');
|
|
1590
|
+
const parsed = safeJsonParse(content, 'E_ARTIFACT_JSON_INVALID');
|
|
1591
|
+
|
|
1592
|
+
if (parsed?.artifactType === 'replay') {
|
|
1593
|
+
replayFiles.push({
|
|
1594
|
+
artifact: parsed,
|
|
1595
|
+
file
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
if (replayFiles.length === 0) {
|
|
1601
|
+
outputJson({
|
|
1602
|
+
command: 'report',
|
|
1603
|
+
ok: true,
|
|
1604
|
+
summary: {
|
|
1605
|
+
errors: 0,
|
|
1606
|
+
noData: true,
|
|
1607
|
+
ok: 0,
|
|
1608
|
+
total: 0
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
const latencies = replayFiles
|
|
1615
|
+
.map(({ artifact }) => artifact.timings?.endToEndLatencyMs)
|
|
1616
|
+
.filter((value) => typeof value === 'number');
|
|
1617
|
+
|
|
1618
|
+
const summary = {
|
|
1619
|
+
averageLatencyMs:
|
|
1620
|
+
latencies.length > 0
|
|
1621
|
+
? Math.round(
|
|
1622
|
+
latencies.reduce((sum, value) => {
|
|
1623
|
+
return sum + value;
|
|
1624
|
+
}, 0) / latencies.length
|
|
1625
|
+
)
|
|
1626
|
+
: null,
|
|
1627
|
+
error: replayFiles.filter(({ artifact }) => artifact.status === 'error').length,
|
|
1628
|
+
ok: replayFiles.filter(({ artifact }) => artifact.status === 'ok').length,
|
|
1629
|
+
total: replayFiles.length,
|
|
1630
|
+
totalToolCalls: replayFiles.reduce((sum, { artifact }) => {
|
|
1631
|
+
return sum + ensureArray(artifact.toolCalls).length;
|
|
1632
|
+
}, 0)
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
outputJson({
|
|
1636
|
+
command: 'report',
|
|
1637
|
+
ok: true,
|
|
1638
|
+
summary
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
async function run() {
|
|
1643
|
+
const { command, options, positionals } = parseArgs(process.argv.slice(2));
|
|
1644
|
+
|
|
1645
|
+
if (command === 'help' || options.help) {
|
|
1646
|
+
printHelp();
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
if (command === 'capture') {
|
|
1651
|
+
await captureCommand({ options, positionals });
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
if (command === 'validate') {
|
|
1656
|
+
await validateCommand({ options });
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
if (command === 'heal') {
|
|
1661
|
+
await healCommand({ options });
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
if (command === 'report') {
|
|
1666
|
+
await reportCommand({ options });
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
throw new ReplayCliError({
|
|
1671
|
+
code: 'E_COMMAND_UNKNOWN',
|
|
1672
|
+
message: `Unknown command: ${command}`,
|
|
1673
|
+
howToFix: 'Use `replay help` to see available commands.'
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
run().catch((error) => {
|
|
1678
|
+
outputError(error);
|
|
1679
|
+
process.exitCode = 1;
|
|
1680
|
+
});
|