@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/adapters/ui.d.ts +2 -0
  3. package/dist/adapters/ui.d.ts.map +1 -1
  4. package/dist/adapters/ui.js +79 -37
  5. package/dist/adapters/ui.js.map +1 -1
  6. package/dist/cli/support.d.ts.map +1 -1
  7. package/dist/cli/support.js +5 -1
  8. package/dist/cli/support.js.map +1 -1
  9. package/dist/core/execution.d.ts +3 -0
  10. package/dist/core/execution.d.ts.map +1 -1
  11. package/dist/core/execution.js +60 -3
  12. package/dist/core/execution.js.map +1 -1
  13. package/dist/core/graph.d.ts +1 -0
  14. package/dist/core/graph.d.ts.map +1 -1
  15. package/dist/core/graph.js +2 -2
  16. package/dist/core/graph.js.map +1 -1
  17. package/dist/core/hud.d.ts.map +1 -1
  18. package/dist/core/hud.js +3 -3
  19. package/dist/core/hud.js.map +1 -1
  20. package/dist/core/invocation.d.ts +4 -1
  21. package/dist/core/invocation.d.ts.map +1 -1
  22. package/dist/core/invocation.js +3 -1
  23. package/dist/core/invocation.js.map +1 -1
  24. package/dist/core/recording-limits.d.ts +4 -0
  25. package/dist/core/recording-limits.d.ts.map +1 -0
  26. package/dist/core/recording-limits.js +74 -0
  27. package/dist/core/recording-limits.js.map +1 -0
  28. package/dist/core/recording-segments.d.ts +4 -0
  29. package/dist/core/recording-segments.d.ts.map +1 -0
  30. package/dist/core/recording-segments.js +101 -0
  31. package/dist/core/recording-segments.js.map +1 -0
  32. package/dist/core/runner.d.ts.map +1 -1
  33. package/dist/core/runner.js +335 -108
  34. package/dist/core/runner.js.map +1 -1
  35. package/dist/core/types.d.ts +37 -22
  36. package/dist/core/types.d.ts.map +1 -1
  37. package/dist/evidence/assertions.d.ts +50 -0
  38. package/dist/evidence/assertions.d.ts.map +1 -0
  39. package/dist/evidence/assertions.js +156 -0
  40. package/dist/evidence/assertions.js.map +1 -0
  41. package/dist/evidence/claims.d.ts +15 -0
  42. package/dist/evidence/claims.d.ts.map +1 -0
  43. package/dist/evidence/claims.js +201 -0
  44. package/dist/evidence/claims.js.map +1 -0
  45. package/dist/evidence/index.d.ts +4 -0
  46. package/dist/evidence/index.d.ts.map +1 -0
  47. package/dist/evidence/index.js +3 -0
  48. package/dist/evidence/index.js.map +1 -0
  49. package/dist/evidence/moments.d.ts +8 -0
  50. package/dist/evidence/moments.d.ts.map +1 -0
  51. package/dist/evidence/moments.js +26 -0
  52. package/dist/evidence/moments.js.map +1 -0
  53. package/dist/evidence/package.d.ts +4 -0
  54. package/dist/evidence/package.d.ts.map +1 -0
  55. package/dist/evidence/package.js +140 -0
  56. package/dist/evidence/package.js.map +1 -0
  57. package/dist/evidence/plan.d.ts +9 -0
  58. package/dist/evidence/plan.d.ts.map +1 -0
  59. package/dist/evidence/plan.js +87 -0
  60. package/dist/evidence/plan.js.map +1 -0
  61. package/dist/evidence/read-package.d.ts +14 -0
  62. package/dist/evidence/read-package.d.ts.map +1 -0
  63. package/dist/evidence/read-package.js +153 -0
  64. package/dist/evidence/read-package.js.map +1 -0
  65. package/dist/evidence/stills.d.ts +5 -0
  66. package/dist/evidence/stills.d.ts.map +1 -0
  67. package/dist/evidence/stills.js +58 -0
  68. package/dist/evidence/stills.js.map +1 -0
  69. package/dist/file-digest.d.ts +3 -0
  70. package/dist/file-digest.d.ts.map +1 -0
  71. package/dist/file-digest.js +4 -0
  72. package/dist/file-digest.js.map +1 -0
  73. package/dist/index.d.ts +5 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +5 -0
  76. package/dist/index.js.map +1 -1
  77. package/dist/recording/android-mirror.d.ts.map +1 -1
  78. package/dist/recording/android-mirror.js +17 -8
  79. package/dist/recording/android-mirror.js.map +1 -1
  80. package/dist/recording/capture-helper-timing.d.ts +2 -2
  81. package/dist/recording/capture-helper-timing.d.ts.map +1 -1
  82. package/dist/recording/capture-helper.d.ts.map +1 -1
  83. package/dist/recording/capture-helper.js +24 -12
  84. package/dist/recording/capture-helper.js.map +1 -1
  85. package/dist/recording/cdp-video-recorder.d.ts +2 -0
  86. package/dist/recording/cdp-video-recorder.d.ts.map +1 -1
  87. package/dist/recording/cdp-video-recorder.js +160 -50
  88. package/dist/recording/cdp-video-recorder.js.map +1 -1
  89. package/dist/recording/timeline.d.ts +2 -2
  90. package/dist/recording/timeline.d.ts.map +1 -1
  91. package/dist/runtime/cdp.d.ts +1 -0
  92. package/dist/runtime/cdp.d.ts.map +1 -1
  93. package/dist/runtime/cdp.js +71 -16
  94. package/dist/runtime/cdp.js.map +1 -1
  95. 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 {
@@ -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
- traceWriter.record({
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 (videoTimeline) {
414
- 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) {
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 = 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)])),
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 { ...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
+ };
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 target = videoOptions.target ??
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 (!target) {
564
- 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.');
565
692
  }
566
- 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);
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
- 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, {
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
- recording,
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, runRecording.entry.path)
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
- 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
- });
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
- entry,
843
+ videos,
641
844
  ...(interruption ? { interruption } : {}),
642
- ...(result.timing ? { timing: result.timing } : {}),
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 (!input)
969
+ function normalizeVideoRecordingOptions(input, needsUi, reason) {
970
+ if (input === undefined && !needsUi)
765
971
  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 };
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')