@farmslot/recipe-runner 0.28.5 → 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.
Files changed (110) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/adapters/core.d.ts +32 -1
  3. package/dist/adapters/core.d.ts.map +1 -1
  4. package/dist/adapters/core.js +152 -38
  5. package/dist/adapters/core.js.map +1 -1
  6. package/dist/adapters/ui.d.ts +2 -0
  7. package/dist/adapters/ui.d.ts.map +1 -1
  8. package/dist/adapters/ui.js +79 -37
  9. package/dist/adapters/ui.js.map +1 -1
  10. package/dist/cli/support.d.ts.map +1 -1
  11. package/dist/cli/support.js +5 -1
  12. package/dist/cli/support.js.map +1 -1
  13. package/dist/core/execution-signal.d.ts +4 -0
  14. package/dist/core/execution-signal.d.ts.map +1 -0
  15. package/dist/core/execution-signal.js +13 -0
  16. package/dist/core/execution-signal.js.map +1 -0
  17. package/dist/core/execution.d.ts +4 -0
  18. package/dist/core/execution.d.ts.map +1 -1
  19. package/dist/core/execution.js +86 -12
  20. package/dist/core/execution.js.map +1 -1
  21. package/dist/core/failure.d.ts +2 -0
  22. package/dist/core/failure.d.ts.map +1 -1
  23. package/dist/core/failure.js +10 -0
  24. package/dist/core/failure.js.map +1 -1
  25. package/dist/core/graph.d.ts +1 -0
  26. package/dist/core/graph.d.ts.map +1 -1
  27. package/dist/core/graph.js +2 -2
  28. package/dist/core/graph.js.map +1 -1
  29. package/dist/core/hud.d.ts.map +1 -1
  30. package/dist/core/hud.js +3 -3
  31. package/dist/core/hud.js.map +1 -1
  32. package/dist/core/invocation.d.ts +4 -1
  33. package/dist/core/invocation.d.ts.map +1 -1
  34. package/dist/core/invocation.js +3 -1
  35. package/dist/core/invocation.js.map +1 -1
  36. package/dist/core/recording-limits.d.ts +4 -0
  37. package/dist/core/recording-limits.d.ts.map +1 -0
  38. package/dist/core/recording-limits.js +74 -0
  39. package/dist/core/recording-limits.js.map +1 -0
  40. package/dist/core/recording-segments.d.ts +4 -0
  41. package/dist/core/recording-segments.d.ts.map +1 -0
  42. package/dist/core/recording-segments.js +101 -0
  43. package/dist/core/recording-segments.js.map +1 -0
  44. package/dist/core/runner.d.ts.map +1 -1
  45. package/dist/core/runner.js +336 -108
  46. package/dist/core/runner.js.map +1 -1
  47. package/dist/core/trace.d.ts.map +1 -1
  48. package/dist/core/trace.js +2 -0
  49. package/dist/core/trace.js.map +1 -1
  50. package/dist/core/types.d.ts +40 -22
  51. package/dist/core/types.d.ts.map +1 -1
  52. package/dist/evidence/assertions.d.ts +50 -0
  53. package/dist/evidence/assertions.d.ts.map +1 -0
  54. package/dist/evidence/assertions.js +156 -0
  55. package/dist/evidence/assertions.js.map +1 -0
  56. package/dist/evidence/claims.d.ts +15 -0
  57. package/dist/evidence/claims.d.ts.map +1 -0
  58. package/dist/evidence/claims.js +201 -0
  59. package/dist/evidence/claims.js.map +1 -0
  60. package/dist/evidence/index.d.ts +4 -0
  61. package/dist/evidence/index.d.ts.map +1 -0
  62. package/dist/evidence/index.js +3 -0
  63. package/dist/evidence/index.js.map +1 -0
  64. package/dist/evidence/moments.d.ts +8 -0
  65. package/dist/evidence/moments.d.ts.map +1 -0
  66. package/dist/evidence/moments.js +26 -0
  67. package/dist/evidence/moments.js.map +1 -0
  68. package/dist/evidence/package.d.ts +4 -0
  69. package/dist/evidence/package.d.ts.map +1 -0
  70. package/dist/evidence/package.js +140 -0
  71. package/dist/evidence/package.js.map +1 -0
  72. package/dist/evidence/plan.d.ts +9 -0
  73. package/dist/evidence/plan.d.ts.map +1 -0
  74. package/dist/evidence/plan.js +87 -0
  75. package/dist/evidence/plan.js.map +1 -0
  76. package/dist/evidence/read-package.d.ts +14 -0
  77. package/dist/evidence/read-package.d.ts.map +1 -0
  78. package/dist/evidence/read-package.js +153 -0
  79. package/dist/evidence/read-package.js.map +1 -0
  80. package/dist/evidence/stills.d.ts +5 -0
  81. package/dist/evidence/stills.d.ts.map +1 -0
  82. package/dist/evidence/stills.js +58 -0
  83. package/dist/evidence/stills.js.map +1 -0
  84. package/dist/file-digest.d.ts +3 -0
  85. package/dist/file-digest.d.ts.map +1 -0
  86. package/dist/file-digest.js +4 -0
  87. package/dist/file-digest.js.map +1 -0
  88. package/dist/index.d.ts +5 -0
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +5 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/recording/android-mirror.d.ts.map +1 -1
  93. package/dist/recording/android-mirror.js +17 -8
  94. package/dist/recording/android-mirror.js.map +1 -1
  95. package/dist/recording/capture-helper-timing.d.ts +2 -2
  96. package/dist/recording/capture-helper-timing.d.ts.map +1 -1
  97. package/dist/recording/capture-helper.d.ts.map +1 -1
  98. package/dist/recording/capture-helper.js +24 -12
  99. package/dist/recording/capture-helper.js.map +1 -1
  100. package/dist/recording/cdp-video-recorder.d.ts +2 -0
  101. package/dist/recording/cdp-video-recorder.d.ts.map +1 -1
  102. package/dist/recording/cdp-video-recorder.js +160 -50
  103. package/dist/recording/cdp-video-recorder.js.map +1 -1
  104. package/dist/recording/timeline.d.ts +2 -2
  105. package/dist/recording/timeline.d.ts.map +1 -1
  106. package/dist/runtime/cdp.d.ts +1 -0
  107. package/dist/runtime/cdp.d.ts.map +1 -1
  108. package/dist/runtime/cdp.js +71 -16
  109. package/dist/runtime/cdp.js.map +1 -1
  110. package/package.json +8 -3
@@ -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, validateRecipeArtifactPackage, } from '@farmslot/protocol';
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: request.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 videoOptions = normalizeVideoRecordingOptions(request.recordVideo);
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 videoTimeline;
260
+ let completionHudFailure;
261
+ const videoTimelines = [];
242
262
  let canExecute = true;
243
263
  if (videoRecorder) {
244
264
  try {
@@ -272,6 +292,7 @@ class DefaultRecipeRunner {
272
292
  try {
273
293
  if (canExecute) {
274
294
  const execution = await executeRecipe({
295
+ signal: request.signal,
275
296
  ref: rootRef,
276
297
  adapter: request.adapter,
277
298
  recipe,
@@ -280,6 +301,7 @@ class DefaultRecipeRunner {
280
301
  actionManifest: this.#actionManifest,
281
302
  adapters: this.#adapters,
282
303
  traceWriter,
304
+ claimProjections: evidencePlan.nodes,
283
305
  logger: this.#logger,
284
306
  defaultObserverRefs: this.#defaultObserverRefs,
285
307
  createContext: (nodeId, activeRecipe, activeOutputs, getOutput) => this.#createExecutionContext({
@@ -301,7 +323,7 @@ class DefaultRecipeRunner {
301
323
  publishHudProgress: (hudStatus, event) => this.#publishHudProgressOrRecord(traceWriter, hudStatus, event),
302
324
  ...(request.stopAfterNode !== undefined ? { stopAfterNode: request.stopAfterNode } : {}),
303
325
  });
304
- status = execution.status;
326
+ status = hasUnresolvedRecipeFailure(traceWriter.list()) ? 'fail' : execution.status;
305
327
  outputs = execution.outputs;
306
328
  }
307
329
  artifactWriter.register({
@@ -316,63 +338,6 @@ class DefaultRecipeRunner {
316
338
  label: 'Execution trace',
317
339
  category: 'system',
318
340
  });
319
- if (runRecording) {
320
- const recordingToStop = runRecording;
321
- runRecording = undefined;
322
- try {
323
- const { entry: videoArtifact, interruption, timing, } = await this.#stopRunVideoRecording(recordingToStop, traceWriter, artifactWriter, startedAt);
324
- artifactWriter.register(videoArtifact);
325
- if (timing)
326
- videoTimeline = { entry: videoArtifact, timing };
327
- if (interruption) {
328
- // The partial video stays registered as evidence; the run still fails, typed.
329
- captureInterruption = interruption;
330
- this.#logger.error(`record.video interrupted: ${interruption.message}`);
331
- status = 'fail';
332
- }
333
- }
334
- catch (error) {
335
- const message = errorMessage(error);
336
- await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
337
- await rm(recordingToStop.stagingRoot, { recursive: true, force: true });
338
- traceWriter.record({
339
- nodeId: 'recipe-run:video',
340
- action: 'record.video',
341
- startedAt: startedAt.toISOString(),
342
- endedAt: new Date().toISOString(),
343
- durationMs: Date.now() - startedAt.getTime(),
344
- ok: false,
345
- cause_class: recipeFailureCause(error, 'harness'),
346
- error: message,
347
- });
348
- this.#logger.error(`record.video failed: ${message}`);
349
- status = 'fail';
350
- }
351
- }
352
- if (request.finalizeRecording) {
353
- const recordingStartedAt = new Date();
354
- try {
355
- const interruption = await request.finalizeRecording();
356
- if (interruption) {
357
- captureInterruption = interruption;
358
- traceWriter.record(captureInterruptedTraceEntry(interruption, startedAt));
359
- status = 'fail';
360
- }
361
- }
362
- catch (error) {
363
- traceWriter.record({
364
- nodeId: 'recipe-run:video',
365
- action: 'record.video',
366
- startedAt: recordingStartedAt.toISOString(),
367
- endedAt: new Date().toISOString(),
368
- durationMs: Date.now() - recordingStartedAt.getTime(),
369
- ok: false,
370
- cause_class: recipeFailureCause(error, 'harness'),
371
- error: errorMessage(error),
372
- });
373
- status = 'fail';
374
- }
375
- }
376
341
  if (status === 'pass' || status === 'fail') {
377
342
  const runHudStartedAt = new Date();
378
343
  try {
@@ -388,7 +353,7 @@ class DefaultRecipeRunner {
388
353
  const message = error instanceof Error ? error.message : String(error);
389
354
  const fields = recipeFailureTraceFields(error);
390
355
  const captureEndpointLost = captureInterruption && fields.error_code === RUNTIME_CONNECTION_CLOSED;
391
- traceWriter.record({
356
+ completionHudFailure = {
392
357
  nodeId: 'recipe-complete:hud',
393
358
  action: 'app.hud',
394
359
  startedAt: runHudStartedAt.toISOString(),
@@ -404,13 +369,66 @@ class DefaultRecipeRunner {
404
369
  error_details: { runtime_error_code: fields.error_code },
405
370
  }
406
371
  : {}),
407
- });
372
+ };
373
+ traceWriter.record(completionHudFailure);
408
374
  this.#logger.error(`app.hud complete update failed: ${message}`);
409
375
  status = 'fail';
410
376
  }
411
377
  }
412
- if (videoTimeline) {
413
- const { entry, timing } = videoTimeline;
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) {
414
432
  try {
415
433
  entry.timelinePath = await writeRecordingTimeline(artifactsDir, entry.path, timing, traceWriter.list());
416
434
  artifactWriter.register(entry);
@@ -438,7 +456,101 @@ class DefaultRecipeRunner {
438
456
  const endedAt = new Date();
439
457
  const tracePath = await traceWriter.write();
440
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
+ });
441
552
  const summary = {
553
+ recipeRunId,
442
554
  invocationDigest: digestRecipeDocument(invocation),
443
555
  status,
444
556
  ...summarizeTraceCounts(trace),
@@ -459,7 +571,14 @@ class DefaultRecipeRunner {
459
571
  };
460
572
  const summaryPath = await summaryWriter.write(summary);
461
573
  const artifactManifestPath = await artifactWriter.write(status, this.#runnerProvenance);
462
- const packageValidation = validateRecipeArtifactPackage({
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)])),
463
582
  recipe,
464
583
  resolvedRecipes: Object.fromEntries([...dependencyResolution.recipes.values()].map((entry) => [
465
584
  entry.provenance.digest,
@@ -494,6 +613,8 @@ class DefaultRecipeRunner {
494
613
  tracePath,
495
614
  artifactManifestPath,
496
615
  recipePath,
616
+ claimsPath,
617
+ claims,
497
618
  ...(captureInterruption ? { captureInterruption } : {}),
498
619
  };
499
620
  }
@@ -518,7 +639,14 @@ class DefaultRecipeRunner {
518
639
  await copyFileWithinRoots(dir, 'frame.png', artifactsDir, relative);
519
640
  // Keep the provider's source path in its raw sidecar; the action output
520
641
  // maps it to the published artifact path without rewriting capture facts.
521
- return { ...evidence, sourceOutput: evidence.output, output: relative };
642
+ return {
643
+ ...evidence,
644
+ sourceOutput: evidence.output,
645
+ output: relative,
646
+ ...(evidence.fallbackFrom
647
+ ? {}
648
+ : { videoPath: recordedVideoPath(Number(evidence.segmentIndex ?? 0)) }),
649
+ };
522
650
  },
523
651
  }
524
652
  : {}),
@@ -549,7 +677,7 @@ class DefaultRecipeRunner {
549
677
  action: 'record.video',
550
678
  intent: 'Record the recipe run when motion proof is useful',
551
679
  };
552
- const target = videoOptions.target ??
680
+ const resolvedTarget = videoOptions.target ??
553
681
  (await this.#recording?.targetProvider?.resolveRecordingTarget({
554
682
  nodeId: 'recipe-run',
555
683
  node,
@@ -559,16 +687,29 @@ class DefaultRecipeRunner {
559
687
  env,
560
688
  scope: 'run',
561
689
  }));
562
- if (!target) {
563
- throw new Error('--record-video requires a recording target. Provide a project RecordingTargetProvider or CLI target flags.');
690
+ if (!resolvedTarget) {
691
+ throw new Error('UI recording requires a target. Provide a project RecordingTargetProvider or disable recording with a reason.');
564
692
  }
565
- const relativePath = 'videos/recipe-run.mp4';
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);
566
699
  const stagingRoot = await mkdtemp(path.join(os.tmpdir(), 'farmslot-recipe-video-'));
567
700
  const stagingRelativePath = 'recipe-run.mp4';
568
701
  const outputPath = path.join(stagingRoot, stagingRelativePath);
702
+ let admission;
703
+ let releaseFailure;
569
704
  let recording;
570
705
  try {
571
- recording = await recorder.start({
706
+ admission = await this.#recording?.admission?.acquire({
707
+ backend: recorder.name,
708
+ target,
709
+ projectRoot,
710
+ artifactsDir,
711
+ });
712
+ const backend = await startSegmentedRecording(recorder, {
572
713
  outputPath,
573
714
  target,
574
715
  maxFps: videoOptions.maxFps,
@@ -576,10 +717,42 @@ class DefaultRecipeRunner {
576
717
  nodeId: 'recipe-run',
577
718
  record: 'full_run',
578
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
+ };
579
746
  }
580
747
  catch (error) {
581
748
  await removePartialRunVideoOutput(outputPath, this.#logger);
582
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
+ }
583
756
  throw error;
584
757
  }
585
758
  const entry = {
@@ -598,7 +771,10 @@ class DefaultRecipeRunner {
598
771
  ...(videoOptions.maxFps != null ? { maxFps: videoOptions.maxFps } : {}),
599
772
  };
600
773
  return {
601
- recording,
774
+ get releaseFailure() {
775
+ return releaseFailure;
776
+ },
777
+ recording: boundRecording(recording, outputPath, videoOptions),
602
778
  entry,
603
779
  outputPath,
604
780
  stagingRoot,
@@ -608,37 +784,65 @@ class DefaultRecipeRunner {
608
784
  }
609
785
  async #stopRunVideoRecording(runRecording, traceWriter, artifactWriter, runStartedAt) {
610
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.');
611
802
  const interruption = result.interruption
612
- ? runCaptureInterruption(result.interruption, runRecording.entry.path)
803
+ ? runCaptureInterruption(result.interruption, recordedVideoPath(result.segments.length - 1))
613
804
  : undefined;
614
- // Record the failure before the timeline, which is bound to the final trace.
615
805
  if (interruption)
616
806
  traceWriter.record(captureInterruptedTraceEntry(interruption, runStartedAt));
617
- await assertVideoOutputReady(runRecording.outputPath);
618
- await copyFileWithinRoots(runRecording.stagingRoot, runRecording.stagingRelativePath, runRecording.artifactsDir, runRecording.entry.path);
619
- if (result.timingEvidencePath && result.timing) {
620
- const evidencePath = `${runRecording.entry.path}.capture.json`;
621
- await copyFileWithinRoots(runRecording.stagingRoot, result.timingEvidencePath, runRecording.artifactsDir, evidencePath);
622
- artifactWriter.register({
623
- path: evidencePath,
624
- type: 'json',
625
- category: 'system',
626
- label: 'Native recording and snapshot timing',
627
- });
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 } : {}) });
628
840
  }
629
841
  await rm(runRecording.stagingRoot, { recursive: true, force: true });
630
- const entry = {
631
- ...runRecording.entry,
632
- ...(result.recorder ? { recorder: result.recorder } : {}),
633
- ...(result.interruption ? { interruption: result.interruption } : {}),
634
- };
635
- if (!result.timing)
636
- entry.timelineUnavailableReason =
637
- result.timingUnavailableReason ?? 'Recorder does not provide timeline alignment.';
638
842
  return {
639
- entry,
843
+ videos,
640
844
  ...(interruption ? { interruption } : {}),
641
- ...(result.timing ? { timing: result.timing } : {}),
845
+ ...(releaseFailure ? { releaseFailure } : {}),
642
846
  };
643
847
  }
644
848
  #hudAction() {
@@ -732,6 +936,9 @@ class DefaultRecipeRunner {
732
936
  return 'Recipe run';
733
937
  }
734
938
  }
939
+ function recordedVideoPath(index) {
940
+ return index === 0 ? 'videos/recipe-run.mp4' : `videos/recipe-run-${index}.mp4`;
941
+ }
735
942
  function buildRecipeLibrarySummary(resolution) {
736
943
  return {
737
944
  sources: resolution.sources,
@@ -759,15 +966,36 @@ function rootRecipeRef(sourceRecipePath, recipes, digest) {
759
966
  }
760
967
  return rootResolutionRef(digest);
761
968
  }
762
- function normalizeVideoRecordingOptions(input) {
763
- if (!input)
969
+ function normalizeVideoRecordingOptions(input, needsUi, reason) {
970
+ if (input === undefined && !needsUi)
764
971
  return { mode: 'off' };
765
- if (input === true)
766
- return { mode: 'full-run' };
767
- if (typeof input === 'string')
768
- return normalizeVideoRecordingMode(input);
769
- const mode = input.mode ?? 'full-run';
770
- return { ...input, mode: normalizeVideoRecordingMode(mode).mode };
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;
771
999
  }
772
1000
  function normalizeVideoRecordingMode(mode) {
773
1001
  if (mode === 'off')