@farmslot/recipe-runner 0.29.0 → 0.30.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/CHANGELOG.md +9 -0
- package/dist/adapters/ui.d.ts +2 -0
- package/dist/adapters/ui.d.ts.map +1 -1
- package/dist/adapters/ui.js +79 -37
- package/dist/adapters/ui.js.map +1 -1
- package/dist/cli/support.d.ts.map +1 -1
- package/dist/cli/support.js +5 -1
- package/dist/cli/support.js.map +1 -1
- package/dist/core/execution.d.ts +3 -0
- package/dist/core/execution.d.ts.map +1 -1
- package/dist/core/execution.js +60 -3
- package/dist/core/execution.js.map +1 -1
- package/dist/core/graph.d.ts +1 -0
- package/dist/core/graph.d.ts.map +1 -1
- package/dist/core/graph.js +2 -2
- package/dist/core/graph.js.map +1 -1
- package/dist/core/hud.d.ts.map +1 -1
- package/dist/core/hud.js +3 -3
- package/dist/core/hud.js.map +1 -1
- package/dist/core/invocation.d.ts +4 -1
- package/dist/core/invocation.d.ts.map +1 -1
- package/dist/core/invocation.js +3 -1
- package/dist/core/invocation.js.map +1 -1
- package/dist/core/recording-limits.d.ts +4 -0
- package/dist/core/recording-limits.d.ts.map +1 -0
- package/dist/core/recording-limits.js +74 -0
- package/dist/core/recording-limits.js.map +1 -0
- package/dist/core/recording-segments.d.ts +4 -0
- package/dist/core/recording-segments.d.ts.map +1 -0
- package/dist/core/recording-segments.js +101 -0
- package/dist/core/recording-segments.js.map +1 -0
- package/dist/core/runner.d.ts.map +1 -1
- package/dist/core/runner.js +335 -108
- package/dist/core/runner.js.map +1 -1
- package/dist/core/types.d.ts +37 -22
- package/dist/core/types.d.ts.map +1 -1
- package/dist/evidence/assertions.d.ts +50 -0
- package/dist/evidence/assertions.d.ts.map +1 -0
- package/dist/evidence/assertions.js +156 -0
- package/dist/evidence/assertions.js.map +1 -0
- package/dist/evidence/claims.d.ts +15 -0
- package/dist/evidence/claims.d.ts.map +1 -0
- package/dist/evidence/claims.js +201 -0
- package/dist/evidence/claims.js.map +1 -0
- package/dist/evidence/index.d.ts +4 -0
- package/dist/evidence/index.d.ts.map +1 -0
- package/dist/evidence/index.js +3 -0
- package/dist/evidence/index.js.map +1 -0
- package/dist/evidence/moments.d.ts +8 -0
- package/dist/evidence/moments.d.ts.map +1 -0
- package/dist/evidence/moments.js +26 -0
- package/dist/evidence/moments.js.map +1 -0
- package/dist/evidence/package.d.ts +4 -0
- package/dist/evidence/package.d.ts.map +1 -0
- package/dist/evidence/package.js +140 -0
- package/dist/evidence/package.js.map +1 -0
- package/dist/evidence/plan.d.ts +9 -0
- package/dist/evidence/plan.d.ts.map +1 -0
- package/dist/evidence/plan.js +87 -0
- package/dist/evidence/plan.js.map +1 -0
- package/dist/evidence/read-package.d.ts +14 -0
- package/dist/evidence/read-package.d.ts.map +1 -0
- package/dist/evidence/read-package.js +153 -0
- package/dist/evidence/read-package.js.map +1 -0
- package/dist/evidence/stills.d.ts +5 -0
- package/dist/evidence/stills.d.ts.map +1 -0
- package/dist/evidence/stills.js +58 -0
- package/dist/evidence/stills.js.map +1 -0
- package/dist/file-digest.d.ts +3 -0
- package/dist/file-digest.d.ts.map +1 -0
- package/dist/file-digest.js +4 -0
- package/dist/file-digest.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/recording/android-mirror.d.ts.map +1 -1
- package/dist/recording/android-mirror.js +17 -8
- package/dist/recording/android-mirror.js.map +1 -1
- package/dist/recording/capture-helper-timing.d.ts +2 -2
- package/dist/recording/capture-helper-timing.d.ts.map +1 -1
- package/dist/recording/capture-helper.d.ts.map +1 -1
- package/dist/recording/capture-helper.js +24 -12
- package/dist/recording/capture-helper.js.map +1 -1
- package/dist/recording/cdp-video-recorder.d.ts +2 -0
- package/dist/recording/cdp-video-recorder.d.ts.map +1 -1
- package/dist/recording/cdp-video-recorder.js +160 -50
- package/dist/recording/cdp-video-recorder.js.map +1 -1
- package/dist/recording/timeline.d.ts +2 -2
- package/dist/recording/timeline.d.ts.map +1 -1
- package/dist/runtime/cdp.d.ts +1 -0
- package/dist/runtime/cdp.d.ts.map +1 -1
- package/dist/runtime/cdp.js +71 -16
- package/dist/runtime/cdp.js.map +1 -1
- package/package.json +8 -3
package/dist/core/runner.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
|
-
import { digestRecipeDocument, getRecipeActionManifestActionNames,
|
|
5
|
+
import { DEFAULT_RECORDED_EVIDENCE_POLICY, digestRecipeDocument, getRecipeActionManifestActionNames, hasUnresolvedRecipeFailure, validateRecipeRecordingTarget, } from '@farmslot/protocol';
|
|
6
|
+
import { proofDocument } from '../evidence/assertions.js';
|
|
7
|
+
import { deriveRecipeClaims } from '../evidence/claims.js';
|
|
8
|
+
import { validateRecordedEvidencePackage } from '../evidence/package.js';
|
|
9
|
+
import { planRecipeEvidence } from '../evidence/plan.js';
|
|
10
|
+
import { materializeEvidenceStill } from '../evidence/stills.js';
|
|
5
11
|
import { JsonArtifactWriter, JsonSummaryWriter, JsonTraceWriter, summarizeTraceCounts, } from '../node/writers.js';
|
|
6
12
|
import { createCaptureHelperVideoRecorder, errorMessage, manifestTarget, } from '../recording/capture-helper.js';
|
|
7
13
|
import { CAPTURE_INTERRUPTED, captureInterruptedTraceEntry, runCaptureInterruption, } from '../recording/capture-helper-interruption.js';
|
|
@@ -17,8 +23,10 @@ import { isRecord, normalizeRelativePath, readJsonFile } from './json.js';
|
|
|
17
23
|
import { loadRecipeLibraries, logRecipeLibraryShadows, logRecipeLibrarySources, } from './library.js';
|
|
18
24
|
import { resolveRecipeParams } from './parameters.js';
|
|
19
25
|
import { defaultObserverRefsFromManifest } from './passive-observations.js';
|
|
20
|
-
import { copyFileWithinRoots, statFileWithinRoot } from './path.js';
|
|
26
|
+
import { copyFileWithinRoots, digestFileWithinRoot, statFileWithinRoot, writeFileWithinRoot, } from './path.js';
|
|
21
27
|
import { cleanupAbortedRunVideoRecording, removePartialRunVideoOutput, } from './recording-cleanup.js';
|
|
28
|
+
import { boundRecording } from './recording-limits.js';
|
|
29
|
+
import { startSegmentedRecording } from './recording-segments.js';
|
|
22
30
|
import { RecipeResolutionError } from './resolution-error.js';
|
|
23
31
|
import { buildRecipeExecutionPlan, enforceRecipeExecutionPlan, recipeSourceForRequest, verifyExecutableSource, } from './trust.js';
|
|
24
32
|
import { RECIPE_TRUST_ENV } from './trust-input.js';
|
|
@@ -182,6 +190,12 @@ class DefaultRecipeRunner {
|
|
|
182
190
|
params,
|
|
183
191
|
recipes: dependencyResolution.recipes,
|
|
184
192
|
});
|
|
193
|
+
const evidencePlan = planRecipeEvidence(proofDocument(recipe), new Map([...dependencyResolution.recipes].map(([ref, entry]) => [
|
|
194
|
+
ref,
|
|
195
|
+
proofDocument(entry.document),
|
|
196
|
+
])), request.claims);
|
|
197
|
+
const needsUi = [...evidencePlan.nodes.values()].some((node) => node.action.startsWith('ui.'));
|
|
198
|
+
const videoOptions = normalizeVideoRecordingOptions(request.recordVideo, needsUi, request.recordingReason);
|
|
185
199
|
const executionPlan = buildRecipeExecutionPlan({
|
|
186
200
|
recipe,
|
|
187
201
|
params,
|
|
@@ -194,7 +208,7 @@ class DefaultRecipeRunner {
|
|
|
194
208
|
artifactsDir,
|
|
195
209
|
env,
|
|
196
210
|
hud: this.#hud,
|
|
197
|
-
recordVideo:
|
|
211
|
+
recordVideo: videoOptions.mode === 'off' ? false : videoOptions,
|
|
198
212
|
});
|
|
199
213
|
return {
|
|
200
214
|
projectRoot,
|
|
@@ -210,12 +224,16 @@ class DefaultRecipeRunner {
|
|
|
210
224
|
recipes: dependencyResolution.recipes,
|
|
211
225
|
dependencyResolution,
|
|
212
226
|
executionPlan,
|
|
227
|
+
evidencePlan,
|
|
228
|
+
recordingRequired: needsUi,
|
|
229
|
+
videoOptions,
|
|
213
230
|
};
|
|
214
231
|
}
|
|
215
232
|
async run(request) {
|
|
216
|
-
const { projectRoot, artifactsDir, recipe, rootRef, params, env, libraryResolution, graph, recipes, dependencyResolution, executionPlan, } = await this.#resolveRequest(request);
|
|
233
|
+
const { projectRoot, artifactsDir, recipe, rootRef, params, env, libraryResolution, graph, recipes, dependencyResolution, executionPlan, evidencePlan, recordingRequired, videoOptions, } = await this.#resolveRequest(request);
|
|
217
234
|
enforceRecipeExecutionPlan(executionPlan, request, this.#blockedCapabilities);
|
|
218
235
|
const startedAt = new Date();
|
|
236
|
+
const recipeRunId = randomUUID();
|
|
219
237
|
await mkdir(artifactsDir, { recursive: true });
|
|
220
238
|
const runFileOffsets = await collectRunFileOffsets({
|
|
221
239
|
graph,
|
|
@@ -227,18 +245,20 @@ class DefaultRecipeRunner {
|
|
|
227
245
|
const summaryWriter = new JsonSummaryWriter(artifactsDir);
|
|
228
246
|
let outputs = new Map();
|
|
229
247
|
const recipePath = await artifactWriter.copyRecipe(recipe);
|
|
230
|
-
const invocation = createRecipeInvocation(recipe, params, startedAt.toISOString());
|
|
248
|
+
const invocation = createRecipeInvocation(recipe, params, startedAt.toISOString(), request.claims, recipeRunId);
|
|
231
249
|
await artifactWriter.writeInvocation(invocation);
|
|
232
250
|
for (const dependency of dependencyResolution.recipes.values()) {
|
|
233
251
|
await artifactWriter.copyResolvedRecipe(dependency.provenance.digest, dependency.document);
|
|
234
252
|
}
|
|
235
253
|
await artifactWriter.writeRecipeResolution(dependencyResolution.document);
|
|
236
254
|
let status = 'unknown';
|
|
237
|
-
const
|
|
255
|
+
const segments = [];
|
|
256
|
+
const timelines = new Map();
|
|
238
257
|
const videoRecorder = videoOptions.mode !== 'off' ? this.#videoRecorder() : undefined;
|
|
239
258
|
let runRecording;
|
|
240
259
|
let captureInterruption;
|
|
241
|
-
let
|
|
260
|
+
let completionHudFailure;
|
|
261
|
+
const videoTimelines = [];
|
|
242
262
|
let canExecute = true;
|
|
243
263
|
if (videoRecorder) {
|
|
244
264
|
try {
|
|
@@ -281,6 +301,7 @@ class DefaultRecipeRunner {
|
|
|
281
301
|
actionManifest: this.#actionManifest,
|
|
282
302
|
adapters: this.#adapters,
|
|
283
303
|
traceWriter,
|
|
304
|
+
claimProjections: evidencePlan.nodes,
|
|
284
305
|
logger: this.#logger,
|
|
285
306
|
defaultObserverRefs: this.#defaultObserverRefs,
|
|
286
307
|
createContext: (nodeId, activeRecipe, activeOutputs, getOutput) => this.#createExecutionContext({
|
|
@@ -302,7 +323,7 @@ class DefaultRecipeRunner {
|
|
|
302
323
|
publishHudProgress: (hudStatus, event) => this.#publishHudProgressOrRecord(traceWriter, hudStatus, event),
|
|
303
324
|
...(request.stopAfterNode !== undefined ? { stopAfterNode: request.stopAfterNode } : {}),
|
|
304
325
|
});
|
|
305
|
-
status = execution.status;
|
|
326
|
+
status = hasUnresolvedRecipeFailure(traceWriter.list()) ? 'fail' : execution.status;
|
|
306
327
|
outputs = execution.outputs;
|
|
307
328
|
}
|
|
308
329
|
artifactWriter.register({
|
|
@@ -317,63 +338,6 @@ class DefaultRecipeRunner {
|
|
|
317
338
|
label: 'Execution trace',
|
|
318
339
|
category: 'system',
|
|
319
340
|
});
|
|
320
|
-
if (runRecording) {
|
|
321
|
-
const recordingToStop = runRecording;
|
|
322
|
-
runRecording = undefined;
|
|
323
|
-
try {
|
|
324
|
-
const { entry: videoArtifact, interruption, timing, } = await this.#stopRunVideoRecording(recordingToStop, traceWriter, artifactWriter, startedAt);
|
|
325
|
-
artifactWriter.register(videoArtifact);
|
|
326
|
-
if (timing)
|
|
327
|
-
videoTimeline = { entry: videoArtifact, timing };
|
|
328
|
-
if (interruption) {
|
|
329
|
-
// The partial video stays registered as evidence; the run still fails, typed.
|
|
330
|
-
captureInterruption = interruption;
|
|
331
|
-
this.#logger.error(`record.video interrupted: ${interruption.message}`);
|
|
332
|
-
status = 'fail';
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
catch (error) {
|
|
336
|
-
const message = errorMessage(error);
|
|
337
|
-
await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
|
|
338
|
-
await rm(recordingToStop.stagingRoot, { recursive: true, force: true });
|
|
339
|
-
traceWriter.record({
|
|
340
|
-
nodeId: 'recipe-run:video',
|
|
341
|
-
action: 'record.video',
|
|
342
|
-
startedAt: startedAt.toISOString(),
|
|
343
|
-
endedAt: new Date().toISOString(),
|
|
344
|
-
durationMs: Date.now() - startedAt.getTime(),
|
|
345
|
-
ok: false,
|
|
346
|
-
cause_class: recipeFailureCause(error, 'harness'),
|
|
347
|
-
error: message,
|
|
348
|
-
});
|
|
349
|
-
this.#logger.error(`record.video failed: ${message}`);
|
|
350
|
-
status = 'fail';
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
if (request.finalizeRecording) {
|
|
354
|
-
const recordingStartedAt = new Date();
|
|
355
|
-
try {
|
|
356
|
-
const interruption = await request.finalizeRecording();
|
|
357
|
-
if (interruption) {
|
|
358
|
-
captureInterruption = interruption;
|
|
359
|
-
traceWriter.record(captureInterruptedTraceEntry(interruption, startedAt));
|
|
360
|
-
status = 'fail';
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
catch (error) {
|
|
364
|
-
traceWriter.record({
|
|
365
|
-
nodeId: 'recipe-run:video',
|
|
366
|
-
action: 'record.video',
|
|
367
|
-
startedAt: recordingStartedAt.toISOString(),
|
|
368
|
-
endedAt: new Date().toISOString(),
|
|
369
|
-
durationMs: Date.now() - recordingStartedAt.getTime(),
|
|
370
|
-
ok: false,
|
|
371
|
-
cause_class: recipeFailureCause(error, 'harness'),
|
|
372
|
-
error: errorMessage(error),
|
|
373
|
-
});
|
|
374
|
-
status = 'fail';
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
341
|
if (status === 'pass' || status === 'fail') {
|
|
378
342
|
const runHudStartedAt = new Date();
|
|
379
343
|
try {
|
|
@@ -389,7 +353,7 @@ class DefaultRecipeRunner {
|
|
|
389
353
|
const message = error instanceof Error ? error.message : String(error);
|
|
390
354
|
const fields = recipeFailureTraceFields(error);
|
|
391
355
|
const captureEndpointLost = captureInterruption && fields.error_code === RUNTIME_CONNECTION_CLOSED;
|
|
392
|
-
|
|
356
|
+
completionHudFailure = {
|
|
393
357
|
nodeId: 'recipe-complete:hud',
|
|
394
358
|
action: 'app.hud',
|
|
395
359
|
startedAt: runHudStartedAt.toISOString(),
|
|
@@ -405,13 +369,66 @@ class DefaultRecipeRunner {
|
|
|
405
369
|
error_details: { runtime_error_code: fields.error_code },
|
|
406
370
|
}
|
|
407
371
|
: {}),
|
|
408
|
-
}
|
|
372
|
+
};
|
|
373
|
+
traceWriter.record(completionHudFailure);
|
|
409
374
|
this.#logger.error(`app.hud complete update failed: ${message}`);
|
|
410
375
|
status = 'fail';
|
|
411
376
|
}
|
|
412
377
|
}
|
|
413
|
-
if (
|
|
414
|
-
const
|
|
378
|
+
if (runRecording) {
|
|
379
|
+
const recordingToStop = runRecording;
|
|
380
|
+
runRecording = undefined;
|
|
381
|
+
try {
|
|
382
|
+
const { videos, interruption, releaseFailure } = await this.#stopRunVideoRecording(recordingToStop, traceWriter, artifactWriter, startedAt);
|
|
383
|
+
for (const video of videos)
|
|
384
|
+
if (video.timing)
|
|
385
|
+
videoTimelines.push({ entry: video.entry, timing: video.timing });
|
|
386
|
+
if (releaseFailure) {
|
|
387
|
+
this.#logger.error(`Recording lease cleanup failed: ${releaseFailure}`);
|
|
388
|
+
status = 'fail';
|
|
389
|
+
}
|
|
390
|
+
if (interruption) {
|
|
391
|
+
// The partial video stays registered as evidence; the run still fails, typed.
|
|
392
|
+
captureInterruption = interruption;
|
|
393
|
+
if (completionHudFailure?.error_code === RUNTIME_CONNECTION_CLOSED) {
|
|
394
|
+
completionHudFailure.error_code = CAPTURE_INTERRUPTED;
|
|
395
|
+
completionHudFailure.cause_class = 'environment';
|
|
396
|
+
completionHudFailure.error_details = {
|
|
397
|
+
runtime_error_code: RUNTIME_CONNECTION_CLOSED,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
this.#logger.error(`record.video interrupted: ${interruption.message}`);
|
|
401
|
+
status = 'fail';
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
const message = errorMessage(error);
|
|
406
|
+
await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
|
|
407
|
+
await rm(recordingToStop.stagingRoot, { recursive: true, force: true });
|
|
408
|
+
traceWriter.record({
|
|
409
|
+
nodeId: 'recipe-run:video',
|
|
410
|
+
action: 'record.video',
|
|
411
|
+
startedAt: startedAt.toISOString(),
|
|
412
|
+
endedAt: new Date().toISOString(),
|
|
413
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
414
|
+
ok: false,
|
|
415
|
+
cause_class: recipeFailureCause(error, 'harness'),
|
|
416
|
+
error: message,
|
|
417
|
+
});
|
|
418
|
+
this.#logger.error(`record.video failed: ${message}`);
|
|
419
|
+
status = 'fail';
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const entry of artifactWriter
|
|
423
|
+
.list()
|
|
424
|
+
.filter((artifact) => artifact.type === 'video' && artifact.sha256)) {
|
|
425
|
+
for (const still of artifactWriter.list()) {
|
|
426
|
+
const source = still.metadata?.recordedEvidence;
|
|
427
|
+
if (isRecord(source) && source.videoPath === entry.path)
|
|
428
|
+
source.videoDigest = `sha256:${entry.sha256}`;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
for (const { entry, timing } of videoTimelines) {
|
|
415
432
|
try {
|
|
416
433
|
entry.timelinePath = await writeRecordingTimeline(artifactsDir, entry.path, timing, traceWriter.list());
|
|
417
434
|
artifactWriter.register(entry);
|
|
@@ -439,7 +456,101 @@ class DefaultRecipeRunner {
|
|
|
439
456
|
const endedAt = new Date();
|
|
440
457
|
const tracePath = await traceWriter.write();
|
|
441
458
|
const trace = traceWriter.list();
|
|
459
|
+
for (const artifact of artifactWriter.list()) {
|
|
460
|
+
if (artifact.type !== 'video')
|
|
461
|
+
continue;
|
|
462
|
+
const videoDigest = artifact.sha256
|
|
463
|
+
? `sha256:${artifact.sha256}`
|
|
464
|
+
: await digestFileWithinRoot(artifactsDir, artifact.path);
|
|
465
|
+
segments.push({
|
|
466
|
+
videoPath: artifact.path,
|
|
467
|
+
videoDigest,
|
|
468
|
+
...(artifact.timelinePath ? { timelinePath: artifact.timelinePath } : {}),
|
|
469
|
+
...(artifact.metadata?.gapBefore === true ? { gapBefore: true } : {}),
|
|
470
|
+
});
|
|
471
|
+
if (artifact.timelinePath)
|
|
472
|
+
timelines.set(videoDigest, (await readJsonFile(path.join(artifactsDir, artifact.timelinePath))));
|
|
473
|
+
}
|
|
474
|
+
const pendingClaims = deriveRecipeClaims({
|
|
475
|
+
recipeRunId,
|
|
476
|
+
...evidencePlan,
|
|
477
|
+
trace,
|
|
478
|
+
artifacts: artifactWriter.list(),
|
|
479
|
+
segments,
|
|
480
|
+
timelines,
|
|
481
|
+
});
|
|
482
|
+
const derivativeIssues = [];
|
|
483
|
+
const requiredStills = [...new Set(pendingClaims.flatMap((claim) => claim.assertionIndices))];
|
|
484
|
+
await mkdir(path.join(artifactsDir, 'stills'), { recursive: true });
|
|
485
|
+
for (const traceIndex of requiredStills) {
|
|
486
|
+
const nodeId = trace[traceIndex]?.nodeId;
|
|
487
|
+
if (!trace[traceIndex]?.action.startsWith('ui.') ||
|
|
488
|
+
artifactWriter
|
|
489
|
+
.list()
|
|
490
|
+
.some((artifact) => artifact.type === 'screenshot' &&
|
|
491
|
+
artifact.nodeId === nodeId &&
|
|
492
|
+
isRecord(artifact.metadata?.recordedEvidence)))
|
|
493
|
+
continue;
|
|
494
|
+
const claim = pendingClaims.find((entry) => entry.assertionIndices.includes(traceIndex));
|
|
495
|
+
const moment = claim?.moments.find((entry) => entry.traceIndex === traceIndex);
|
|
496
|
+
const segment = moment
|
|
497
|
+
? segments.find((entry) => entry.videoDigest === moment.videoDigest)
|
|
498
|
+
: undefined;
|
|
499
|
+
const timeline = segment ? timelines.get(segment.videoDigest) : undefined;
|
|
500
|
+
if (!segment || !timeline)
|
|
501
|
+
continue;
|
|
502
|
+
try {
|
|
503
|
+
const still = await materializeEvidenceStill(artifactsDir, segment, timeline, traceIndex, trace);
|
|
504
|
+
if (still)
|
|
505
|
+
artifactWriter.register(still);
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
// The source clip remains usable; retain the failed derivative as package diagnostics.
|
|
509
|
+
artifactWriter.register({
|
|
510
|
+
path: 'recording-derivatives.json',
|
|
511
|
+
type: 'json',
|
|
512
|
+
category: 'diagnostic',
|
|
513
|
+
label: 'Evidence derivative limitations',
|
|
514
|
+
});
|
|
515
|
+
derivativeIssues.push({ nodeId, error: errorMessage(error) });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (derivativeIssues.length)
|
|
519
|
+
await writeFileWithinRoot(artifactsDir, 'recording-derivatives.json', JSON.stringify(derivativeIssues));
|
|
520
|
+
const claims = {
|
|
521
|
+
schemaVersion: 2,
|
|
522
|
+
recipeRunId,
|
|
523
|
+
criteria: deriveRecipeClaims({
|
|
524
|
+
recipeRunId,
|
|
525
|
+
...evidencePlan,
|
|
526
|
+
trace,
|
|
527
|
+
artifacts: artifactWriter.list(),
|
|
528
|
+
segments,
|
|
529
|
+
timelines,
|
|
530
|
+
}),
|
|
531
|
+
recording: {
|
|
532
|
+
status: videoOptions.mode === 'off'
|
|
533
|
+
? recordingRequired
|
|
534
|
+
? 'explicit-off'
|
|
535
|
+
: 'not-required'
|
|
536
|
+
: captureInterruption
|
|
537
|
+
? 'incomplete'
|
|
538
|
+
: segments.length
|
|
539
|
+
? 'complete'
|
|
540
|
+
: 'failed',
|
|
541
|
+
...(videoOptions.reason ? { reason: videoOptions.reason } : {}),
|
|
542
|
+
segments,
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
const claimsPath = await writeFileWithinRoot(artifactsDir, 'acceptance-status.json', `${JSON.stringify(claims, null, 2)}\n`);
|
|
546
|
+
artifactWriter.register({
|
|
547
|
+
path: 'acceptance-status.json',
|
|
548
|
+
type: 'json',
|
|
549
|
+
category: 'system',
|
|
550
|
+
label: 'Derived claim evidence',
|
|
551
|
+
});
|
|
442
552
|
const summary = {
|
|
553
|
+
recipeRunId,
|
|
443
554
|
invocationDigest: digestRecipeDocument(invocation),
|
|
444
555
|
status,
|
|
445
556
|
...summarizeTraceCounts(trace),
|
|
@@ -460,7 +571,14 @@ class DefaultRecipeRunner {
|
|
|
460
571
|
};
|
|
461
572
|
const summaryPath = await summaryWriter.write(summary);
|
|
462
573
|
const artifactManifestPath = await artifactWriter.write(status, this.#runnerProvenance);
|
|
463
|
-
const packageValidation =
|
|
574
|
+
const packageValidation = validateRecordedEvidencePackage({
|
|
575
|
+
invocation,
|
|
576
|
+
recipeRunId,
|
|
577
|
+
claims,
|
|
578
|
+
artifactDigests: Object.fromEntries(segments.map((segment) => [segment.videoPath, segment.videoDigest])),
|
|
579
|
+
timelines: Object.fromEntries(segments
|
|
580
|
+
.filter((segment) => segment.timelinePath)
|
|
581
|
+
.map((segment) => [segment.timelinePath, timelines.get(segment.videoDigest)])),
|
|
464
582
|
recipe,
|
|
465
583
|
resolvedRecipes: Object.fromEntries([...dependencyResolution.recipes.values()].map((entry) => [
|
|
466
584
|
entry.provenance.digest,
|
|
@@ -495,6 +613,8 @@ class DefaultRecipeRunner {
|
|
|
495
613
|
tracePath,
|
|
496
614
|
artifactManifestPath,
|
|
497
615
|
recipePath,
|
|
616
|
+
claimsPath,
|
|
617
|
+
claims,
|
|
498
618
|
...(captureInterruption ? { captureInterruption } : {}),
|
|
499
619
|
};
|
|
500
620
|
}
|
|
@@ -519,7 +639,14 @@ class DefaultRecipeRunner {
|
|
|
519
639
|
await copyFileWithinRoots(dir, 'frame.png', artifactsDir, relative);
|
|
520
640
|
// Keep the provider's source path in its raw sidecar; the action output
|
|
521
641
|
// maps it to the published artifact path without rewriting capture facts.
|
|
522
|
-
return {
|
|
642
|
+
return {
|
|
643
|
+
...evidence,
|
|
644
|
+
sourceOutput: evidence.output,
|
|
645
|
+
output: relative,
|
|
646
|
+
...(evidence.fallbackFrom
|
|
647
|
+
? {}
|
|
648
|
+
: { videoPath: recordedVideoPath(Number(evidence.segmentIndex ?? 0)) }),
|
|
649
|
+
};
|
|
523
650
|
},
|
|
524
651
|
}
|
|
525
652
|
: {}),
|
|
@@ -550,7 +677,7 @@ class DefaultRecipeRunner {
|
|
|
550
677
|
action: 'record.video',
|
|
551
678
|
intent: 'Record the recipe run when motion proof is useful',
|
|
552
679
|
};
|
|
553
|
-
const
|
|
680
|
+
const resolvedTarget = videoOptions.target ??
|
|
554
681
|
(await this.#recording?.targetProvider?.resolveRecordingTarget({
|
|
555
682
|
nodeId: 'recipe-run',
|
|
556
683
|
node,
|
|
@@ -560,16 +687,29 @@ class DefaultRecipeRunner {
|
|
|
560
687
|
env,
|
|
561
688
|
scope: 'run',
|
|
562
689
|
}));
|
|
563
|
-
if (!
|
|
564
|
-
throw new Error('
|
|
690
|
+
if (!resolvedTarget) {
|
|
691
|
+
throw new Error('UI recording requires a target. Provide a project RecordingTargetProvider or disable recording with a reason.');
|
|
565
692
|
}
|
|
566
|
-
const
|
|
693
|
+
const validation = validateRecipeRecordingTarget(resolvedTarget);
|
|
694
|
+
if (validation.status !== 'valid')
|
|
695
|
+
throw new RecipeExecutionError('environment', validation.findings.map((finding) => finding.message).join('; '));
|
|
696
|
+
// Admission and backend start use the same immutable provider-resolved identity.
|
|
697
|
+
const target = Object.freeze({ ...resolvedTarget });
|
|
698
|
+
const relativePath = recordedVideoPath(0);
|
|
567
699
|
const stagingRoot = await mkdtemp(path.join(os.tmpdir(), 'farmslot-recipe-video-'));
|
|
568
700
|
const stagingRelativePath = 'recipe-run.mp4';
|
|
569
701
|
const outputPath = path.join(stagingRoot, stagingRelativePath);
|
|
702
|
+
let admission;
|
|
703
|
+
let releaseFailure;
|
|
570
704
|
let recording;
|
|
571
705
|
try {
|
|
572
|
-
|
|
706
|
+
admission = await this.#recording?.admission?.acquire({
|
|
707
|
+
backend: recorder.name,
|
|
708
|
+
target,
|
|
709
|
+
projectRoot,
|
|
710
|
+
artifactsDir,
|
|
711
|
+
});
|
|
712
|
+
const backend = await startSegmentedRecording(recorder, {
|
|
573
713
|
outputPath,
|
|
574
714
|
target,
|
|
575
715
|
maxFps: videoOptions.maxFps,
|
|
@@ -577,10 +717,42 @@ class DefaultRecipeRunner {
|
|
|
577
717
|
nodeId: 'recipe-run',
|
|
578
718
|
record: 'full_run',
|
|
579
719
|
});
|
|
720
|
+
recording = {
|
|
721
|
+
...backend,
|
|
722
|
+
async stop() {
|
|
723
|
+
let result;
|
|
724
|
+
try {
|
|
725
|
+
result = await backend.stop();
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
try {
|
|
729
|
+
await admission?.release();
|
|
730
|
+
}
|
|
731
|
+
catch (releaseError) {
|
|
732
|
+
throw new AggregateError([error, releaseError], 'Recording stop and admission release failed.');
|
|
733
|
+
}
|
|
734
|
+
throw error;
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
await admission?.release();
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
// Completed originals remain usable; the caller records failed lease cleanup separately.
|
|
741
|
+
releaseFailure = errorMessage(error);
|
|
742
|
+
}
|
|
743
|
+
return result;
|
|
744
|
+
},
|
|
745
|
+
};
|
|
580
746
|
}
|
|
581
747
|
catch (error) {
|
|
582
748
|
await removePartialRunVideoOutput(outputPath, this.#logger);
|
|
583
749
|
await rm(stagingRoot, { recursive: true, force: true });
|
|
750
|
+
try {
|
|
751
|
+
await admission?.release();
|
|
752
|
+
}
|
|
753
|
+
catch (releaseError) {
|
|
754
|
+
throw new AggregateError([error, releaseError], 'Recording start and admission release failed.');
|
|
755
|
+
}
|
|
584
756
|
throw error;
|
|
585
757
|
}
|
|
586
758
|
const entry = {
|
|
@@ -599,7 +771,10 @@ class DefaultRecipeRunner {
|
|
|
599
771
|
...(videoOptions.maxFps != null ? { maxFps: videoOptions.maxFps } : {}),
|
|
600
772
|
};
|
|
601
773
|
return {
|
|
602
|
-
|
|
774
|
+
get releaseFailure() {
|
|
775
|
+
return releaseFailure;
|
|
776
|
+
},
|
|
777
|
+
recording: boundRecording(recording, outputPath, videoOptions),
|
|
603
778
|
entry,
|
|
604
779
|
outputPath,
|
|
605
780
|
stagingRoot,
|
|
@@ -609,37 +784,65 @@ class DefaultRecipeRunner {
|
|
|
609
784
|
}
|
|
610
785
|
async #stopRunVideoRecording(runRecording, traceWriter, artifactWriter, runStartedAt) {
|
|
611
786
|
const result = await runRecording.recording.stop();
|
|
787
|
+
const releaseFailure = runRecording.releaseFailure;
|
|
788
|
+
if (releaseFailure)
|
|
789
|
+
traceWriter.record({
|
|
790
|
+
nodeId: 'recipe-run:video',
|
|
791
|
+
action: 'record.video',
|
|
792
|
+
startedAt: runStartedAt.toISOString(),
|
|
793
|
+
endedAt: new Date().toISOString(),
|
|
794
|
+
durationMs: Date.now() - runStartedAt.getTime(),
|
|
795
|
+
ok: false,
|
|
796
|
+
cause_class: 'environment',
|
|
797
|
+
error_code: 'RECORDING_ADMISSION_RELEASE_FAILED',
|
|
798
|
+
error: releaseFailure,
|
|
799
|
+
});
|
|
800
|
+
if (!result.segments.length)
|
|
801
|
+
throw new Error('Recorder returned no original media segments.');
|
|
612
802
|
const interruption = result.interruption
|
|
613
|
-
? runCaptureInterruption(result.interruption,
|
|
803
|
+
? runCaptureInterruption(result.interruption, recordedVideoPath(result.segments.length - 1))
|
|
614
804
|
: undefined;
|
|
615
|
-
// Record the failure before the timeline, which is bound to the final trace.
|
|
616
805
|
if (interruption)
|
|
617
806
|
traceWriter.record(captureInterruptedTraceEntry(interruption, runStartedAt));
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
807
|
+
const videos = [];
|
|
808
|
+
for (const [index, segment] of result.segments.entries()) {
|
|
809
|
+
const source = normalizeRelativePath(segment.path);
|
|
810
|
+
await assertVideoOutputReady(path.join(runRecording.stagingRoot, source));
|
|
811
|
+
const relativePath = recordedVideoPath(index);
|
|
812
|
+
await copyFileWithinRoots(runRecording.stagingRoot, source, runRecording.artifactsDir, relativePath);
|
|
813
|
+
if (segment.timingEvidencePath && segment.timing) {
|
|
814
|
+
const evidencePath = `${relativePath}.capture.json`;
|
|
815
|
+
await copyFileWithinRoots(runRecording.stagingRoot, segment.timingEvidencePath, runRecording.artifactsDir, evidencePath);
|
|
816
|
+
artifactWriter.register({
|
|
817
|
+
path: evidencePath,
|
|
818
|
+
type: 'json',
|
|
819
|
+
category: 'system',
|
|
820
|
+
label: 'Native recording and snapshot timing',
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const entry = {
|
|
824
|
+
...runRecording.entry,
|
|
825
|
+
path: relativePath,
|
|
826
|
+
sha256: (await digestFileWithinRoot(runRecording.artifactsDir, relativePath)).slice(7),
|
|
827
|
+
...(segment.recorder ? { recorder: segment.recorder } : {}),
|
|
828
|
+
...(segment.gapBefore ? { metadata: { gapBefore: true } } : {}),
|
|
829
|
+
...(result.interruption && index === result.segments.length - 1
|
|
830
|
+
? { interruption: result.interruption }
|
|
831
|
+
: {}),
|
|
832
|
+
...(!segment.timing
|
|
833
|
+
? {
|
|
834
|
+
timelineUnavailableReason: segment.timingUnavailableReason ?? 'Recorder does not provide timeline alignment.',
|
|
835
|
+
}
|
|
836
|
+
: {}),
|
|
837
|
+
};
|
|
838
|
+
artifactWriter.register(entry);
|
|
839
|
+
videos.push({ entry, ...(segment.timing ? { timing: segment.timing } : {}) });
|
|
629
840
|
}
|
|
630
841
|
await rm(runRecording.stagingRoot, { recursive: true, force: true });
|
|
631
|
-
const entry = {
|
|
632
|
-
...runRecording.entry,
|
|
633
|
-
...(result.recorder ? { recorder: result.recorder } : {}),
|
|
634
|
-
...(result.interruption ? { interruption: result.interruption } : {}),
|
|
635
|
-
};
|
|
636
|
-
if (!result.timing)
|
|
637
|
-
entry.timelineUnavailableReason =
|
|
638
|
-
result.timingUnavailableReason ?? 'Recorder does not provide timeline alignment.';
|
|
639
842
|
return {
|
|
640
|
-
|
|
843
|
+
videos,
|
|
641
844
|
...(interruption ? { interruption } : {}),
|
|
642
|
-
...(
|
|
845
|
+
...(releaseFailure ? { releaseFailure } : {}),
|
|
643
846
|
};
|
|
644
847
|
}
|
|
645
848
|
#hudAction() {
|
|
@@ -733,6 +936,9 @@ class DefaultRecipeRunner {
|
|
|
733
936
|
return 'Recipe run';
|
|
734
937
|
}
|
|
735
938
|
}
|
|
939
|
+
function recordedVideoPath(index) {
|
|
940
|
+
return index === 0 ? 'videos/recipe-run.mp4' : `videos/recipe-run-${index}.mp4`;
|
|
941
|
+
}
|
|
736
942
|
function buildRecipeLibrarySummary(resolution) {
|
|
737
943
|
return {
|
|
738
944
|
sources: resolution.sources,
|
|
@@ -760,15 +966,36 @@ function rootRecipeRef(sourceRecipePath, recipes, digest) {
|
|
|
760
966
|
}
|
|
761
967
|
return rootResolutionRef(digest);
|
|
762
968
|
}
|
|
763
|
-
function normalizeVideoRecordingOptions(input) {
|
|
764
|
-
if (!
|
|
969
|
+
function normalizeVideoRecordingOptions(input, needsUi, reason) {
|
|
970
|
+
if (input === undefined && !needsUi)
|
|
765
971
|
return { mode: 'off' };
|
|
766
|
-
if (input ===
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
972
|
+
if (input === false || input === 'off' || (typeof input === 'object' && input.mode === 'off')) {
|
|
973
|
+
const offReason = (typeof input === 'object' ? input.reason : undefined) ?? reason;
|
|
974
|
+
if (needsUi && !offReason?.trim())
|
|
975
|
+
throw new Error('Disabling UI recording requires recordingReason.');
|
|
976
|
+
return { mode: 'off', ...(offReason ? { reason: offReason } : {}) };
|
|
977
|
+
}
|
|
978
|
+
const mode = typeof input === 'string'
|
|
979
|
+
? input
|
|
980
|
+
: typeof input === 'object'
|
|
981
|
+
? (input.mode ?? 'full-run')
|
|
982
|
+
: 'full-run';
|
|
983
|
+
const options = {
|
|
984
|
+
maxFps: DEFAULT_RECORDED_EVIDENCE_POLICY.maxFps,
|
|
985
|
+
maxSize: DEFAULT_RECORDED_EVIDENCE_POLICY.maxSize,
|
|
986
|
+
maxDurationMs: DEFAULT_RECORDED_EVIDENCE_POLICY.maxDurationMs,
|
|
987
|
+
maxBytes: DEFAULT_RECORDED_EVIDENCE_POLICY.maxBytes,
|
|
988
|
+
...(typeof input === 'object' ? input : {}),
|
|
989
|
+
mode: normalizeVideoRecordingMode(mode).mode,
|
|
990
|
+
};
|
|
991
|
+
for (const key of ['maxFps', 'maxSize', 'maxDurationMs', 'maxBytes']) {
|
|
992
|
+
if (!Number.isFinite(options[key]) || options[key] <= 0)
|
|
993
|
+
throw new Error(`Recording ${key} must be finite and positive.`);
|
|
994
|
+
}
|
|
995
|
+
if (options.maxDurationMs > DEFAULT_RECORDED_EVIDENCE_POLICY.maxDurationMs ||
|
|
996
|
+
options.maxBytes > DEFAULT_RECORDED_EVIDENCE_POLICY.maxBytes)
|
|
997
|
+
throw new Error('Recording policy exceeds the execution duration/byte limits.');
|
|
998
|
+
return options;
|
|
772
999
|
}
|
|
773
1000
|
function normalizeVideoRecordingMode(mode) {
|
|
774
1001
|
if (mode === 'off')
|