@farmslot/recipe-runner 0.29.0 → 0.31.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 (113) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/adapters/core.d.ts.map +1 -1
  3. package/dist/adapters/core.js +60 -55
  4. package/dist/adapters/core.js.map +1 -1
  5. package/dist/adapters/ui.d.ts +2 -0
  6. package/dist/adapters/ui.d.ts.map +1 -1
  7. package/dist/adapters/ui.js +79 -37
  8. package/dist/adapters/ui.js.map +1 -1
  9. package/dist/cli/support.d.ts.map +1 -1
  10. package/dist/cli/support.js +13 -9
  11. package/dist/cli/support.js.map +1 -1
  12. package/dist/core/assertions.d.ts +16 -0
  13. package/dist/core/assertions.d.ts.map +1 -0
  14. package/dist/core/assertions.js +59 -0
  15. package/dist/core/assertions.js.map +1 -0
  16. package/dist/core/compose.d.ts +3 -2
  17. package/dist/core/compose.d.ts.map +1 -1
  18. package/dist/core/compose.js +23 -7
  19. package/dist/core/compose.js.map +1 -1
  20. package/dist/core/execution.d.ts +3 -0
  21. package/dist/core/execution.d.ts.map +1 -1
  22. package/dist/core/execution.js +60 -3
  23. package/dist/core/execution.js.map +1 -1
  24. package/dist/core/graph.d.ts +1 -0
  25. package/dist/core/graph.d.ts.map +1 -1
  26. package/dist/core/graph.js +2 -2
  27. package/dist/core/graph.js.map +1 -1
  28. package/dist/core/hud.d.ts.map +1 -1
  29. package/dist/core/hud.js +3 -3
  30. package/dist/core/hud.js.map +1 -1
  31. package/dist/core/invocation.d.ts +4 -1
  32. package/dist/core/invocation.d.ts.map +1 -1
  33. package/dist/core/invocation.js +3 -1
  34. package/dist/core/invocation.js.map +1 -1
  35. package/dist/core/json.d.ts +1 -0
  36. package/dist/core/json.d.ts.map +1 -1
  37. package/dist/core/json.js +7 -9
  38. package/dist/core/json.js.map +1 -1
  39. package/dist/core/recording-limits.d.ts +4 -0
  40. package/dist/core/recording-limits.d.ts.map +1 -0
  41. package/dist/core/recording-limits.js +74 -0
  42. package/dist/core/recording-limits.js.map +1 -0
  43. package/dist/core/recording-segments.d.ts +4 -0
  44. package/dist/core/recording-segments.d.ts.map +1 -0
  45. package/dist/core/recording-segments.js +101 -0
  46. package/dist/core/recording-segments.js.map +1 -0
  47. package/dist/core/runner.d.ts.map +1 -1
  48. package/dist/core/runner.js +340 -129
  49. package/dist/core/runner.js.map +1 -1
  50. package/dist/core/trust.d.ts.map +1 -1
  51. package/dist/core/trust.js +7 -0
  52. package/dist/core/trust.js.map +1 -1
  53. package/dist/core/types.d.ts +37 -22
  54. package/dist/core/types.d.ts.map +1 -1
  55. package/dist/evidence/assertions.d.ts +50 -0
  56. package/dist/evidence/assertions.d.ts.map +1 -0
  57. package/dist/evidence/assertions.js +156 -0
  58. package/dist/evidence/assertions.js.map +1 -0
  59. package/dist/evidence/claims.d.ts +15 -0
  60. package/dist/evidence/claims.d.ts.map +1 -0
  61. package/dist/evidence/claims.js +201 -0
  62. package/dist/evidence/claims.js.map +1 -0
  63. package/dist/evidence/index.d.ts +4 -0
  64. package/dist/evidence/index.d.ts.map +1 -0
  65. package/dist/evidence/index.js +3 -0
  66. package/dist/evidence/index.js.map +1 -0
  67. package/dist/evidence/moments.d.ts +8 -0
  68. package/dist/evidence/moments.d.ts.map +1 -0
  69. package/dist/evidence/moments.js +26 -0
  70. package/dist/evidence/moments.js.map +1 -0
  71. package/dist/evidence/package.d.ts +4 -0
  72. package/dist/evidence/package.d.ts.map +1 -0
  73. package/dist/evidence/package.js +140 -0
  74. package/dist/evidence/package.js.map +1 -0
  75. package/dist/evidence/plan.d.ts +9 -0
  76. package/dist/evidence/plan.d.ts.map +1 -0
  77. package/dist/evidence/plan.js +87 -0
  78. package/dist/evidence/plan.js.map +1 -0
  79. package/dist/evidence/read-package.d.ts +14 -0
  80. package/dist/evidence/read-package.d.ts.map +1 -0
  81. package/dist/evidence/read-package.js +153 -0
  82. package/dist/evidence/read-package.js.map +1 -0
  83. package/dist/evidence/stills.d.ts +5 -0
  84. package/dist/evidence/stills.d.ts.map +1 -0
  85. package/dist/evidence/stills.js +58 -0
  86. package/dist/evidence/stills.js.map +1 -0
  87. package/dist/file-digest.d.ts +3 -0
  88. package/dist/file-digest.d.ts.map +1 -0
  89. package/dist/file-digest.js +4 -0
  90. package/dist/file-digest.js.map +1 -0
  91. package/dist/index.d.ts +5 -0
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +5 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/recording/android-mirror.d.ts.map +1 -1
  96. package/dist/recording/android-mirror.js +17 -8
  97. package/dist/recording/android-mirror.js.map +1 -1
  98. package/dist/recording/capture-helper-timing.d.ts +2 -2
  99. package/dist/recording/capture-helper-timing.d.ts.map +1 -1
  100. package/dist/recording/capture-helper.d.ts.map +1 -1
  101. package/dist/recording/capture-helper.js +24 -12
  102. package/dist/recording/capture-helper.js.map +1 -1
  103. package/dist/recording/cdp-video-recorder.d.ts +2 -0
  104. package/dist/recording/cdp-video-recorder.d.ts.map +1 -1
  105. package/dist/recording/cdp-video-recorder.js +160 -50
  106. package/dist/recording/cdp-video-recorder.js.map +1 -1
  107. package/dist/recording/timeline.d.ts +2 -2
  108. package/dist/recording/timeline.d.ts.map +1 -1
  109. package/dist/runtime/cdp.d.ts +1 -0
  110. package/dist/runtime/cdp.d.ts.map +1 -1
  111. package/dist/runtime/cdp.js +105 -28
  112. package/dist/runtime/cdp.js.map +1 -1
  113. 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';
@@ -56,22 +64,8 @@ export function createRecipeRunner(options) {
56
64
  }
57
65
  return new DefaultRecipeRunner(options.actionManifest, adapterMap, options.logger ?? noopLogger, options.hud, options.runner, options.recording, options.defaultSource, options.blockedCapabilities);
58
66
  }
59
- function collectWatchLogPaths(paths, nodes) {
60
- for (const node of Object.values(nodes)) {
61
- if (!isRecord(node) || node.action !== 'watch_logs' || typeof node.path !== 'string')
62
- continue;
63
- paths.add(normalizeRelativePath(node.path).split(path.sep).join('/'));
64
- }
65
- }
66
- async function collectRunFileOffsets({ graph, recipes, projectRoot, }) {
67
+ async function collectRunFileOffsets(paths, projectRoot) {
67
68
  const offsets = new Map();
68
- const paths = new Set();
69
- collectWatchLogPaths(paths, graph.nodes);
70
- for (const recipe of recipes.values()) {
71
- if (recipe.aliasFor)
72
- continue;
73
- collectWatchLogPaths(paths, extractWorkflowGraph(recipe.document).nodes);
74
- }
75
69
  for (const relativePath of paths) {
76
70
  try {
77
71
  const info = await statFileWithinRoot(projectRoot, relativePath);
@@ -177,11 +171,18 @@ class DefaultRecipeRunner {
177
171
  });
178
172
  }
179
173
  const params = resolveRecipeParams(rootRef, recipe, request.params ?? {});
180
- validateRecipeDependencyParams({
174
+ const runLogPaths = validateRecipeDependencyParams({
181
175
  root: recipe,
182
176
  params,
183
177
  recipes: dependencyResolution.recipes,
178
+ actionManifest: this.#actionManifest,
184
179
  });
180
+ const evidencePlan = planRecipeEvidence(proofDocument(recipe), new Map([...dependencyResolution.recipes].map(([ref, entry]) => [
181
+ ref,
182
+ proofDocument(entry.document),
183
+ ])), request.claims);
184
+ const needsUi = [...evidencePlan.nodes.values()].some((node) => node.action.startsWith('ui.'));
185
+ const videoOptions = normalizeVideoRecordingOptions(request.recordVideo, needsUi, request.recordingReason);
185
186
  const executionPlan = buildRecipeExecutionPlan({
186
187
  recipe,
187
188
  params,
@@ -194,7 +195,7 @@ class DefaultRecipeRunner {
194
195
  artifactsDir,
195
196
  env,
196
197
  hud: this.#hud,
197
- recordVideo: request.recordVideo,
198
+ recordVideo: videoOptions.mode === 'off' ? false : videoOptions,
198
199
  });
199
200
  return {
200
201
  projectRoot,
@@ -210,35 +211,38 @@ class DefaultRecipeRunner {
210
211
  recipes: dependencyResolution.recipes,
211
212
  dependencyResolution,
212
213
  executionPlan,
214
+ runLogPaths,
215
+ evidencePlan,
216
+ recordingRequired: needsUi,
217
+ videoOptions,
213
218
  };
214
219
  }
215
220
  async run(request) {
216
- const { projectRoot, artifactsDir, recipe, rootRef, params, env, libraryResolution, graph, recipes, dependencyResolution, executionPlan, } = await this.#resolveRequest(request);
221
+ const { projectRoot, artifactsDir, recipe, rootRef, params, env, libraryResolution, recipes, dependencyResolution, executionPlan, runLogPaths, evidencePlan, recordingRequired, videoOptions, } = await this.#resolveRequest(request);
217
222
  enforceRecipeExecutionPlan(executionPlan, request, this.#blockedCapabilities);
218
223
  const startedAt = new Date();
224
+ const recipeRunId = randomUUID();
219
225
  await mkdir(artifactsDir, { recursive: true });
220
- const runFileOffsets = await collectRunFileOffsets({
221
- graph,
222
- recipes,
223
- projectRoot,
224
- });
226
+ const runFileOffsets = await collectRunFileOffsets(runLogPaths, projectRoot);
225
227
  const artifactWriter = new JsonArtifactWriter(artifactsDir);
226
228
  const traceWriter = new JsonTraceWriter(artifactsDir, this.#runnerProvenance);
227
229
  const summaryWriter = new JsonSummaryWriter(artifactsDir);
228
230
  let outputs = new Map();
229
231
  const recipePath = await artifactWriter.copyRecipe(recipe);
230
- const invocation = createRecipeInvocation(recipe, params, startedAt.toISOString());
232
+ const invocation = createRecipeInvocation(recipe, params, startedAt.toISOString(), request.claims, recipeRunId);
231
233
  await artifactWriter.writeInvocation(invocation);
232
234
  for (const dependency of dependencyResolution.recipes.values()) {
233
235
  await artifactWriter.copyResolvedRecipe(dependency.provenance.digest, dependency.document);
234
236
  }
235
237
  await artifactWriter.writeRecipeResolution(dependencyResolution.document);
236
238
  let status = 'unknown';
237
- const videoOptions = normalizeVideoRecordingOptions(request.recordVideo);
239
+ const segments = [];
240
+ const timelines = new Map();
238
241
  const videoRecorder = videoOptions.mode !== 'off' ? this.#videoRecorder() : undefined;
239
242
  let runRecording;
240
243
  let captureInterruption;
241
- let videoTimeline;
244
+ let completionHudFailure;
245
+ const videoTimelines = [];
242
246
  let canExecute = true;
243
247
  if (videoRecorder) {
244
248
  try {
@@ -281,6 +285,7 @@ class DefaultRecipeRunner {
281
285
  actionManifest: this.#actionManifest,
282
286
  adapters: this.#adapters,
283
287
  traceWriter,
288
+ claimProjections: evidencePlan.nodes,
284
289
  logger: this.#logger,
285
290
  defaultObserverRefs: this.#defaultObserverRefs,
286
291
  createContext: (nodeId, activeRecipe, activeOutputs, getOutput) => this.#createExecutionContext({
@@ -302,7 +307,7 @@ class DefaultRecipeRunner {
302
307
  publishHudProgress: (hudStatus, event) => this.#publishHudProgressOrRecord(traceWriter, hudStatus, event),
303
308
  ...(request.stopAfterNode !== undefined ? { stopAfterNode: request.stopAfterNode } : {}),
304
309
  });
305
- status = execution.status;
310
+ status = hasUnresolvedRecipeFailure(traceWriter.list()) ? 'fail' : execution.status;
306
311
  outputs = execution.outputs;
307
312
  }
308
313
  artifactWriter.register({
@@ -317,63 +322,6 @@ class DefaultRecipeRunner {
317
322
  label: 'Execution trace',
318
323
  category: 'system',
319
324
  });
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
325
  if (status === 'pass' || status === 'fail') {
378
326
  const runHudStartedAt = new Date();
379
327
  try {
@@ -389,7 +337,7 @@ class DefaultRecipeRunner {
389
337
  const message = error instanceof Error ? error.message : String(error);
390
338
  const fields = recipeFailureTraceFields(error);
391
339
  const captureEndpointLost = captureInterruption && fields.error_code === RUNTIME_CONNECTION_CLOSED;
392
- traceWriter.record({
340
+ completionHudFailure = {
393
341
  nodeId: 'recipe-complete:hud',
394
342
  action: 'app.hud',
395
343
  startedAt: runHudStartedAt.toISOString(),
@@ -405,13 +353,66 @@ class DefaultRecipeRunner {
405
353
  error_details: { runtime_error_code: fields.error_code },
406
354
  }
407
355
  : {}),
408
- });
356
+ };
357
+ traceWriter.record(completionHudFailure);
409
358
  this.#logger.error(`app.hud complete update failed: ${message}`);
410
359
  status = 'fail';
411
360
  }
412
361
  }
413
- if (videoTimeline) {
414
- const { entry, timing } = videoTimeline;
362
+ if (runRecording) {
363
+ const recordingToStop = runRecording;
364
+ runRecording = undefined;
365
+ try {
366
+ const { videos, interruption, releaseFailure } = await this.#stopRunVideoRecording(recordingToStop, traceWriter, artifactWriter, startedAt);
367
+ for (const video of videos)
368
+ if (video.timing)
369
+ videoTimelines.push({ entry: video.entry, timing: video.timing });
370
+ if (releaseFailure) {
371
+ this.#logger.error(`Recording lease cleanup failed: ${releaseFailure}`);
372
+ status = 'fail';
373
+ }
374
+ if (interruption) {
375
+ // The partial video stays registered as evidence; the run still fails, typed.
376
+ captureInterruption = interruption;
377
+ if (completionHudFailure?.error_code === RUNTIME_CONNECTION_CLOSED) {
378
+ completionHudFailure.error_code = CAPTURE_INTERRUPTED;
379
+ completionHudFailure.cause_class = 'environment';
380
+ completionHudFailure.error_details = {
381
+ runtime_error_code: RUNTIME_CONNECTION_CLOSED,
382
+ };
383
+ }
384
+ this.#logger.error(`record.video interrupted: ${interruption.message}`);
385
+ status = 'fail';
386
+ }
387
+ }
388
+ catch (error) {
389
+ const message = errorMessage(error);
390
+ await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
391
+ await rm(recordingToStop.stagingRoot, { recursive: true, force: true });
392
+ traceWriter.record({
393
+ nodeId: 'recipe-run:video',
394
+ action: 'record.video',
395
+ startedAt: startedAt.toISOString(),
396
+ endedAt: new Date().toISOString(),
397
+ durationMs: Date.now() - startedAt.getTime(),
398
+ ok: false,
399
+ cause_class: recipeFailureCause(error, 'harness'),
400
+ error: message,
401
+ });
402
+ this.#logger.error(`record.video failed: ${message}`);
403
+ status = 'fail';
404
+ }
405
+ }
406
+ for (const entry of artifactWriter
407
+ .list()
408
+ .filter((artifact) => artifact.type === 'video' && artifact.sha256)) {
409
+ for (const still of artifactWriter.list()) {
410
+ const source = still.metadata?.recordedEvidence;
411
+ if (isRecord(source) && source.videoPath === entry.path)
412
+ source.videoDigest = `sha256:${entry.sha256}`;
413
+ }
414
+ }
415
+ for (const { entry, timing } of videoTimelines) {
415
416
  try {
416
417
  entry.timelinePath = await writeRecordingTimeline(artifactsDir, entry.path, timing, traceWriter.list());
417
418
  artifactWriter.register(entry);
@@ -439,7 +440,101 @@ class DefaultRecipeRunner {
439
440
  const endedAt = new Date();
440
441
  const tracePath = await traceWriter.write();
441
442
  const trace = traceWriter.list();
443
+ for (const artifact of artifactWriter.list()) {
444
+ if (artifact.type !== 'video')
445
+ continue;
446
+ const videoDigest = artifact.sha256
447
+ ? `sha256:${artifact.sha256}`
448
+ : await digestFileWithinRoot(artifactsDir, artifact.path);
449
+ segments.push({
450
+ videoPath: artifact.path,
451
+ videoDigest,
452
+ ...(artifact.timelinePath ? { timelinePath: artifact.timelinePath } : {}),
453
+ ...(artifact.metadata?.gapBefore === true ? { gapBefore: true } : {}),
454
+ });
455
+ if (artifact.timelinePath)
456
+ timelines.set(videoDigest, (await readJsonFile(path.join(artifactsDir, artifact.timelinePath))));
457
+ }
458
+ const pendingClaims = deriveRecipeClaims({
459
+ recipeRunId,
460
+ ...evidencePlan,
461
+ trace,
462
+ artifacts: artifactWriter.list(),
463
+ segments,
464
+ timelines,
465
+ });
466
+ const derivativeIssues = [];
467
+ const requiredStills = [...new Set(pendingClaims.flatMap((claim) => claim.assertionIndices))];
468
+ await mkdir(path.join(artifactsDir, 'stills'), { recursive: true });
469
+ for (const traceIndex of requiredStills) {
470
+ const nodeId = trace[traceIndex]?.nodeId;
471
+ if (!trace[traceIndex]?.action.startsWith('ui.') ||
472
+ artifactWriter
473
+ .list()
474
+ .some((artifact) => artifact.type === 'screenshot' &&
475
+ artifact.nodeId === nodeId &&
476
+ isRecord(artifact.metadata?.recordedEvidence)))
477
+ continue;
478
+ const claim = pendingClaims.find((entry) => entry.assertionIndices.includes(traceIndex));
479
+ const moment = claim?.moments.find((entry) => entry.traceIndex === traceIndex);
480
+ const segment = moment
481
+ ? segments.find((entry) => entry.videoDigest === moment.videoDigest)
482
+ : undefined;
483
+ const timeline = segment ? timelines.get(segment.videoDigest) : undefined;
484
+ if (!segment || !timeline)
485
+ continue;
486
+ try {
487
+ const still = await materializeEvidenceStill(artifactsDir, segment, timeline, traceIndex, trace);
488
+ if (still)
489
+ artifactWriter.register(still);
490
+ }
491
+ catch (error) {
492
+ // The source clip remains usable; retain the failed derivative as package diagnostics.
493
+ artifactWriter.register({
494
+ path: 'recording-derivatives.json',
495
+ type: 'json',
496
+ category: 'diagnostic',
497
+ label: 'Evidence derivative limitations',
498
+ });
499
+ derivativeIssues.push({ nodeId, error: errorMessage(error) });
500
+ }
501
+ }
502
+ if (derivativeIssues.length)
503
+ await writeFileWithinRoot(artifactsDir, 'recording-derivatives.json', JSON.stringify(derivativeIssues));
504
+ const claims = {
505
+ schemaVersion: 2,
506
+ recipeRunId,
507
+ criteria: deriveRecipeClaims({
508
+ recipeRunId,
509
+ ...evidencePlan,
510
+ trace,
511
+ artifacts: artifactWriter.list(),
512
+ segments,
513
+ timelines,
514
+ }),
515
+ recording: {
516
+ status: videoOptions.mode === 'off'
517
+ ? recordingRequired
518
+ ? 'explicit-off'
519
+ : 'not-required'
520
+ : captureInterruption
521
+ ? 'incomplete'
522
+ : segments.length
523
+ ? 'complete'
524
+ : 'failed',
525
+ ...(videoOptions.reason ? { reason: videoOptions.reason } : {}),
526
+ segments,
527
+ },
528
+ };
529
+ const claimsPath = await writeFileWithinRoot(artifactsDir, 'acceptance-status.json', `${JSON.stringify(claims, null, 2)}\n`);
530
+ artifactWriter.register({
531
+ path: 'acceptance-status.json',
532
+ type: 'json',
533
+ category: 'system',
534
+ label: 'Derived claim evidence',
535
+ });
442
536
  const summary = {
537
+ recipeRunId,
443
538
  invocationDigest: digestRecipeDocument(invocation),
444
539
  status,
445
540
  ...summarizeTraceCounts(trace),
@@ -460,7 +555,14 @@ class DefaultRecipeRunner {
460
555
  };
461
556
  const summaryPath = await summaryWriter.write(summary);
462
557
  const artifactManifestPath = await artifactWriter.write(status, this.#runnerProvenance);
463
- const packageValidation = validateRecipeArtifactPackage({
558
+ const packageValidation = validateRecordedEvidencePackage({
559
+ invocation,
560
+ recipeRunId,
561
+ claims,
562
+ artifactDigests: Object.fromEntries(segments.map((segment) => [segment.videoPath, segment.videoDigest])),
563
+ timelines: Object.fromEntries(segments
564
+ .filter((segment) => segment.timelinePath)
565
+ .map((segment) => [segment.timelinePath, timelines.get(segment.videoDigest)])),
464
566
  recipe,
465
567
  resolvedRecipes: Object.fromEntries([...dependencyResolution.recipes.values()].map((entry) => [
466
568
  entry.provenance.digest,
@@ -495,6 +597,8 @@ class DefaultRecipeRunner {
495
597
  tracePath,
496
598
  artifactManifestPath,
497
599
  recipePath,
600
+ claimsPath,
601
+ claims,
498
602
  ...(captureInterruption ? { captureInterruption } : {}),
499
603
  };
500
604
  }
@@ -519,7 +623,14 @@ class DefaultRecipeRunner {
519
623
  await copyFileWithinRoots(dir, 'frame.png', artifactsDir, relative);
520
624
  // Keep the provider's source path in its raw sidecar; the action output
521
625
  // maps it to the published artifact path without rewriting capture facts.
522
- return { ...evidence, sourceOutput: evidence.output, output: relative };
626
+ return {
627
+ ...evidence,
628
+ sourceOutput: evidence.output,
629
+ output: relative,
630
+ ...(evidence.fallbackFrom
631
+ ? {}
632
+ : { videoPath: recordedVideoPath(Number(evidence.segmentIndex ?? 0)) }),
633
+ };
523
634
  },
524
635
  }
525
636
  : {}),
@@ -550,7 +661,7 @@ class DefaultRecipeRunner {
550
661
  action: 'record.video',
551
662
  intent: 'Record the recipe run when motion proof is useful',
552
663
  };
553
- const target = videoOptions.target ??
664
+ const resolvedTarget = videoOptions.target ??
554
665
  (await this.#recording?.targetProvider?.resolveRecordingTarget({
555
666
  nodeId: 'recipe-run',
556
667
  node,
@@ -560,16 +671,29 @@ class DefaultRecipeRunner {
560
671
  env,
561
672
  scope: 'run',
562
673
  }));
563
- if (!target) {
564
- throw new Error('--record-video requires a recording target. Provide a project RecordingTargetProvider or CLI target flags.');
674
+ if (!resolvedTarget) {
675
+ throw new Error('UI recording requires a target. Provide a project RecordingTargetProvider or disable recording with a reason.');
565
676
  }
566
- const relativePath = 'videos/recipe-run.mp4';
677
+ const validation = validateRecipeRecordingTarget(resolvedTarget);
678
+ if (validation.status !== 'valid')
679
+ throw new RecipeExecutionError('environment', validation.findings.map((finding) => finding.message).join('; '));
680
+ // Admission and backend start use the same immutable provider-resolved identity.
681
+ const target = Object.freeze({ ...resolvedTarget });
682
+ const relativePath = recordedVideoPath(0);
567
683
  const stagingRoot = await mkdtemp(path.join(os.tmpdir(), 'farmslot-recipe-video-'));
568
684
  const stagingRelativePath = 'recipe-run.mp4';
569
685
  const outputPath = path.join(stagingRoot, stagingRelativePath);
686
+ let admission;
687
+ let releaseFailure;
570
688
  let recording;
571
689
  try {
572
- recording = await recorder.start({
690
+ admission = await this.#recording?.admission?.acquire({
691
+ backend: recorder.name,
692
+ target,
693
+ projectRoot,
694
+ artifactsDir,
695
+ });
696
+ const backend = await startSegmentedRecording(recorder, {
573
697
  outputPath,
574
698
  target,
575
699
  maxFps: videoOptions.maxFps,
@@ -577,10 +701,42 @@ class DefaultRecipeRunner {
577
701
  nodeId: 'recipe-run',
578
702
  record: 'full_run',
579
703
  });
704
+ recording = {
705
+ ...backend,
706
+ async stop() {
707
+ let result;
708
+ try {
709
+ result = await backend.stop();
710
+ }
711
+ catch (error) {
712
+ try {
713
+ await admission?.release();
714
+ }
715
+ catch (releaseError) {
716
+ throw new AggregateError([error, releaseError], 'Recording stop and admission release failed.');
717
+ }
718
+ throw error;
719
+ }
720
+ try {
721
+ await admission?.release();
722
+ }
723
+ catch (error) {
724
+ // Completed originals remain usable; the caller records failed lease cleanup separately.
725
+ releaseFailure = errorMessage(error);
726
+ }
727
+ return result;
728
+ },
729
+ };
580
730
  }
581
731
  catch (error) {
582
732
  await removePartialRunVideoOutput(outputPath, this.#logger);
583
733
  await rm(stagingRoot, { recursive: true, force: true });
734
+ try {
735
+ await admission?.release();
736
+ }
737
+ catch (releaseError) {
738
+ throw new AggregateError([error, releaseError], 'Recording start and admission release failed.');
739
+ }
584
740
  throw error;
585
741
  }
586
742
  const entry = {
@@ -599,7 +755,10 @@ class DefaultRecipeRunner {
599
755
  ...(videoOptions.maxFps != null ? { maxFps: videoOptions.maxFps } : {}),
600
756
  };
601
757
  return {
602
- recording,
758
+ get releaseFailure() {
759
+ return releaseFailure;
760
+ },
761
+ recording: boundRecording(recording, outputPath, videoOptions),
603
762
  entry,
604
763
  outputPath,
605
764
  stagingRoot,
@@ -609,37 +768,65 @@ class DefaultRecipeRunner {
609
768
  }
610
769
  async #stopRunVideoRecording(runRecording, traceWriter, artifactWriter, runStartedAt) {
611
770
  const result = await runRecording.recording.stop();
771
+ const releaseFailure = runRecording.releaseFailure;
772
+ if (releaseFailure)
773
+ traceWriter.record({
774
+ nodeId: 'recipe-run:video',
775
+ action: 'record.video',
776
+ startedAt: runStartedAt.toISOString(),
777
+ endedAt: new Date().toISOString(),
778
+ durationMs: Date.now() - runStartedAt.getTime(),
779
+ ok: false,
780
+ cause_class: 'environment',
781
+ error_code: 'RECORDING_ADMISSION_RELEASE_FAILED',
782
+ error: releaseFailure,
783
+ });
784
+ if (!result.segments.length)
785
+ throw new Error('Recorder returned no original media segments.');
612
786
  const interruption = result.interruption
613
- ? runCaptureInterruption(result.interruption, runRecording.entry.path)
787
+ ? runCaptureInterruption(result.interruption, recordedVideoPath(result.segments.length - 1))
614
788
  : undefined;
615
- // Record the failure before the timeline, which is bound to the final trace.
616
789
  if (interruption)
617
790
  traceWriter.record(captureInterruptedTraceEntry(interruption, runStartedAt));
618
- await assertVideoOutputReady(runRecording.outputPath);
619
- await copyFileWithinRoots(runRecording.stagingRoot, runRecording.stagingRelativePath, runRecording.artifactsDir, runRecording.entry.path);
620
- if (result.timingEvidencePath && result.timing) {
621
- const evidencePath = `${runRecording.entry.path}.capture.json`;
622
- await copyFileWithinRoots(runRecording.stagingRoot, result.timingEvidencePath, runRecording.artifactsDir, evidencePath);
623
- artifactWriter.register({
624
- path: evidencePath,
625
- type: 'json',
626
- category: 'system',
627
- label: 'Native recording and snapshot timing',
628
- });
791
+ const videos = [];
792
+ for (const [index, segment] of result.segments.entries()) {
793
+ const source = normalizeRelativePath(segment.path);
794
+ await assertVideoOutputReady(path.join(runRecording.stagingRoot, source));
795
+ const relativePath = recordedVideoPath(index);
796
+ await copyFileWithinRoots(runRecording.stagingRoot, source, runRecording.artifactsDir, relativePath);
797
+ if (segment.timingEvidencePath && segment.timing) {
798
+ const evidencePath = `${relativePath}.capture.json`;
799
+ await copyFileWithinRoots(runRecording.stagingRoot, segment.timingEvidencePath, runRecording.artifactsDir, evidencePath);
800
+ artifactWriter.register({
801
+ path: evidencePath,
802
+ type: 'json',
803
+ category: 'system',
804
+ label: 'Native recording and snapshot timing',
805
+ });
806
+ }
807
+ const entry = {
808
+ ...runRecording.entry,
809
+ path: relativePath,
810
+ sha256: (await digestFileWithinRoot(runRecording.artifactsDir, relativePath)).slice(7),
811
+ ...(segment.recorder ? { recorder: segment.recorder } : {}),
812
+ ...(segment.gapBefore ? { metadata: { gapBefore: true } } : {}),
813
+ ...(result.interruption && index === result.segments.length - 1
814
+ ? { interruption: result.interruption }
815
+ : {}),
816
+ ...(!segment.timing
817
+ ? {
818
+ timelineUnavailableReason: segment.timingUnavailableReason ?? 'Recorder does not provide timeline alignment.',
819
+ }
820
+ : {}),
821
+ };
822
+ artifactWriter.register(entry);
823
+ videos.push({ entry, ...(segment.timing ? { timing: segment.timing } : {}) });
629
824
  }
630
825
  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
826
  return {
640
- entry,
827
+ videos,
641
828
  ...(interruption ? { interruption } : {}),
642
- ...(result.timing ? { timing: result.timing } : {}),
829
+ ...(releaseFailure ? { releaseFailure } : {}),
643
830
  };
644
831
  }
645
832
  #hudAction() {
@@ -733,6 +920,9 @@ class DefaultRecipeRunner {
733
920
  return 'Recipe run';
734
921
  }
735
922
  }
923
+ function recordedVideoPath(index) {
924
+ return index === 0 ? 'videos/recipe-run.mp4' : `videos/recipe-run-${index}.mp4`;
925
+ }
736
926
  function buildRecipeLibrarySummary(resolution) {
737
927
  return {
738
928
  sources: resolution.sources,
@@ -760,15 +950,36 @@ function rootRecipeRef(sourceRecipePath, recipes, digest) {
760
950
  }
761
951
  return rootResolutionRef(digest);
762
952
  }
763
- function normalizeVideoRecordingOptions(input) {
764
- if (!input)
953
+ function normalizeVideoRecordingOptions(input, needsUi, reason) {
954
+ if (input === undefined && !needsUi)
765
955
  return { mode: 'off' };
766
- if (input === true)
767
- return { mode: 'full-run' };
768
- if (typeof input === 'string')
769
- return normalizeVideoRecordingMode(input);
770
- const mode = input.mode ?? 'full-run';
771
- return { ...input, mode: normalizeVideoRecordingMode(mode).mode };
956
+ if (input === false || input === 'off' || (typeof input === 'object' && input.mode === 'off')) {
957
+ const offReason = (typeof input === 'object' ? input.reason : undefined) ?? reason;
958
+ if (needsUi && !offReason?.trim())
959
+ throw new Error('Disabling UI recording requires recordingReason.');
960
+ return { mode: 'off', ...(offReason ? { reason: offReason } : {}) };
961
+ }
962
+ const mode = typeof input === 'string'
963
+ ? input
964
+ : typeof input === 'object'
965
+ ? (input.mode ?? 'full-run')
966
+ : 'full-run';
967
+ const options = {
968
+ maxFps: DEFAULT_RECORDED_EVIDENCE_POLICY.maxFps,
969
+ maxSize: DEFAULT_RECORDED_EVIDENCE_POLICY.maxSize,
970
+ maxDurationMs: DEFAULT_RECORDED_EVIDENCE_POLICY.maxDurationMs,
971
+ maxBytes: DEFAULT_RECORDED_EVIDENCE_POLICY.maxBytes,
972
+ ...(typeof input === 'object' ? input : {}),
973
+ mode: normalizeVideoRecordingMode(mode).mode,
974
+ };
975
+ for (const key of ['maxFps', 'maxSize', 'maxDurationMs', 'maxBytes']) {
976
+ if (!Number.isFinite(options[key]) || options[key] <= 0)
977
+ throw new Error(`Recording ${key} must be finite and positive.`);
978
+ }
979
+ if (options.maxDurationMs > DEFAULT_RECORDED_EVIDENCE_POLICY.maxDurationMs ||
980
+ options.maxBytes > DEFAULT_RECORDED_EVIDENCE_POLICY.maxBytes)
981
+ throw new Error('Recording policy exceeds the execution duration/byte limits.');
982
+ return options;
772
983
  }
773
984
  function normalizeVideoRecordingMode(mode) {
774
985
  if (mode === 'off')