agent-device-lab 0.0.0-stage → 0.3.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/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +142 -2
- package/assets/dashboard/app.css +238 -0
- package/assets/dashboard/app.js +976 -0
- package/assets/dashboard/index.html +120 -0
- package/bin/agentlab.js +8 -0
- package/dist/cli/ci.js +443 -0
- package/dist/cli/flow.js +127 -0
- package/dist/cli/main.js +855 -0
- package/dist/cli/signals.js +38 -0
- package/dist/core/action-log.js +125 -0
- package/dist/core/auth.js +175 -0
- package/dist/core/bundle.js +432 -0
- package/dist/core/checks.js +137 -0
- package/dist/core/ci.js +389 -0
- package/dist/core/commands.js +366 -0
- package/dist/core/control.js +217 -0
- package/dist/core/detectors.js +392 -0
- package/dist/core/devices.js +83 -0
- package/dist/core/doctor.js +203 -0
- package/dist/core/explore-safety.js +110 -0
- package/dist/core/extract.js +1445 -0
- package/dist/core/feed.js +493 -0
- package/dist/core/findings.js +179 -0
- package/dist/core/format.js +311 -0
- package/dist/core/init.js +607 -0
- package/dist/core/lab.js +1979 -0
- package/dist/core/migrate.js +35 -0
- package/dist/core/observation.js +161 -0
- package/dist/core/ownership.js +142 -0
- package/dist/core/process-group.js +206 -0
- package/dist/core/process-identity.js +56 -0
- package/dist/core/profile.js +816 -0
- package/dist/core/project-runner.js +359 -0
- package/dist/core/replay.js +303 -0
- package/dist/core/reproduction.js +36 -0
- package/dist/core/scan-policy.js +156 -0
- package/dist/core/scan-report.js +145 -0
- package/dist/core/scan.js +455 -0
- package/dist/core/schema.js +39 -0
- package/dist/core/services.js +134 -0
- package/dist/core/steps.js +80 -0
- package/dist/core/sweep.js +138 -0
- package/dist/core/trace-sanitize.js +641 -0
- package/dist/core/url-redact.js +25 -0
- package/dist/core/versions.js +29 -0
- package/dist/core/zip.js +264 -0
- package/dist/daemon/main.js +160 -0
- package/dist/daemon/state.js +93 -0
- package/dist/dashboard/open.js +25 -0
- package/dist/dashboard/server.js +366 -0
- package/dist/mcp/server.js +89 -0
- package/package.json +58 -4
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { connect } from 'node:net';
|
|
4
|
+
import { redactSecrets } from './feed.js';
|
|
5
|
+
import { identify } from './process-identity.js';
|
|
6
|
+
import { describeMemberStop, mergeMembers, snapshotMembers, stopGroup } from './process-group.js';
|
|
7
|
+
import { LabError } from './schema.js';
|
|
8
|
+
/** One readiness probe: GET url+path, following redirects. */
|
|
9
|
+
export async function probe(spec, timeoutMs = 2000) {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(spec.url + spec.readiness.path, { signal: AbortSignal.timeout(timeoutMs) });
|
|
12
|
+
await res.body?.cancel();
|
|
13
|
+
return res.status === spec.readiness.status ? { kind: 'healthy', status: res.status } : { kind: 'unhealthy', status: res.status };
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const cause = err.cause;
|
|
17
|
+
return { kind: 'down', reason: cause?.code ?? err.name };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Does host:port accept a TCP connection? */
|
|
21
|
+
export function probeTcp(host, port, timeoutMs = 1000) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const socket = connect({ host, port });
|
|
24
|
+
const done = (r) => { socket.destroy(); resolve(r); };
|
|
25
|
+
socket.setTimeout(timeoutMs, () => done({ kind: 'down', reason: 'ETIMEDOUT' }));
|
|
26
|
+
socket.once('connect', () => done({ kind: 'healthy', status: 0 }));
|
|
27
|
+
socket.once('error', (err) => done({ kind: 'down', reason: err.code ?? err.name }));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Probe an http or tcp readiness check; other kinds cannot detect an instance that is already running. */
|
|
31
|
+
export function probeReadiness(r, timeoutMs = 2000) {
|
|
32
|
+
if (r.kind === 'http')
|
|
33
|
+
return probe({ url: r.url, readiness: { path: r.path, status: r.status, timeoutMs: r.timeoutMs, intervalMs: r.intervalMs } }, timeoutMs);
|
|
34
|
+
if (r.kind === 'tcp')
|
|
35
|
+
return probeTcp(r.host, r.port, Math.min(timeoutMs, 1000));
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export function describeReadiness(r) {
|
|
39
|
+
switch (r.kind) {
|
|
40
|
+
case 'http': return `GET ${r.url}${r.path} ${r.status}`;
|
|
41
|
+
case 'tcp': return `tcp ${r.host}:${r.port}`;
|
|
42
|
+
case 'log': return `log /${r.pattern}/`;
|
|
43
|
+
case 'alive': return `alive ${r.ms}ms`;
|
|
44
|
+
case 'exit': return 'exit 0';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Each service's output file stops growing at this size (env AGENTLAB_SERVICE_LOG_MAX_BYTES). */
|
|
48
|
+
export const SERVICE_LOG_MAX_BYTES = 20 * 1024 * 1024;
|
|
49
|
+
export function serviceLogMax(env = process.env) {
|
|
50
|
+
const n = Number(env.AGENTLAB_SERVICE_LOG_MAX_BYTES);
|
|
51
|
+
return Number.isInteger(n) && n > 0 ? n : SERVICE_LOG_MAX_BYTES;
|
|
52
|
+
}
|
|
53
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
54
|
+
/**
|
|
55
|
+
* A declared service the lab is responsible for. Only instances with `owned === true` are ever
|
|
56
|
+
* stopped; a reused service is never signalled, and a stop command runs only for a one-shot service
|
|
57
|
+
* the lab itself started.
|
|
58
|
+
*/
|
|
59
|
+
export class Service {
|
|
60
|
+
spec;
|
|
61
|
+
info;
|
|
62
|
+
child;
|
|
63
|
+
log;
|
|
64
|
+
exited;
|
|
65
|
+
stopping = false;
|
|
66
|
+
/** Identity of the process-group leader, captured at spawn; used for crash recovery. */
|
|
67
|
+
identity;
|
|
68
|
+
/**
|
|
69
|
+
* Identities of the other processes in the leader's group, snapshotted while the leader was alive. If the
|
|
70
|
+
* leader (the shell wrapper) dies first, these are what stop() may still signal, one by one.
|
|
71
|
+
*/
|
|
72
|
+
groupMembers = [];
|
|
73
|
+
constructor(spec, info, child, log) {
|
|
74
|
+
this.spec = spec;
|
|
75
|
+
this.info = info;
|
|
76
|
+
this.child = child;
|
|
77
|
+
this.log = log;
|
|
78
|
+
this.identity = spec.mode === 'process' ? identify(child?.pid) : undefined;
|
|
79
|
+
this.exited = child
|
|
80
|
+
? new Promise((resolve) => (child.exitCode !== null || child.signalCode !== null ? resolve() : child.once('exit', () => resolve())))
|
|
81
|
+
: Promise.resolve();
|
|
82
|
+
}
|
|
83
|
+
/** Reuse a service that already passes its http/tcp readiness check, or run its command and wait for readiness. */
|
|
84
|
+
static async ensure(spec, opts = {}) {
|
|
85
|
+
const t0 = Date.now();
|
|
86
|
+
const at = spec.url ?? describeReadiness(spec.readiness);
|
|
87
|
+
const existing = await probeReadiness(spec.readiness);
|
|
88
|
+
if (existing?.kind === 'healthy') {
|
|
89
|
+
if (!spec.reuseExisting) {
|
|
90
|
+
throw new LabError('port_conflict', `${label(spec)}: something is already ready at ${at} and reuseExisting is false`, {
|
|
91
|
+
hint: 'Stop that process yourself, or set "reuseExisting": true.', details: { service: spec.name },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return new Service(spec, {
|
|
95
|
+
name: spec.name, ...(spec.url ? { url: spec.url } : {}), mode: spec.mode, status: 'reused', owned: false,
|
|
96
|
+
command: spec.command, readyMs: Date.now() - t0, readiness: describeReadiness(spec.readiness),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (existing?.kind === 'unhealthy' && spec.readiness.kind === 'http') {
|
|
100
|
+
throw new LabError('port_conflict', `${label(spec)}: something is already answering at ${spec.readiness.url} but ${spec.readiness.path} returned ${existing.status} (expected ${spec.readiness.status})`, { hint: 'The lab will not start a second server on an occupied port or stop a process it did not start. Stop it or fix the readiness check.', details: { service: spec.name } });
|
|
101
|
+
}
|
|
102
|
+
return Service.start(spec, opts, t0);
|
|
103
|
+
}
|
|
104
|
+
static async start(spec, opts, t0) {
|
|
105
|
+
const log = opts.logFile ? createWriteStream(opts.logFile, { flags: 'a' }) : undefined;
|
|
106
|
+
const tail = [];
|
|
107
|
+
let logMatched = false;
|
|
108
|
+
const pattern = spec.readiness.kind === 'log' ? new RegExp(spec.readiness.pattern) : undefined;
|
|
109
|
+
const maxBytes = serviceLogMax();
|
|
110
|
+
let written = 0;
|
|
111
|
+
// The file is capped; readiness matching and the tail below keep seeing every line.
|
|
112
|
+
const write = (chunk) => {
|
|
113
|
+
if (!log || log.writableEnded || written > maxBytes)
|
|
114
|
+
return;
|
|
115
|
+
const room = maxBytes - written;
|
|
116
|
+
if (chunk.length <= room) {
|
|
117
|
+
written += chunk.length;
|
|
118
|
+
log.write(chunk);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
log.write(chunk.subarray(0, room));
|
|
122
|
+
log.write(`\n… output truncated at ${maxBytes} bytes; later output discarded\n`);
|
|
123
|
+
written = maxBytes + 1;
|
|
124
|
+
};
|
|
125
|
+
const record = (stream) => (chunk) => {
|
|
126
|
+
write(chunk);
|
|
127
|
+
for (const line of chunk.toString().split(/\r?\n/)) {
|
|
128
|
+
if (!line.trim())
|
|
129
|
+
continue;
|
|
130
|
+
tail.push(`${stream}: ${line}`);
|
|
131
|
+
if (tail.length > 40)
|
|
132
|
+
tail.shift();
|
|
133
|
+
if (pattern?.test(line))
|
|
134
|
+
logMatched = true;
|
|
135
|
+
opts.onLog?.(line);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
// detached → own process group, so stop() can signal the command and its children (npm → node).
|
|
139
|
+
const child = spawn(spec.command, {
|
|
140
|
+
cwd: spec.cwd,
|
|
141
|
+
env: { ...process.env, ...spec.env },
|
|
142
|
+
shell: true,
|
|
143
|
+
detached: true,
|
|
144
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
|
+
});
|
|
146
|
+
child.stdout?.on('data', record('out'));
|
|
147
|
+
child.stderr?.on('data', record('err'));
|
|
148
|
+
let spawnError;
|
|
149
|
+
child.once('error', (err) => { spawnError = err; });
|
|
150
|
+
const service = new Service(spec, {
|
|
151
|
+
name: spec.name, ...(spec.url ? { url: spec.url } : {}), mode: spec.mode, status: 'started', owned: true,
|
|
152
|
+
...(spec.mode === 'process' ? { pid: child.pid } : {}), command: spec.command, readyMs: 0,
|
|
153
|
+
readiness: describeReadiness(spec.readiness), ...(opts.logFile ? { logFile: opts.logFile } : {}),
|
|
154
|
+
}, child, log);
|
|
155
|
+
const exited = () => !!spawnError || child.exitCode !== null || child.signalCode !== null;
|
|
156
|
+
const details = (extra = {}) => ({ service: spec.name, command: spec.command, cwd: spec.cwd, logTail: tail.slice(-15), logFile: opts.logFile, ...extra });
|
|
157
|
+
const failExit = async () => {
|
|
158
|
+
await sleep(50); // let trailing output arrive
|
|
159
|
+
log?.end();
|
|
160
|
+
throw new LabError('startup_failed', `${label(spec)}: command exited before becoming ready (${spawnError?.message ?? `exit code ${child.exitCode ?? child.signalCode}`})`, { hint: startupHint(tail, child.exitCode), details: details({ exitCode: child.exitCode }) });
|
|
161
|
+
};
|
|
162
|
+
const aborted = async () => {
|
|
163
|
+
await service.stop();
|
|
164
|
+
throw new LabError('startup_failed', `${label(spec)}: start abandoned because another service failed; the started process was stopped`, {
|
|
165
|
+
details: details({ aborted: true }),
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const r = spec.readiness;
|
|
169
|
+
const timeoutMs = r.kind === 'alive' ? r.ms + 60_000 : r.timeoutMs;
|
|
170
|
+
const deadline = t0 + timeoutMs;
|
|
171
|
+
if (spec.mode === 'oneshot') {
|
|
172
|
+
// The command itself must finish successfully (e.g. `docker compose up -d`), then readiness is polled.
|
|
173
|
+
while (!exited()) {
|
|
174
|
+
if (opts.signal?.aborted)
|
|
175
|
+
return aborted();
|
|
176
|
+
if (Date.now() > deadline) {
|
|
177
|
+
await service.kill();
|
|
178
|
+
throw new LabError('readiness_timeout', `${label(spec)}: one-shot command did not finish within ${timeoutMs}ms; it was stopped`, {
|
|
179
|
+
hint: startupHint(tail), details: details(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
await sleep(50);
|
|
183
|
+
}
|
|
184
|
+
await sleep(20);
|
|
185
|
+
if (spawnError || child.exitCode !== 0)
|
|
186
|
+
return failExit();
|
|
187
|
+
}
|
|
188
|
+
let last = 'not probed';
|
|
189
|
+
while (Date.now() < deadline) {
|
|
190
|
+
if (opts.signal?.aborted)
|
|
191
|
+
return aborted();
|
|
192
|
+
if (spec.mode === 'process' && exited())
|
|
193
|
+
return failExit();
|
|
194
|
+
let ready = false;
|
|
195
|
+
if (r.kind === 'log')
|
|
196
|
+
ready = logMatched;
|
|
197
|
+
else if (r.kind === 'exit')
|
|
198
|
+
ready = true;
|
|
199
|
+
else if (r.kind === 'alive')
|
|
200
|
+
ready = Date.now() - t0 >= r.ms;
|
|
201
|
+
else {
|
|
202
|
+
const p = (await probeReadiness(r, Math.min(2000, r.intervalMs * 4)));
|
|
203
|
+
ready = p.kind === 'healthy';
|
|
204
|
+
last = p.kind === 'down' ? p.reason : `HTTP ${p.status}`;
|
|
205
|
+
}
|
|
206
|
+
if (ready) {
|
|
207
|
+
service.info.readyMs = Date.now() - t0;
|
|
208
|
+
if (spec.mode === 'process') {
|
|
209
|
+
service.refreshMembers();
|
|
210
|
+
service.watchExit(opts.onUnexpectedExit, tail);
|
|
211
|
+
}
|
|
212
|
+
return service;
|
|
213
|
+
}
|
|
214
|
+
await sleep(r.kind === 'http' || r.kind === 'tcp' ? r.intervalMs : 50);
|
|
215
|
+
}
|
|
216
|
+
const stop = await service.stop();
|
|
217
|
+
const what = r.kind === 'http' ? `${r.url}${r.path}` : describeReadiness(r);
|
|
218
|
+
throw new LabError('readiness_timeout', `${label(spec)}: ${what} was not ready within ${timeoutMs}ms${r.kind === 'http' || r.kind === 'tcp' ? ` (last probe: ${last})` : ''}; the started process was ${stop.stopped ? 'stopped' : `not stopped (${stop.reason})`}`, { hint: startupHint(tail), details: details() });
|
|
219
|
+
}
|
|
220
|
+
/** Report an exit that the lab did not cause (see stop and kill). */
|
|
221
|
+
watchExit(cb, tail) {
|
|
222
|
+
const child = this.child;
|
|
223
|
+
if (!cb || !child)
|
|
224
|
+
return;
|
|
225
|
+
const report = () => {
|
|
226
|
+
if (this.stopping)
|
|
227
|
+
return;
|
|
228
|
+
// Let trailing output arrive before the tail is read.
|
|
229
|
+
setTimeout(() => cb({ name: this.spec.name, code: child.exitCode, signal: child.signalCode, logTail: tail.slice(-10).map(redactSecrets) }), 50);
|
|
230
|
+
};
|
|
231
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
232
|
+
report();
|
|
233
|
+
else
|
|
234
|
+
child.once('exit', report);
|
|
235
|
+
}
|
|
236
|
+
/** Snapshot the group's current members; possible only while the leader is alive and verifiably ours. */
|
|
237
|
+
refreshMembers() {
|
|
238
|
+
if (this.spec.mode !== 'process' || !this.info.owned)
|
|
239
|
+
return;
|
|
240
|
+
const snapshot = snapshotMembers(this.identity);
|
|
241
|
+
if (snapshot)
|
|
242
|
+
this.groupMembers = mergeMembers(this.groupMembers, snapshot);
|
|
243
|
+
}
|
|
244
|
+
/** The recorded group members (not the leader), for ownership records. */
|
|
245
|
+
get members() {
|
|
246
|
+
return this.groupMembers;
|
|
247
|
+
}
|
|
248
|
+
get running() {
|
|
249
|
+
return !!this.child && this.child.exitCode === null && this.child.signalCode === null;
|
|
250
|
+
}
|
|
251
|
+
/** Stop the service if and only if the lab started it: signal its process group, or run a one-shot's stop command. */
|
|
252
|
+
async stop() {
|
|
253
|
+
if (!this.info.owned || !this.child?.pid)
|
|
254
|
+
return { stopped: false, reason: 'not owned by the lab; left running' };
|
|
255
|
+
this.stopping = true;
|
|
256
|
+
if (this.spec.mode === 'oneshot') {
|
|
257
|
+
if (this.running)
|
|
258
|
+
await this.kill();
|
|
259
|
+
this.log?.end();
|
|
260
|
+
if (!this.spec.shutdown.command)
|
|
261
|
+
return { stopped: false, reason: 'one-shot command finished; no shutdown.command declared' };
|
|
262
|
+
return runStopCommand(this.spec);
|
|
263
|
+
}
|
|
264
|
+
const { signal: sig, graceMs } = this.spec.shutdown;
|
|
265
|
+
if (this.running)
|
|
266
|
+
this.refreshMembers();
|
|
267
|
+
// The shell (e.g. dash) may stay as the service's parent and exit first, so the service has
|
|
268
|
+
// stopped only when its whole process group is empty. If the leader is already gone, only the
|
|
269
|
+
// members recorded while it lived are signalled, each after an identity check.
|
|
270
|
+
const r = await stopGroup(this.identity, this.groupMembers, { signal: sig, graceMs, ...(this.running ? { liveLeaderPid: this.child.pid } : {}) });
|
|
271
|
+
await this.exited;
|
|
272
|
+
this.log?.end();
|
|
273
|
+
if (r.via === 'group')
|
|
274
|
+
return { stopped: true, reason: r.graceful ? sig : `SIGKILL after ${graceMs}ms grace period` };
|
|
275
|
+
const detail = describeMemberStop(r, graceMs);
|
|
276
|
+
if (!detail)
|
|
277
|
+
return { stopped: true, reason: 'already exited' };
|
|
278
|
+
return { stopped: r.survivors.length === 0 && r.unverified.length === 0, reason: `already exited; ${detail}` };
|
|
279
|
+
}
|
|
280
|
+
/** Immediately end a command the lab started (a one-shot that overran). */
|
|
281
|
+
async kill() {
|
|
282
|
+
if (!this.child?.pid || !this.running)
|
|
283
|
+
return;
|
|
284
|
+
this.stopping = true;
|
|
285
|
+
try {
|
|
286
|
+
process.kill(-this.child.pid, 'SIGKILL');
|
|
287
|
+
}
|
|
288
|
+
catch { /* gone */ }
|
|
289
|
+
await this.exited;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Run a one-shot service's declared stop command (e.g. `docker compose stop cache`). */
|
|
293
|
+
function runStopCommand(spec) {
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
const child = spawn(spec.shutdown.command, { cwd: spec.cwd, env: { ...process.env, ...spec.env }, shell: true, stdio: 'ignore', detached: true });
|
|
296
|
+
const timer = setTimeout(() => { try {
|
|
297
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
298
|
+
}
|
|
299
|
+
catch { /* gone */ } }, Math.max(spec.shutdown.graceMs, 30_000));
|
|
300
|
+
child.once('error', (err) => { clearTimeout(timer); resolve({ stopped: false, reason: `stop command failed: ${err.message}` }); });
|
|
301
|
+
child.once('exit', (code, sig) => {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
resolve(code === 0 ? { stopped: true, reason: `ran ${spec.shutdown.command}` } : { stopped: false, reason: `stop command exited ${code ?? sig}` });
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/** The single web server of a schemaVersion 1 profile: a Service named "web" with an http readiness check. */
|
|
308
|
+
export class WebServer {
|
|
309
|
+
service;
|
|
310
|
+
constructor(service) {
|
|
311
|
+
this.service = service;
|
|
312
|
+
}
|
|
313
|
+
static async ensure(spec, opts = {}) {
|
|
314
|
+
return new WebServer(await Service.ensure(webService(spec), opts));
|
|
315
|
+
}
|
|
316
|
+
get info() {
|
|
317
|
+
return serverInfo(this.service.info);
|
|
318
|
+
}
|
|
319
|
+
get identity() {
|
|
320
|
+
return this.service.identity;
|
|
321
|
+
}
|
|
322
|
+
get running() {
|
|
323
|
+
return this.service.running;
|
|
324
|
+
}
|
|
325
|
+
stop() {
|
|
326
|
+
return this.service.stop();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
export function webService(spec) {
|
|
330
|
+
return {
|
|
331
|
+
name: 'web', command: spec.command, cwd: spec.cwd, url: spec.url, env: spec.env, requiredEnv: [],
|
|
332
|
+
readiness: { kind: 'http', url: spec.url, ...spec.readiness }, dependsOn: [], reuseExisting: spec.reuseExisting,
|
|
333
|
+
required: true, mode: 'process', shutdown: { signal: 'SIGTERM', graceMs: 5000 },
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** The single-server view (start and status results) of a service. */
|
|
337
|
+
export function serverInfo(s) {
|
|
338
|
+
return {
|
|
339
|
+
url: s.url ?? '', owned: s.owned, reused: s.status === 'reused',
|
|
340
|
+
...(s.pid !== undefined ? { pid: s.pid } : {}), ...(s.owned ? { command: s.command } : {}),
|
|
341
|
+
readyMs: s.readyMs, ...(s.logFile ? { logFile: s.logFile } : {}),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function label(spec) {
|
|
345
|
+
return `service "${spec.name}"`;
|
|
346
|
+
}
|
|
347
|
+
function startupHint(tail, exitCode) {
|
|
348
|
+
const text = tail.join('\n');
|
|
349
|
+
if (exitCode === 127 || /: not found$/m.test(text))
|
|
350
|
+
return 'The command was not found; install project dependencies or fix the service command.';
|
|
351
|
+
if (/EADDRINUSE|address already in use/i.test(text))
|
|
352
|
+
return 'The port is already in use by another process.';
|
|
353
|
+
if (/command not found|ENOENT|Cannot find module/i.test(text))
|
|
354
|
+
return 'A command or module is missing; install project dependencies first.';
|
|
355
|
+
if (/Cannot connect to the Docker daemon|docker daemon/i.test(text))
|
|
356
|
+
return 'Docker is not running; start it and try again.';
|
|
357
|
+
return 'See the log tail and log file for the service output.';
|
|
358
|
+
}
|
|
359
|
+
//# sourceMappingURL=project-runner.js.map
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { routePath } from './action-log.js';
|
|
3
|
+
import { loadBundle, sha256 } from './bundle.js';
|
|
4
|
+
import { Lab } from './lab.js';
|
|
5
|
+
import { loadProfile } from './profile.js';
|
|
6
|
+
import { LabError, SCHEMA_VERSION, } from './schema.js';
|
|
7
|
+
// Replay: re-run the agent actions of a failure bundle against the live project and say whether the failure
|
|
8
|
+
// happens again. Security: nothing that runs comes from the bundle. Commands, environment and paths always
|
|
9
|
+
// come from the agentlab.json in --project (its hash is compared and a difference is only a warning); the
|
|
10
|
+
// bundle supplies role/name targets and typed values, which go through the same `act()` (and so the same
|
|
11
|
+
// uploads.allow check) an agent's would. A replay never continues past a divergence: it stops there and reports.
|
|
12
|
+
const ACTIONS = [
|
|
13
|
+
'click', 'fill', 'press', 'select', 'check', 'uncheck', 'scroll', 'swipe', 'back', 'forward', 'hover', 'upload', 'drag', 'open_tab', 'switch_tab', 'close_tab',
|
|
14
|
+
];
|
|
15
|
+
const NEEDS_TARGET = ['click', 'fill', 'select', 'check', 'uncheck', 'hover', 'upload', 'drag'];
|
|
16
|
+
const DIRECTIONS = ['up', 'down', 'left', 'right'];
|
|
17
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
18
|
+
/** 0 reproduced, 1 not reproduced, 3 diverged or blocked (2, could not run, is an error thrown before a result exists). */
|
|
19
|
+
export function replayExitCode(r) {
|
|
20
|
+
return r.outcome === 'reproduced' ? 0 : r.outcome === 'not-reproduced' ? 1 : 3;
|
|
21
|
+
}
|
|
22
|
+
const isRec = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
23
|
+
/** The bundle is untrusted input: check every field the replay uses before it drives a browser. */
|
|
24
|
+
function checkActions(raw) {
|
|
25
|
+
const bad = (i, why) => { throw new LabError('bundle_invalid', `Not a usable failure bundle: action ${i + 1} ${why}`); };
|
|
26
|
+
const optStr = (v) => v === undefined || typeof v === 'string';
|
|
27
|
+
const target = (v, i, what) => {
|
|
28
|
+
if (v === undefined)
|
|
29
|
+
return;
|
|
30
|
+
if (!isRec(v) || typeof v.role !== 'string' || typeof v.name !== 'string' || !optStr(v.context))
|
|
31
|
+
bad(i, `has a malformed ${what}`);
|
|
32
|
+
};
|
|
33
|
+
raw.forEach((a, i) => {
|
|
34
|
+
if (!isRec(a) || !Number.isInteger(a.index) || a.index < 1 || typeof a.at !== 'string')
|
|
35
|
+
return bad(i, 'is malformed');
|
|
36
|
+
if (a.actor === 'person') {
|
|
37
|
+
if (typeof a.description !== 'string')
|
|
38
|
+
bad(i, 'has no description');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (a.actor !== 'agent' || !ACTIONS.includes(a.action))
|
|
42
|
+
return bad(i, 'has an unknown actor or action');
|
|
43
|
+
target(a.target, i, 'target');
|
|
44
|
+
target(a.toTarget, i, 'toTarget');
|
|
45
|
+
const args = a.args;
|
|
46
|
+
if (!isRec(args))
|
|
47
|
+
return bad(i, 'has no args');
|
|
48
|
+
for (const k of ['value', 'key', 'tab', 'path'])
|
|
49
|
+
if (!optStr(args[k]))
|
|
50
|
+
bad(i, `has a non-text ${k}`);
|
|
51
|
+
for (const k of ['amount', 'dx', 'dy'])
|
|
52
|
+
if (args[k] !== undefined && (typeof args[k] !== 'number' || !Number.isFinite(args[k])))
|
|
53
|
+
bad(i, `has a non-numeric ${k}`);
|
|
54
|
+
for (const k of ['values', 'files'])
|
|
55
|
+
if (args[k] !== undefined && (!Array.isArray(args[k]) || !args[k].every((x) => typeof x === 'string')))
|
|
56
|
+
bad(i, `has a malformed ${k}`);
|
|
57
|
+
if (args.direction !== undefined && !DIRECTIONS.includes(args.direction))
|
|
58
|
+
bad(i, 'has an unknown direction');
|
|
59
|
+
for (const k of ['routeBefore', 'routeAfter', 'dialogBefore', 'dialogAfter'])
|
|
60
|
+
if (!optStr(a[k]))
|
|
61
|
+
bad(i, `has a malformed ${k}`);
|
|
62
|
+
if (a.outcome !== 'success' && a.outcome !== 'error')
|
|
63
|
+
bad(i, 'has no outcome');
|
|
64
|
+
if (a.error !== undefined && (!isRec(a.error) || typeof a.error.code !== 'string'))
|
|
65
|
+
bad(i, 'has a malformed error');
|
|
66
|
+
});
|
|
67
|
+
return [...raw].sort((a, b) => a.index - b.index);
|
|
68
|
+
}
|
|
69
|
+
function describe(a) {
|
|
70
|
+
const t = a.target ? ` ${a.target.role} ${JSON.stringify(a.target.name)}${a.target.context ? ` (in ${JSON.stringify(a.target.context)})` : ''}` : '';
|
|
71
|
+
const args = a.args;
|
|
72
|
+
switch (a.action) {
|
|
73
|
+
case 'fill': return `fill${t} ${a.secret ? 'with ‹secret›' : `(${(args.value ?? '').length} chars)`}`;
|
|
74
|
+
case 'press': return `press ${a.secret ? '‹secret›' : args.key}${t ? ` in${t}` : ''}`;
|
|
75
|
+
case 'select': return `select ${args.values?.length ?? 0} option(s) in${t}`;
|
|
76
|
+
case 'scroll':
|
|
77
|
+
case 'swipe': return `${a.action} ${args.direction ?? 'to'}${t}`;
|
|
78
|
+
case 'upload': return `upload ${args.files?.length ?? 0} file(s) to${t}`;
|
|
79
|
+
case 'drag': return `drag${t} ${a.toTarget ? `onto ${a.toTarget.role} ${JSON.stringify(a.toTarget.name)}` : `by (${args.dx ?? 0}, ${args.dy ?? 0})`}`;
|
|
80
|
+
case 'open_tab': return `open a tab at ${args.path}`;
|
|
81
|
+
case 'switch_tab':
|
|
82
|
+
case 'close_tab': return `${a.action} ${args.tab ?? ''}`.trim();
|
|
83
|
+
default: return `${a.action}${t}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const matchesTarget = (o, t) => o.controls.filter((c) => c.role === t.role && c.name === t.name);
|
|
87
|
+
/** The ref of the one control matching role, name and (to pick among duplicates) context; otherwise why not. Never guesses. */
|
|
88
|
+
async function resolveTarget(lab, t) {
|
|
89
|
+
const find = (o) => {
|
|
90
|
+
let m = matchesTarget(o, t);
|
|
91
|
+
if (m.length > 1 && t.context !== undefined)
|
|
92
|
+
m = m.filter((c) => c.context === t.context);
|
|
93
|
+
return m;
|
|
94
|
+
};
|
|
95
|
+
let obs = lab.lastObservation;
|
|
96
|
+
let m = find(obs);
|
|
97
|
+
if (!m.length && obs.omitted > 0) {
|
|
98
|
+
obs = await lab.observe({ limit: 500 });
|
|
99
|
+
m = find(obs);
|
|
100
|
+
}
|
|
101
|
+
const what = `${t.role} ${JSON.stringify(t.name)}${t.context ? ` in ${JSON.stringify(t.context)}` : ''}`;
|
|
102
|
+
if (m.length === 1)
|
|
103
|
+
return { ref: m[0].ref };
|
|
104
|
+
if (m.length > 1)
|
|
105
|
+
return { problem: `${m.length} controls match ${what}; not guessing` };
|
|
106
|
+
const sameRole = obs.controls.filter((c) => c.role === t.role).slice(0, 6).map((c) => JSON.stringify(c.name));
|
|
107
|
+
return { problem: `no ${what}${sameRole.length ? ` (${t.role}s here: ${sameRole.join(', ')})` : ''}` };
|
|
108
|
+
}
|
|
109
|
+
export async function replayBundle(o) {
|
|
110
|
+
const env = o.env ?? process.env;
|
|
111
|
+
const bundle = loadBundle(o.bundle);
|
|
112
|
+
const actions = checkActions(bundle.actions);
|
|
113
|
+
const failure = bundle.failure;
|
|
114
|
+
const failStep = failure.kind === 'action-error' ? failure.step : undefined;
|
|
115
|
+
for (const [step, name] of Object.entries(o.secrets ?? {})) {
|
|
116
|
+
const a = actions.find((x) => x.index === Number(step));
|
|
117
|
+
if (!ENV_NAME.test(name))
|
|
118
|
+
throw new LabError('invalid_request', `--secret ${step}=${name.slice(0, 40)}: "${name.slice(0, 40)}" is not an environment variable name`);
|
|
119
|
+
if (!a || a.actor !== 'agent' || !a.secret)
|
|
120
|
+
throw new LabError('invalid_request', `--secret: step ${step} has no masked value`, { hint: 'Steps with a masked value are marked ‹secret› in `agentlab bundles show`.' });
|
|
121
|
+
}
|
|
122
|
+
const profile = await loadProfile(o.project ?? process.cwd());
|
|
123
|
+
const warnings = [];
|
|
124
|
+
if (sha256(readFileSync(profile.profilePath)) !== bundle.project.profileSha256) {
|
|
125
|
+
warnings.push('agentlab.json differs from the one the bundle was recorded with; replaying against the current one');
|
|
126
|
+
}
|
|
127
|
+
const result = {
|
|
128
|
+
schemaVersion: SCHEMA_VERSION, outcome: 'blocked', reason: '', warnings, steps: [],
|
|
129
|
+
bundle: { id: bundle.id, createdAt: bundle.retention?.createdAt ?? '', reason: bundle.reason, failure },
|
|
130
|
+
};
|
|
131
|
+
const finish = (outcome, reason) => Object.assign(result, { outcome, reason });
|
|
132
|
+
const step = (s) => { result.steps.push(s); o.onStep?.(s); };
|
|
133
|
+
const block = (index, description, reason) => {
|
|
134
|
+
step({ index, description, status: 'blocked', detail: reason });
|
|
135
|
+
result.blocked = { step: index, reason };
|
|
136
|
+
return finish('blocked', `step ${index}: ${reason}`);
|
|
137
|
+
};
|
|
138
|
+
if (bundle.actionsOmitted > 0) {
|
|
139
|
+
return block(1, 'the recorded start', `the bundle's log dropped its first ${bundle.actionsOmitted} action(s), so the session cannot be rebuilt from the start`);
|
|
140
|
+
}
|
|
141
|
+
const lab = new Lab({ stateDir: o.stateDir });
|
|
142
|
+
o.onLab?.(lab);
|
|
143
|
+
try {
|
|
144
|
+
const started = await lab.start({
|
|
145
|
+
project: profile.profilePath, device: bundle.session.device, headed: o.headed === true, auth: bundle.session.auth === 'saved-state' ? 'saved' : 'fresh',
|
|
146
|
+
});
|
|
147
|
+
result.session = { id: started.session.id, device: started.session.device.id };
|
|
148
|
+
const now = () => ({ route: routePath(lab.lastObservation?.route), dialog: lab.lastObservation?.dialog });
|
|
149
|
+
const last = Math.min(o.until ?? Infinity, failStep ?? Infinity);
|
|
150
|
+
for (const a of actions) {
|
|
151
|
+
if (a.index > last)
|
|
152
|
+
break;
|
|
153
|
+
if (a.actor === 'person')
|
|
154
|
+
return block(a.index, `a person: ${a.description}`, `a person's interaction is recorded only as a description ("${a.description}"), so it cannot be replayed`);
|
|
155
|
+
const description = describe(a);
|
|
156
|
+
const diverge = (what, expected, actual) => {
|
|
157
|
+
step({ index: a.index, description, status: 'diverged', detail: `${what}: expected ${expected}, got ${actual}` });
|
|
158
|
+
result.divergence = { step: a.index, what, expected, actual };
|
|
159
|
+
return finish('diverged', `step ${a.index}: ${what}: expected ${expected}, got ${actual}`);
|
|
160
|
+
};
|
|
161
|
+
const cur = now();
|
|
162
|
+
if (a.routeBefore !== undefined && cur.route !== a.routeBefore)
|
|
163
|
+
return diverge('route before the step', a.routeBefore, cur.route ?? 'none');
|
|
164
|
+
if ((a.dialogBefore ?? null) !== (cur.dialog ?? null))
|
|
165
|
+
return diverge('dialog before the step', a.dialogBefore ? JSON.stringify(a.dialogBefore) : 'none', cur.dialog ? JSON.stringify(cur.dialog) : 'none');
|
|
166
|
+
if (NEEDS_TARGET.includes(a.action) && !a.target)
|
|
167
|
+
return block(a.index, description, 'the recorded action has no role/name target (its ref was not in the last observation), so it cannot be found again');
|
|
168
|
+
let ref;
|
|
169
|
+
let toRef;
|
|
170
|
+
if (a.target) {
|
|
171
|
+
const r = await resolveTarget(lab, a.target);
|
|
172
|
+
if ('problem' in r)
|
|
173
|
+
return diverge('target', `${a.target.role} ${JSON.stringify(a.target.name)}`, r.problem);
|
|
174
|
+
ref = r.ref;
|
|
175
|
+
}
|
|
176
|
+
if (a.toTarget) {
|
|
177
|
+
const r = await resolveTarget(lab, a.toTarget);
|
|
178
|
+
if ('problem' in r)
|
|
179
|
+
return diverge('drop target', `${a.toTarget.role} ${JSON.stringify(a.toTarget.name)}`, r.problem);
|
|
180
|
+
toRef = r.ref;
|
|
181
|
+
}
|
|
182
|
+
if (a.consequential && !o.allowConsequential) {
|
|
183
|
+
return block(a.index, description, `its target's name suggests a consequential action (${JSON.stringify(a.target?.name ?? a.toTarget?.name)}); pass --allow-consequential to run it`);
|
|
184
|
+
}
|
|
185
|
+
if (a.truncated)
|
|
186
|
+
return block(a.index, description, 'the typed value was longer than the log keeps');
|
|
187
|
+
const typed = {};
|
|
188
|
+
if (a.secret) {
|
|
189
|
+
const name = o.secrets?.[a.index];
|
|
190
|
+
if (!name)
|
|
191
|
+
return block(a.index, description, `the recorded value is masked; supply it with --secret ${a.index}=<ENV_NAME>`);
|
|
192
|
+
const value = env[name];
|
|
193
|
+
if (!value)
|
|
194
|
+
return block(a.index, description, `environment variable ${name} (for --secret ${a.index}) is not set`);
|
|
195
|
+
if (a.action === 'fill')
|
|
196
|
+
typed.value = value;
|
|
197
|
+
else
|
|
198
|
+
typed.key = value;
|
|
199
|
+
}
|
|
200
|
+
const args = a.args;
|
|
201
|
+
const request = {
|
|
202
|
+
action: a.action, ...(ref ? { ref } : {}), ...(toRef ? { toRef } : {}),
|
|
203
|
+
...(args.value !== undefined ? { value: args.value } : {}), ...(args.key !== undefined ? { key: args.key } : {}), ...typed,
|
|
204
|
+
...(args.values ? { values: args.values } : {}), ...(args.direction ? { direction: args.direction } : {}),
|
|
205
|
+
...(args.amount !== undefined ? { amount: args.amount } : {}), ...(args.dx !== undefined ? { dx: args.dx } : {}), ...(args.dy !== undefined ? { dy: args.dy } : {}),
|
|
206
|
+
...(args.files ? { files: args.files } : {}), ...(args.tab !== undefined ? { tab: args.tab } : {}), ...(args.path !== undefined ? { path: args.path } : {}),
|
|
207
|
+
};
|
|
208
|
+
const r = await lab.act(request);
|
|
209
|
+
const after = now();
|
|
210
|
+
const code = r.error?.code;
|
|
211
|
+
if (a.index === failStep) {
|
|
212
|
+
const want = failure.code;
|
|
213
|
+
if (r.outcome === 'error' && code === want) {
|
|
214
|
+
step({ index: a.index, description, status: 'reproduced', detail: `failed with ${code}, as recorded` });
|
|
215
|
+
return finish('reproduced', `step ${a.index} failed with ${code} again`);
|
|
216
|
+
}
|
|
217
|
+
const got = r.outcome === 'error' ? `failed with ${code}` : 'succeeded';
|
|
218
|
+
step({ index: a.index, description, status: 'not-reproduced', detail: `expected ${want}, ${got}` });
|
|
219
|
+
return finish('not-reproduced', `step ${a.index} ${got}; the recording failed with ${want}`);
|
|
220
|
+
}
|
|
221
|
+
if (r.outcome !== a.outcome || code !== a.error?.code) {
|
|
222
|
+
return diverge('outcome', a.outcome === 'error' ? `error ${a.error?.code}` : 'success', r.outcome === 'error' ? `error ${code}` : 'success');
|
|
223
|
+
}
|
|
224
|
+
if (a.routeAfter !== undefined && after.route !== a.routeAfter)
|
|
225
|
+
return diverge('route after the step', a.routeAfter, after.route ?? 'none');
|
|
226
|
+
if ((a.dialogAfter ?? null) !== (after.dialog ?? null))
|
|
227
|
+
return diverge('dialog after the step', a.dialogAfter ? JSON.stringify(a.dialogAfter) : 'none', after.dialog ? JSON.stringify(after.dialog) : 'none');
|
|
228
|
+
step({ index: a.index, description, status: a.outcome === 'error' ? 'error-as-recorded' : 'ok', ...(a.outcome === 'error' ? { detail: `failed with ${code}, as recorded` } : {}) });
|
|
229
|
+
}
|
|
230
|
+
// Every step that was asked for has run without divergence.
|
|
231
|
+
const stoppedEarly = o.until !== undefined && actions.some((a) => a.index > o.until && (failStep === undefined || a.index <= failStep));
|
|
232
|
+
if (stoppedEarly)
|
|
233
|
+
return finish('not-reproduced', `stopped after step ${o.until} (--until) before the failure could be checked`);
|
|
234
|
+
return await verify(lab, bundle, failure, result, finish);
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
await lab.close('replay finished').catch(() => undefined);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** After the recorded actions: decide from the kind of failure. */
|
|
241
|
+
async function verify(lab, bundle, failure, result, finish) {
|
|
242
|
+
const scenario = bundle.scenario;
|
|
243
|
+
switch (failure.kind) {
|
|
244
|
+
case 'action-error':
|
|
245
|
+
return finish('blocked', `step ${failure.step} is not in the bundle's action log`);
|
|
246
|
+
case 'findings': {
|
|
247
|
+
let found;
|
|
248
|
+
if (scenario) {
|
|
249
|
+
try {
|
|
250
|
+
found = (await lab.scan({ scenarios: [scenario.name], devices: [scenario.device] })).findings.map((f) => f.fingerprint);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
return finish('blocked', `scenario "${scenario.name}" could not be run: ${LabError.from(err).message}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else
|
|
257
|
+
found = lab.inspect().findings.map((f) => f.fingerprint);
|
|
258
|
+
const missing = failure.fingerprints.filter((f) => !found.includes(f));
|
|
259
|
+
result.findings = { expected: failure.fingerprints, missing };
|
|
260
|
+
return missing.length
|
|
261
|
+
? finish('not-reproduced', `${failure.fingerprints.length - missing.length} of ${failure.fingerprints.length} recorded finding(s) appeared again`)
|
|
262
|
+
: finish('reproduced', `all ${failure.fingerprints.length} recorded finding(s) appeared again`);
|
|
263
|
+
}
|
|
264
|
+
case 'scenario-error': {
|
|
265
|
+
let run;
|
|
266
|
+
try {
|
|
267
|
+
run = (await lab.scan({ scenarios: [failure.scenario], devices: [failure.device] })).runs.find((r) => r.scenario === failure.scenario && r.device === failure.device);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
return finish('blocked', `scenario "${failure.scenario}" could not be run: ${LabError.from(err).message}`);
|
|
271
|
+
}
|
|
272
|
+
const at = run?.failedAt;
|
|
273
|
+
const code = run?.error?.code;
|
|
274
|
+
const same = run?.status === 'failed' && at === failure.failedAt && (failure.code === undefined || code === failure.code);
|
|
275
|
+
return same
|
|
276
|
+
? finish('reproduced', `scenario "${failure.scenario}" on ${failure.device} failed at ${at}${code ? ` with ${code}` : ''} again`)
|
|
277
|
+
: finish('not-reproduced', `scenario "${failure.scenario}" on ${failure.device}: ${run?.status === 'failed' ? `failed at ${at}${code ? ` with ${code}` : ''}` : 'passed'}; the recording failed at ${failure.failedAt}${failure.code ? ` with ${failure.code}` : ''}`);
|
|
278
|
+
}
|
|
279
|
+
case 'expectation':
|
|
280
|
+
case 'manual': {
|
|
281
|
+
// The expectation itself is not in the bundle, so the check is that the recorded end state was reached.
|
|
282
|
+
const end = bundle.observations.at(-1);
|
|
283
|
+
const cur = { route: routePath(lab.lastObservation?.route), dialog: lab.lastObservation?.dialog };
|
|
284
|
+
if (!end)
|
|
285
|
+
return finish('reproduced', 'every recorded step ran; the bundle has no end state to compare');
|
|
286
|
+
if (cur.route !== end.route || (cur.dialog ?? null) !== (end.dialog ?? null)) {
|
|
287
|
+
return finish('not-reproduced', `the recorded end state (route ${end.route}${end.dialog ? `, dialog ${JSON.stringify(end.dialog)}` : ''}) was not reached: route ${cur.route}${cur.dialog ? `, dialog ${JSON.stringify(cur.dialog)}` : ''}`);
|
|
288
|
+
}
|
|
289
|
+
return finish('reproduced', `every recorded step ran and the recorded end state (route ${end.route}) was reached`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Text mode: one line per step and the outcome. */
|
|
294
|
+
export function formatReplay(r) {
|
|
295
|
+
const mark = { ok: 'ok', 'error-as-recorded': 'ok (error, as recorded)', reproduced: 'REPRODUCED', 'not-reproduced': 'NOT REPRODUCED', diverged: 'DIVERGED', blocked: 'BLOCKED' };
|
|
296
|
+
return [
|
|
297
|
+
`replay of ${r.bundle.id}: ${r.bundle.reason}`,
|
|
298
|
+
...r.warnings.map((w) => `warning: ${w}`),
|
|
299
|
+
...r.steps.map((s) => ` step ${s.index} ${mark[s.status].padEnd(8)} ${s.description}${s.detail ? ` (${s.detail})` : ''}`),
|
|
300
|
+
`${r.outcome}: ${r.reason}`,
|
|
301
|
+
].join('\n');
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=replay.js.map
|