@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.
- package/CHANGELOG.md +17 -0
- package/dist/adapters/core.d.ts.map +1 -1
- package/dist/adapters/core.js +60 -55
- package/dist/adapters/core.js.map +1 -1
- package/dist/adapters/ui.d.ts +2 -0
- package/dist/adapters/ui.d.ts.map +1 -1
- package/dist/adapters/ui.js +79 -37
- package/dist/adapters/ui.js.map +1 -1
- package/dist/cli/support.d.ts.map +1 -1
- package/dist/cli/support.js +13 -9
- package/dist/cli/support.js.map +1 -1
- package/dist/core/assertions.d.ts +16 -0
- package/dist/core/assertions.d.ts.map +1 -0
- package/dist/core/assertions.js +59 -0
- package/dist/core/assertions.js.map +1 -0
- package/dist/core/compose.d.ts +3 -2
- package/dist/core/compose.d.ts.map +1 -1
- package/dist/core/compose.js +23 -7
- package/dist/core/compose.js.map +1 -1
- package/dist/core/execution.d.ts +3 -0
- package/dist/core/execution.d.ts.map +1 -1
- package/dist/core/execution.js +60 -3
- package/dist/core/execution.js.map +1 -1
- package/dist/core/graph.d.ts +1 -0
- package/dist/core/graph.d.ts.map +1 -1
- package/dist/core/graph.js +2 -2
- package/dist/core/graph.js.map +1 -1
- package/dist/core/hud.d.ts.map +1 -1
- package/dist/core/hud.js +3 -3
- package/dist/core/hud.js.map +1 -1
- package/dist/core/invocation.d.ts +4 -1
- package/dist/core/invocation.d.ts.map +1 -1
- package/dist/core/invocation.js +3 -1
- package/dist/core/invocation.js.map +1 -1
- package/dist/core/json.d.ts +1 -0
- package/dist/core/json.d.ts.map +1 -1
- package/dist/core/json.js +7 -9
- package/dist/core/json.js.map +1 -1
- package/dist/core/recording-limits.d.ts +4 -0
- package/dist/core/recording-limits.d.ts.map +1 -0
- package/dist/core/recording-limits.js +74 -0
- package/dist/core/recording-limits.js.map +1 -0
- package/dist/core/recording-segments.d.ts +4 -0
- package/dist/core/recording-segments.d.ts.map +1 -0
- package/dist/core/recording-segments.js +101 -0
- package/dist/core/recording-segments.js.map +1 -0
- package/dist/core/runner.d.ts.map +1 -1
- package/dist/core/runner.js +340 -129
- package/dist/core/runner.js.map +1 -1
- package/dist/core/trust.d.ts.map +1 -1
- package/dist/core/trust.js +7 -0
- package/dist/core/trust.js.map +1 -1
- package/dist/core/types.d.ts +37 -22
- package/dist/core/types.d.ts.map +1 -1
- package/dist/evidence/assertions.d.ts +50 -0
- package/dist/evidence/assertions.d.ts.map +1 -0
- package/dist/evidence/assertions.js +156 -0
- package/dist/evidence/assertions.js.map +1 -0
- package/dist/evidence/claims.d.ts +15 -0
- package/dist/evidence/claims.d.ts.map +1 -0
- package/dist/evidence/claims.js +201 -0
- package/dist/evidence/claims.js.map +1 -0
- package/dist/evidence/index.d.ts +4 -0
- package/dist/evidence/index.d.ts.map +1 -0
- package/dist/evidence/index.js +3 -0
- package/dist/evidence/index.js.map +1 -0
- package/dist/evidence/moments.d.ts +8 -0
- package/dist/evidence/moments.d.ts.map +1 -0
- package/dist/evidence/moments.js +26 -0
- package/dist/evidence/moments.js.map +1 -0
- package/dist/evidence/package.d.ts +4 -0
- package/dist/evidence/package.d.ts.map +1 -0
- package/dist/evidence/package.js +140 -0
- package/dist/evidence/package.js.map +1 -0
- package/dist/evidence/plan.d.ts +9 -0
- package/dist/evidence/plan.d.ts.map +1 -0
- package/dist/evidence/plan.js +87 -0
- package/dist/evidence/plan.js.map +1 -0
- package/dist/evidence/read-package.d.ts +14 -0
- package/dist/evidence/read-package.d.ts.map +1 -0
- package/dist/evidence/read-package.js +153 -0
- package/dist/evidence/read-package.js.map +1 -0
- package/dist/evidence/stills.d.ts +5 -0
- package/dist/evidence/stills.d.ts.map +1 -0
- package/dist/evidence/stills.js +58 -0
- package/dist/evidence/stills.js.map +1 -0
- package/dist/file-digest.d.ts +3 -0
- package/dist/file-digest.d.ts.map +1 -0
- package/dist/file-digest.js +4 -0
- package/dist/file-digest.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/recording/android-mirror.d.ts.map +1 -1
- package/dist/recording/android-mirror.js +17 -8
- package/dist/recording/android-mirror.js.map +1 -1
- package/dist/recording/capture-helper-timing.d.ts +2 -2
- package/dist/recording/capture-helper-timing.d.ts.map +1 -1
- package/dist/recording/capture-helper.d.ts.map +1 -1
- package/dist/recording/capture-helper.js +24 -12
- package/dist/recording/capture-helper.js.map +1 -1
- package/dist/recording/cdp-video-recorder.d.ts +2 -0
- package/dist/recording/cdp-video-recorder.d.ts.map +1 -1
- package/dist/recording/cdp-video-recorder.js +160 -50
- package/dist/recording/cdp-video-recorder.js.map +1 -1
- package/dist/recording/timeline.d.ts +2 -2
- package/dist/recording/timeline.d.ts.map +1 -1
- package/dist/runtime/cdp.d.ts +1 -0
- package/dist/runtime/cdp.d.ts.map +1 -1
- package/dist/runtime/cdp.js +105 -28
- package/dist/runtime/cdp.js.map +1 -1
- package/package.json +8 -3
package/dist/core/runner.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
|
-
import { digestRecipeDocument, getRecipeActionManifestActionNames,
|
|
5
|
+
import { DEFAULT_RECORDED_EVIDENCE_POLICY, digestRecipeDocument, getRecipeActionManifestActionNames, hasUnresolvedRecipeFailure, validateRecipeRecordingTarget, } from '@farmslot/protocol';
|
|
6
|
+
import { proofDocument } from '../evidence/assertions.js';
|
|
7
|
+
import { deriveRecipeClaims } from '../evidence/claims.js';
|
|
8
|
+
import { validateRecordedEvidencePackage } from '../evidence/package.js';
|
|
9
|
+
import { planRecipeEvidence } from '../evidence/plan.js';
|
|
10
|
+
import { materializeEvidenceStill } from '../evidence/stills.js';
|
|
5
11
|
import { JsonArtifactWriter, JsonSummaryWriter, JsonTraceWriter, summarizeTraceCounts, } from '../node/writers.js';
|
|
6
12
|
import { createCaptureHelperVideoRecorder, errorMessage, manifestTarget, } from '../recording/capture-helper.js';
|
|
7
13
|
import { CAPTURE_INTERRUPTED, captureInterruptedTraceEntry, runCaptureInterruption, } from '../recording/capture-helper-interruption.js';
|
|
@@ -17,8 +23,10 @@ import { isRecord, normalizeRelativePath, readJsonFile } from './json.js';
|
|
|
17
23
|
import { loadRecipeLibraries, logRecipeLibraryShadows, logRecipeLibrarySources, } from './library.js';
|
|
18
24
|
import { resolveRecipeParams } from './parameters.js';
|
|
19
25
|
import { defaultObserverRefsFromManifest } from './passive-observations.js';
|
|
20
|
-
import { copyFileWithinRoots, statFileWithinRoot } from './path.js';
|
|
26
|
+
import { copyFileWithinRoots, digestFileWithinRoot, statFileWithinRoot, writeFileWithinRoot, } from './path.js';
|
|
21
27
|
import { cleanupAbortedRunVideoRecording, removePartialRunVideoOutput, } from './recording-cleanup.js';
|
|
28
|
+
import { boundRecording } from './recording-limits.js';
|
|
29
|
+
import { startSegmentedRecording } from './recording-segments.js';
|
|
22
30
|
import { RecipeResolutionError } from './resolution-error.js';
|
|
23
31
|
import { buildRecipeExecutionPlan, enforceRecipeExecutionPlan, recipeSourceForRequest, verifyExecutableSource, } from './trust.js';
|
|
24
32
|
import { RECIPE_TRUST_ENV } from './trust-input.js';
|
|
@@ -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
|
|
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:
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
414
|
-
const
|
|
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 =
|
|
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 {
|
|
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
|
|
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 (!
|
|
564
|
-
throw new Error('
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
827
|
+
videos,
|
|
641
828
|
...(interruption ? { interruption } : {}),
|
|
642
|
-
...(
|
|
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 (!
|
|
953
|
+
function normalizeVideoRecordingOptions(input, needsUi, reason) {
|
|
954
|
+
if (input === undefined && !needsUi)
|
|
765
955
|
return { mode: 'off' };
|
|
766
|
-
if (input ===
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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')
|