@farmslot/recipe-harness 0.2.0 → 0.2.2

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -1
  3. package/dist/cli/run-command.d.ts.map +1 -1
  4. package/dist/cli/run-command.js +29 -1
  5. package/dist/cli/run-command.js.map +1 -1
  6. package/dist/cli/support.d.ts +9 -0
  7. package/dist/cli/support.d.ts.map +1 -1
  8. package/dist/cli/support.js +24 -0
  9. package/dist/cli/support.js.map +1 -1
  10. package/dist/core/flows.d.ts +12 -1
  11. package/dist/core/flows.d.ts.map +1 -1
  12. package/dist/core/flows.js +53 -2
  13. package/dist/core/flows.js.map +1 -1
  14. package/dist/core/hud.d.ts.map +1 -1
  15. package/dist/core/hud.js +13 -18
  16. package/dist/core/hud.js.map +1 -1
  17. package/dist/core/recording-cleanup.d.ts +9 -0
  18. package/dist/core/recording-cleanup.d.ts.map +1 -0
  19. package/dist/core/recording-cleanup.js +24 -0
  20. package/dist/core/recording-cleanup.js.map +1 -0
  21. package/dist/core/runner.d.ts.map +1 -1
  22. package/dist/core/runner.js +355 -196
  23. package/dist/core/runner.js.map +1 -1
  24. package/dist/core/trace.d.ts +1 -1
  25. package/dist/core/trace.d.ts.map +1 -1
  26. package/dist/core/trace.js +2 -0
  27. package/dist/core/trace.js.map +1 -1
  28. package/dist/core/types.d.ts +65 -2
  29. package/dist/core/types.d.ts.map +1 -1
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/recording/capture-helper.d.ts +10 -0
  35. package/dist/recording/capture-helper.d.ts.map +1 -0
  36. package/dist/recording/capture-helper.js +194 -0
  37. package/dist/recording/capture-helper.js.map +1 -0
  38. package/dist/runtime/cdp.d.ts +1 -0
  39. package/dist/runtime/cdp.d.ts.map +1 -1
  40. package/dist/runtime/cdp.js +63 -10
  41. package/dist/runtime/cdp.js.map +1 -1
  42. package/package.json +7 -6
  43. package/CHANGELOG.md +0 -15
@@ -1,12 +1,14 @@
1
- import { mkdir } from 'node:fs/promises';
1
+ import { mkdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { getRecipeActionManifestActionNames, validateRecipeArtifactPackage, } from '@farmslot/protocol';
4
4
  import { JsonArtifactWriter, JsonSummaryWriter, JsonTraceWriter } from '../node/writers.js';
5
+ import { createCaptureHelperVideoRecorder, errorMessage, manifestTarget, } from '../recording/capture-helper.js';
5
6
  import { collectFlows, executeInlineFlowCall } from './flows.js';
6
7
  import { extractWorkflowGraph, normalizePreconditionResult, resolveNextNode, } from './graph.js';
7
8
  import { buildHudNode } from './hud.js';
8
9
  import { isRecord, normalizeRelativePath, readJsonFile } from './json.js';
9
10
  import { evaluateNodeGate } from './predicates.js';
11
+ import { cleanupAbortedRunVideoRecording, removePartialRunVideoOutput, } from './recording-cleanup.js';
10
12
  import { recordSyntheticFailure, traceNodeMetadata } from './trace.js';
11
13
  import { assertManifestIsValid, assertRecipeMatchesManifest } from './validation.js';
12
14
  const HARNESS_VERSION = '0.1.0';
@@ -55,7 +57,7 @@ export function createRecipeRunner(options) {
55
57
  throw new Error(`Manifest action ${action} has no registered adapter.`);
56
58
  }
57
59
  }
58
- return new DefaultRecipeRunner(options.actionManifest, adapterMap, preconditionMap, options.logger ?? noopLogger, options.hud, options.runner);
60
+ return new DefaultRecipeRunner(options.actionManifest, adapterMap, preconditionMap, options.logger ?? noopLogger, options.hud, options.runner, options.recording);
59
61
  }
60
62
  class DefaultRecipeRunner {
61
63
  #actionManifest;
@@ -64,13 +66,15 @@ class DefaultRecipeRunner {
64
66
  #logger;
65
67
  #hud;
66
68
  #runnerProvenance;
67
- constructor(actionManifest, adapters, preconditions, logger, hud, runnerProvenance) {
69
+ #recording;
70
+ constructor(actionManifest, adapters, preconditions, logger, hud, runnerProvenance, recording) {
68
71
  this.#actionManifest = actionManifest;
69
72
  this.#adapters = adapters;
70
73
  this.#preconditions = preconditions;
71
74
  this.#logger = logger;
72
75
  this.#hud = hud;
73
76
  this.#runnerProvenance = runnerProvenance;
77
+ this.#recording = recording;
74
78
  }
75
79
  async run(request) {
76
80
  if (!request.recipePath && request.recipeDocument == null) {
@@ -100,227 +104,281 @@ class DefaultRecipeRunner {
100
104
  const visited = new Set();
101
105
  let transitionCount = 0;
102
106
  const maxTransitions = Object.keys(graph.nodes).length * 3;
103
- const preconditionStatus = await this.#runPreconditions({
104
- graph,
105
- recipe,
106
- projectRoot,
107
- artifactsDir,
108
- request,
109
- outputs,
110
- artifactWriter,
111
- traceWriter,
112
- });
113
- if (preconditionStatus === 'fail') {
114
- status = 'fail';
115
- currentNodeId = undefined;
116
- }
117
- while (currentNodeId) {
118
- const activeNodeId = currentNodeId;
119
- transitionCount += 1;
120
- if (transitionCount > maxTransitions) {
121
- const error = new Error('Recipe graph exceeded its maximum transition count.');
122
- recordSyntheticFailure(traceWriter, activeNodeId, error);
123
- status = 'fail';
124
- break;
107
+ const videoOptions = normalizeVideoRecordingOptions(request.recordVideo);
108
+ const videoRecorder = videoOptions.mode !== 'off' ? this.#videoRecorder() : undefined;
109
+ let runRecording;
110
+ if (videoRecorder) {
111
+ try {
112
+ runRecording = await this.#startRunVideoRecording({
113
+ recorder: videoRecorder,
114
+ videoOptions,
115
+ recipe,
116
+ projectRoot,
117
+ artifactsDir,
118
+ env: request.env ?? {},
119
+ });
125
120
  }
126
- visited.add(activeNodeId);
127
- const node = graph.nodes[activeNodeId];
128
- if (!node) {
129
- const error = new Error(`Recipe node ${activeNodeId} does not exist.`);
130
- recordSyntheticFailure(traceWriter, activeNodeId, error);
121
+ catch (error) {
122
+ const message = errorMessage(error);
123
+ traceWriter.record({
124
+ nodeId: 'recipe-run:video',
125
+ action: 'record.video',
126
+ startedAt: startedAt.toISOString(),
127
+ endedAt: new Date().toISOString(),
128
+ durationMs: Date.now() - startedAt.getTime(),
129
+ ok: false,
130
+ error: message,
131
+ });
132
+ this.#logger.error(`record.video start failed: ${message}`);
131
133
  status = 'fail';
132
- break;
134
+ currentNodeId = undefined;
133
135
  }
134
- const action = String(node.action);
135
- const adapter = action === 'call' ? undefined : this.#adapters.get(action);
136
- if (action !== 'call' && !adapter) {
137
- const error = new Error(`No adapter registered for action ${action}.`);
138
- recordSyntheticFailure(traceWriter, activeNodeId, error, action);
139
- status = 'fail';
140
- break;
136
+ }
137
+ try {
138
+ if (currentNodeId) {
139
+ const preconditionStatus = await this.#runPreconditions({
140
+ graph,
141
+ recipe,
142
+ projectRoot,
143
+ artifactsDir,
144
+ request,
145
+ outputs,
146
+ artifactWriter,
147
+ traceWriter,
148
+ });
149
+ if (preconditionStatus === 'fail') {
150
+ status = 'fail';
151
+ currentNodeId = undefined;
152
+ }
141
153
  }
142
- const nodeStartedAt = new Date();
143
- const context = {
144
- nodeId: activeNodeId,
145
- recipe,
146
- projectRoot,
147
- artifactsDir,
148
- env: request.env ?? {},
149
- outputs,
150
- getOutput(nodeId) {
151
- if (!outputs.has(nodeId))
152
- throw new Error(`No output recorded for node ${nodeId}.`);
153
- return outputs.get(nodeId);
154
- },
155
- resolveProjectPath(relativePath) {
156
- return path.join(projectRoot, normalizeRelativePath(relativePath));
157
- },
158
- resolveArtifactPath(relativePath) {
159
- return path.join(artifactsDir, normalizeRelativePath(relativePath));
160
- },
161
- registerArtifact(entry) {
162
- artifactWriter.register(entry);
163
- },
164
- logger: this.#logger,
165
- };
166
- try {
167
- const gate = evaluateNodeGate(node, context.outputs);
168
- if (!gate.run) {
169
- const next = resolveNextNode(node, {});
154
+ while (currentNodeId) {
155
+ const activeNodeId = currentNodeId;
156
+ transitionCount += 1;
157
+ if (transitionCount > maxTransitions) {
158
+ const error = new Error('Recipe graph exceeded its maximum transition count.');
159
+ recordSyntheticFailure(traceWriter, activeNodeId, error);
160
+ status = 'fail';
161
+ break;
162
+ }
163
+ visited.add(activeNodeId);
164
+ const node = graph.nodes[activeNodeId];
165
+ if (!node) {
166
+ const error = new Error(`Recipe node ${activeNodeId} does not exist.`);
167
+ recordSyntheticFailure(traceWriter, activeNodeId, error);
168
+ status = 'fail';
169
+ break;
170
+ }
171
+ const action = String(node.action);
172
+ const adapter = action === 'call' ? undefined : this.#adapters.get(action);
173
+ if (action !== 'call' && !adapter) {
174
+ const error = new Error(`No adapter registered for action ${action}.`);
175
+ recordSyntheticFailure(traceWriter, activeNodeId, error, action);
176
+ status = 'fail';
177
+ break;
178
+ }
179
+ const nodeStartedAt = new Date();
180
+ const context = this.#createExecutionContext({
181
+ nodeId: activeNodeId,
182
+ recipe,
183
+ projectRoot,
184
+ artifactsDir,
185
+ env: request.env ?? {},
186
+ outputs,
187
+ artifactWriter,
188
+ });
189
+ try {
190
+ const gate = evaluateNodeGate(node, context.outputs);
191
+ if (!gate.run) {
192
+ const next = resolveNextNode(node, {});
193
+ traceWriter.record({
194
+ nodeId: activeNodeId,
195
+ action,
196
+ ...traceNodeMetadata(node),
197
+ startedAt: nodeStartedAt.toISOString(),
198
+ endedAt: new Date().toISOString(),
199
+ durationMs: Date.now() - nodeStartedAt.getTime(),
200
+ ok: true,
201
+ next,
202
+ output: { skipped: true, reason: gate.reason },
203
+ });
204
+ currentNodeId = next;
205
+ continue;
206
+ }
207
+ const hudStarted = await this.#publishHudProgressOrRecord(traceWriter, 'running', {
208
+ nodeId: activeNodeId,
209
+ action,
210
+ node,
211
+ recipe,
212
+ index: transitionCount,
213
+ total: Object.keys(graph.nodes).length,
214
+ context,
215
+ });
216
+ if (!hudStarted) {
217
+ status = 'fail';
218
+ break;
219
+ }
220
+ const result = action === 'call'
221
+ ? await executeInlineFlowCall({
222
+ callNodeId: activeNodeId,
223
+ node,
224
+ context,
225
+ flowCatalog,
226
+ adapters: this.#adapters,
227
+ traceWriter,
228
+ callStack: [],
229
+ maxCallDepth: DEFAULT_MAX_FLOW_CALL_DEPTH,
230
+ publishHudProgress: (hudStatus, flowEvent) => this.#publishHudProgressOrRecord(traceWriter, hudStatus, {
231
+ ...flowEvent,
232
+ recipe,
233
+ }),
234
+ })
235
+ : await adapter.execute(node, context);
236
+ if (result.output !== undefined)
237
+ outputs.set(activeNodeId, result.output);
238
+ for (const artifact of result.artifacts ?? [])
239
+ artifactWriter.register(artifact);
240
+ const next = resolveNextNode(node, result);
170
241
  traceWriter.record({
171
242
  nodeId: activeNodeId,
172
243
  action,
173
- ...traceNodeMetadata(node),
244
+ ...traceNodeMetadata(node, result),
174
245
  startedAt: nodeStartedAt.toISOString(),
175
246
  endedAt: new Date().toISOString(),
176
247
  durationMs: Date.now() - nodeStartedAt.getTime(),
177
248
  ok: true,
178
249
  next,
179
- output: { skipped: true, reason: gate.reason },
250
+ status: result.status,
251
+ output: result.output,
180
252
  });
253
+ const hudCompleted = await this.#publishHudProgressOrRecord(traceWriter, result.status === 'fail' ? 'fail' : 'pass', {
254
+ nodeId: activeNodeId,
255
+ action,
256
+ node,
257
+ recipe,
258
+ index: transitionCount,
259
+ total: Object.keys(graph.nodes).length,
260
+ context,
261
+ });
262
+ if (!hudCompleted) {
263
+ status = 'fail';
264
+ break;
265
+ }
266
+ if (result.status) {
267
+ if (runningTeardown) {
268
+ status = mainStatus === 'fail' ? 'fail' : result.status;
269
+ break;
270
+ }
271
+ mainStatus = result.status;
272
+ if (graph.teardownEntry) {
273
+ runningTeardown = true;
274
+ currentNodeId = graph.teardownEntry;
275
+ continue;
276
+ }
277
+ status = result.status;
278
+ break;
279
+ }
181
280
  currentNodeId = next;
182
- continue;
183
281
  }
184
- const hudStarted = await this.#publishHudProgressOrRecord(traceWriter, 'running', {
185
- nodeId: activeNodeId,
186
- action,
187
- node,
188
- recipe,
189
- index: transitionCount,
190
- total: Object.keys(graph.nodes).length,
191
- context,
192
- });
193
- if (!hudStarted) {
194
- status = 'fail';
195
- break;
196
- }
197
- const result = action === 'call'
198
- ? await executeInlineFlowCall({
199
- callNodeId: activeNodeId,
282
+ catch (error) {
283
+ const message = error instanceof Error ? error.message : String(error);
284
+ traceWriter.record({
285
+ nodeId: activeNodeId,
286
+ action,
287
+ ...traceNodeMetadata(node),
288
+ startedAt: nodeStartedAt.toISOString(),
289
+ endedAt: new Date().toISOString(),
290
+ durationMs: Date.now() - nodeStartedAt.getTime(),
291
+ ok: false,
292
+ error: message,
293
+ });
294
+ this.#logger.error(message);
295
+ await this.#publishHudProgressOrRecord(traceWriter, 'fail', {
296
+ nodeId: activeNodeId,
297
+ action,
200
298
  node,
299
+ recipe,
300
+ index: transitionCount,
301
+ total: Object.keys(graph.nodes).length,
201
302
  context,
202
- flowCatalog,
203
- adapters: this.#adapters,
204
- traceWriter,
205
- callStack: [],
206
- maxCallDepth: DEFAULT_MAX_FLOW_CALL_DEPTH,
207
- })
208
- : await adapter.execute(node, context);
209
- if (result.output !== undefined)
210
- outputs.set(activeNodeId, result.output);
211
- for (const artifact of result.artifacts ?? [])
212
- artifactWriter.register(artifact);
213
- const next = resolveNextNode(node, result);
214
- traceWriter.record({
215
- nodeId: activeNodeId,
216
- action,
217
- ...traceNodeMetadata(node, result),
218
- startedAt: nodeStartedAt.toISOString(),
219
- endedAt: new Date().toISOString(),
220
- durationMs: Date.now() - nodeStartedAt.getTime(),
221
- ok: true,
222
- next,
223
- status: result.status,
224
- output: result.output,
225
- });
226
- const hudCompleted = await this.#publishHudProgressOrRecord(traceWriter, result.status === 'fail' ? 'fail' : 'pass', {
227
- nodeId: activeNodeId,
228
- action,
229
- node,
230
- recipe,
231
- index: transitionCount,
232
- total: Object.keys(graph.nodes).length,
233
- context,
234
- });
235
- if (!hudCompleted) {
303
+ error: message,
304
+ });
236
305
  status = 'fail';
237
- break;
238
- }
239
- if (result.status) {
240
- if (runningTeardown) {
241
- status = mainStatus === 'fail' ? 'fail' : result.status;
242
- break;
243
- }
244
- mainStatus = result.status;
245
- if (graph.teardownEntry) {
306
+ if (!runningTeardown && graph.teardownEntry) {
307
+ mainStatus = 'fail';
246
308
  runningTeardown = true;
247
309
  currentNodeId = graph.teardownEntry;
248
310
  continue;
249
311
  }
250
- status = result.status;
251
312
  break;
252
313
  }
253
- currentNodeId = next;
254
314
  }
255
- catch (error) {
256
- const message = error instanceof Error ? error.message : String(error);
257
- traceWriter.record({
258
- nodeId: activeNodeId,
259
- action,
260
- ...traceNodeMetadata(node),
261
- startedAt: nodeStartedAt.toISOString(),
262
- endedAt: new Date().toISOString(),
263
- durationMs: Date.now() - nodeStartedAt.getTime(),
264
- ok: false,
265
- error: message,
266
- });
267
- this.#logger.error(message);
268
- await this.#publishHudProgressOrRecord(traceWriter, 'fail', {
269
- nodeId: activeNodeId,
270
- action,
271
- node,
272
- recipe,
273
- index: transitionCount,
274
- total: Object.keys(graph.nodes).length,
275
- context,
276
- error: message,
277
- });
278
- status = 'fail';
279
- if (!runningTeardown && graph.teardownEntry) {
280
- mainStatus = 'fail';
281
- runningTeardown = true;
282
- currentNodeId = graph.teardownEntry;
283
- continue;
315
+ artifactWriter.register({
316
+ path: 'summary.json',
317
+ type: 'summary',
318
+ label: 'Run summary',
319
+ category: 'system',
320
+ });
321
+ artifactWriter.register({
322
+ path: 'trace.json',
323
+ type: 'trace',
324
+ label: 'Execution trace',
325
+ category: 'system',
326
+ });
327
+ if (status === 'pass') {
328
+ const runHudStartedAt = new Date();
329
+ try {
330
+ await this.#publishRunHud(status, {
331
+ recipe,
332
+ projectRoot,
333
+ artifactsDir,
334
+ env: request.env ?? {},
335
+ outputs,
336
+ });
337
+ }
338
+ catch (error) {
339
+ const message = error instanceof Error ? error.message : String(error);
340
+ traceWriter.record({
341
+ nodeId: 'recipe-complete:hud',
342
+ action: 'app.hud',
343
+ startedAt: runHudStartedAt.toISOString(),
344
+ endedAt: new Date().toISOString(),
345
+ durationMs: Date.now() - runHudStartedAt.getTime(),
346
+ ok: false,
347
+ error: message,
348
+ });
349
+ this.#logger.error(`app.hud complete update failed: ${message}`);
350
+ status = 'fail';
284
351
  }
285
- break;
286
352
  }
287
- }
288
- artifactWriter.register({
289
- path: 'summary.json',
290
- type: 'summary',
291
- label: 'Run summary',
292
- category: 'system',
293
- });
294
- artifactWriter.register({
295
- path: 'trace.json',
296
- type: 'trace',
297
- label: 'Execution trace',
298
- category: 'system',
299
- });
300
- if (status === 'pass') {
301
- const runHudStartedAt = new Date();
302
- try {
303
- await this.#publishRunHud(status, {
304
- recipe,
305
- projectRoot,
306
- artifactsDir,
307
- env: request.env ?? {},
308
- outputs,
309
- });
353
+ if (runRecording) {
354
+ const recordingToStop = runRecording;
355
+ runRecording = undefined;
356
+ try {
357
+ const videoArtifact = await this.#stopRunVideoRecording(recordingToStop);
358
+ artifactWriter.register(videoArtifact);
359
+ }
360
+ catch (error) {
361
+ const message = errorMessage(error);
362
+ await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
363
+ traceWriter.record({
364
+ nodeId: 'recipe-run:video',
365
+ action: 'record.video',
366
+ startedAt: startedAt.toISOString(),
367
+ endedAt: new Date().toISOString(),
368
+ durationMs: Date.now() - startedAt.getTime(),
369
+ ok: false,
370
+ error: message,
371
+ });
372
+ this.#logger.error(`record.video failed: ${message}`);
373
+ status = 'fail';
374
+ }
310
375
  }
311
- catch (error) {
312
- const message = error instanceof Error ? error.message : String(error);
313
- traceWriter.record({
314
- nodeId: 'recipe-complete:hud',
315
- action: 'app.hud',
316
- startedAt: runHudStartedAt.toISOString(),
317
- endedAt: new Date().toISOString(),
318
- durationMs: Date.now() - runHudStartedAt.getTime(),
319
- ok: false,
320
- error: message,
321
- });
322
- this.#logger.error(`app.hud complete update failed: ${message}`);
323
- status = 'fail';
376
+ }
377
+ finally {
378
+ if (runRecording) {
379
+ const recordingToCleanup = runRecording;
380
+ runRecording = undefined;
381
+ await cleanupAbortedRunVideoRecording(recordingToCleanup, this.#logger);
324
382
  }
325
383
  }
326
384
  const endedAt = new Date();
@@ -444,6 +502,76 @@ class DefaultRecipeRunner {
444
502
  logger: this.#logger,
445
503
  };
446
504
  }
505
+ #videoRecorder() {
506
+ return this.#recording?.videoRecorder ?? createCaptureHelperVideoRecorder();
507
+ }
508
+ async #startRunVideoRecording({ recorder, videoOptions, recipe, projectRoot, artifactsDir, env, }) {
509
+ const doctor = await recorder.doctor?.();
510
+ if (doctor && !doctor.ok) {
511
+ throw new Error(`${recorder.name} doctor ${doctor.code}: ${doctor.message}${doctor.suggestedFix ? ` Suggested fix: ${doctor.suggestedFix}` : ''}`);
512
+ }
513
+ const node = {
514
+ action: 'record.video',
515
+ intent: 'Record the recipe run when motion proof is useful',
516
+ };
517
+ const target = videoOptions.target ??
518
+ (await this.#recording?.targetProvider?.resolveRecordingTarget({
519
+ nodeId: 'recipe-run',
520
+ node,
521
+ recipe,
522
+ projectRoot,
523
+ artifactsDir,
524
+ env,
525
+ scope: 'run',
526
+ }));
527
+ if (!target) {
528
+ throw new Error('--record-video requires a recording target. Provide a project RecordingTargetProvider or CLI target flags.');
529
+ }
530
+ const relativePath = 'videos/recipe-run.mp4';
531
+ const outputPath = path.join(artifactsDir, relativePath);
532
+ await mkdir(path.dirname(outputPath), { recursive: true });
533
+ let recording;
534
+ try {
535
+ recording = await recorder.start({
536
+ outputPath,
537
+ target,
538
+ maxFps: videoOptions.maxFps,
539
+ maxSize: videoOptions.maxSize,
540
+ nodeId: 'recipe-run',
541
+ record: 'full_run',
542
+ });
543
+ }
544
+ catch (error) {
545
+ await removePartialRunVideoOutput(outputPath, this.#logger);
546
+ throw error;
547
+ }
548
+ const entry = {
549
+ path: relativePath,
550
+ type: 'video',
551
+ mimeType: 'video/mp4',
552
+ category: 'proof',
553
+ label: 'Recipe run video',
554
+ record: 'full_run',
555
+ recorder: {
556
+ name: recorder.name,
557
+ ...(recorder.version ? { version: recorder.version } : {}),
558
+ ...(recorder.platform ? { platform: recorder.platform } : {}),
559
+ target: manifestTarget(target),
560
+ },
561
+ ...(videoOptions.maxFps != null ? { maxFps: videoOptions.maxFps } : {}),
562
+ };
563
+ return { recording, entry, outputPath };
564
+ }
565
+ async #stopRunVideoRecording(runRecording) {
566
+ const result = await runRecording.recording.stop();
567
+ await assertVideoOutputReady(runRecording.outputPath);
568
+ return result.recorder
569
+ ? {
570
+ ...runRecording.entry,
571
+ recorder: result.recorder,
572
+ }
573
+ : runRecording.entry;
574
+ }
447
575
  #hudAction() {
448
576
  if (this.#hud === false || this.#hud?.enabled === false)
449
577
  return undefined;
@@ -529,4 +657,35 @@ class DefaultRecipeRunner {
529
657
  return 'Recipe run';
530
658
  }
531
659
  }
660
+ function normalizeVideoRecordingOptions(input) {
661
+ if (!input)
662
+ return { mode: 'off' };
663
+ if (input === true)
664
+ return { mode: 'full-run' };
665
+ if (typeof input === 'string')
666
+ return normalizeVideoRecordingMode(input);
667
+ const mode = input.mode ?? 'full-run';
668
+ return { ...input, mode: normalizeVideoRecordingMode(mode).mode };
669
+ }
670
+ function normalizeVideoRecordingMode(mode) {
671
+ if (mode === 'off')
672
+ return { mode: 'off' };
673
+ if (mode === 'full-run')
674
+ return { mode: 'full-run' };
675
+ if (mode === 'proof-window' || mode === 'proof_window') {
676
+ throw new Error('recordVideo proof-window mode is reserved for future focused clips; use full-run for phase 1.');
677
+ }
678
+ throw new Error(`recordVideo mode must be full-run or off, got ${JSON.stringify(mode)}.`);
679
+ }
680
+ async function assertVideoOutputReady(outputPath) {
681
+ let stats;
682
+ try {
683
+ stats = await stat(outputPath);
684
+ }
685
+ catch {
686
+ throw new Error(`Recording output is missing: ${outputPath}`);
687
+ }
688
+ if (stats.size <= 0)
689
+ throw new Error(`Recording output is empty: ${outputPath}`);
690
+ }
532
691
  //# sourceMappingURL=runner.js.map