@farmslot/recipe-runner 0.28.5 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/adapters/core.d.ts +32 -1
- package/dist/adapters/core.d.ts.map +1 -1
- package/dist/adapters/core.js +152 -38
- 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 +5 -1
- package/dist/cli/support.js.map +1 -1
- package/dist/core/execution-signal.d.ts +4 -0
- package/dist/core/execution-signal.d.ts.map +1 -0
- package/dist/core/execution-signal.js +13 -0
- package/dist/core/execution-signal.js.map +1 -0
- package/dist/core/execution.d.ts +4 -0
- package/dist/core/execution.d.ts.map +1 -1
- package/dist/core/execution.js +86 -12
- package/dist/core/execution.js.map +1 -1
- package/dist/core/failure.d.ts +2 -0
- package/dist/core/failure.d.ts.map +1 -1
- package/dist/core/failure.js +10 -0
- package/dist/core/failure.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/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 +336 -108
- package/dist/core/runner.js.map +1 -1
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/trace.js +2 -0
- package/dist/core/trace.js.map +1 -1
- package/dist/core/types.d.ts +40 -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 +71 -16
- 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';
|
|
@@ -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:
|
|
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
|
|
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
|
|
260
|
+
let completionHudFailure;
|
|
261
|
+
const videoTimelines = [];
|
|
242
262
|
let canExecute = true;
|
|
243
263
|
if (videoRecorder) {
|
|
244
264
|
try {
|
|
@@ -272,6 +292,7 @@ class DefaultRecipeRunner {
|
|
|
272
292
|
try {
|
|
273
293
|
if (canExecute) {
|
|
274
294
|
const execution = await executeRecipe({
|
|
295
|
+
signal: request.signal,
|
|
275
296
|
ref: rootRef,
|
|
276
297
|
adapter: request.adapter,
|
|
277
298
|
recipe,
|
|
@@ -280,6 +301,7 @@ class DefaultRecipeRunner {
|
|
|
280
301
|
actionManifest: this.#actionManifest,
|
|
281
302
|
adapters: this.#adapters,
|
|
282
303
|
traceWriter,
|
|
304
|
+
claimProjections: evidencePlan.nodes,
|
|
283
305
|
logger: this.#logger,
|
|
284
306
|
defaultObserverRefs: this.#defaultObserverRefs,
|
|
285
307
|
createContext: (nodeId, activeRecipe, activeOutputs, getOutput) => this.#createExecutionContext({
|
|
@@ -301,7 +323,7 @@ class DefaultRecipeRunner {
|
|
|
301
323
|
publishHudProgress: (hudStatus, event) => this.#publishHudProgressOrRecord(traceWriter, hudStatus, event),
|
|
302
324
|
...(request.stopAfterNode !== undefined ? { stopAfterNode: request.stopAfterNode } : {}),
|
|
303
325
|
});
|
|
304
|
-
status = execution.status;
|
|
326
|
+
status = hasUnresolvedRecipeFailure(traceWriter.list()) ? 'fail' : execution.status;
|
|
305
327
|
outputs = execution.outputs;
|
|
306
328
|
}
|
|
307
329
|
artifactWriter.register({
|
|
@@ -316,63 +338,6 @@ class DefaultRecipeRunner {
|
|
|
316
338
|
label: 'Execution trace',
|
|
317
339
|
category: 'system',
|
|
318
340
|
});
|
|
319
|
-
if (runRecording) {
|
|
320
|
-
const recordingToStop = runRecording;
|
|
321
|
-
runRecording = undefined;
|
|
322
|
-
try {
|
|
323
|
-
const { entry: videoArtifact, interruption, timing, } = await this.#stopRunVideoRecording(recordingToStop, traceWriter, artifactWriter, startedAt);
|
|
324
|
-
artifactWriter.register(videoArtifact);
|
|
325
|
-
if (timing)
|
|
326
|
-
videoTimeline = { entry: videoArtifact, timing };
|
|
327
|
-
if (interruption) {
|
|
328
|
-
// The partial video stays registered as evidence; the run still fails, typed.
|
|
329
|
-
captureInterruption = interruption;
|
|
330
|
-
this.#logger.error(`record.video interrupted: ${interruption.message}`);
|
|
331
|
-
status = 'fail';
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
catch (error) {
|
|
335
|
-
const message = errorMessage(error);
|
|
336
|
-
await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
|
|
337
|
-
await rm(recordingToStop.stagingRoot, { recursive: true, force: true });
|
|
338
|
-
traceWriter.record({
|
|
339
|
-
nodeId: 'recipe-run:video',
|
|
340
|
-
action: 'record.video',
|
|
341
|
-
startedAt: startedAt.toISOString(),
|
|
342
|
-
endedAt: new Date().toISOString(),
|
|
343
|
-
durationMs: Date.now() - startedAt.getTime(),
|
|
344
|
-
ok: false,
|
|
345
|
-
cause_class: recipeFailureCause(error, 'harness'),
|
|
346
|
-
error: message,
|
|
347
|
-
});
|
|
348
|
-
this.#logger.error(`record.video failed: ${message}`);
|
|
349
|
-
status = 'fail';
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
if (request.finalizeRecording) {
|
|
353
|
-
const recordingStartedAt = new Date();
|
|
354
|
-
try {
|
|
355
|
-
const interruption = await request.finalizeRecording();
|
|
356
|
-
if (interruption) {
|
|
357
|
-
captureInterruption = interruption;
|
|
358
|
-
traceWriter.record(captureInterruptedTraceEntry(interruption, startedAt));
|
|
359
|
-
status = 'fail';
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
catch (error) {
|
|
363
|
-
traceWriter.record({
|
|
364
|
-
nodeId: 'recipe-run:video',
|
|
365
|
-
action: 'record.video',
|
|
366
|
-
startedAt: recordingStartedAt.toISOString(),
|
|
367
|
-
endedAt: new Date().toISOString(),
|
|
368
|
-
durationMs: Date.now() - recordingStartedAt.getTime(),
|
|
369
|
-
ok: false,
|
|
370
|
-
cause_class: recipeFailureCause(error, 'harness'),
|
|
371
|
-
error: errorMessage(error),
|
|
372
|
-
});
|
|
373
|
-
status = 'fail';
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
341
|
if (status === 'pass' || status === 'fail') {
|
|
377
342
|
const runHudStartedAt = new Date();
|
|
378
343
|
try {
|
|
@@ -388,7 +353,7 @@ class DefaultRecipeRunner {
|
|
|
388
353
|
const message = error instanceof Error ? error.message : String(error);
|
|
389
354
|
const fields = recipeFailureTraceFields(error);
|
|
390
355
|
const captureEndpointLost = captureInterruption && fields.error_code === RUNTIME_CONNECTION_CLOSED;
|
|
391
|
-
|
|
356
|
+
completionHudFailure = {
|
|
392
357
|
nodeId: 'recipe-complete:hud',
|
|
393
358
|
action: 'app.hud',
|
|
394
359
|
startedAt: runHudStartedAt.toISOString(),
|
|
@@ -404,13 +369,66 @@ class DefaultRecipeRunner {
|
|
|
404
369
|
error_details: { runtime_error_code: fields.error_code },
|
|
405
370
|
}
|
|
406
371
|
: {}),
|
|
407
|
-
}
|
|
372
|
+
};
|
|
373
|
+
traceWriter.record(completionHudFailure);
|
|
408
374
|
this.#logger.error(`app.hud complete update failed: ${message}`);
|
|
409
375
|
status = 'fail';
|
|
410
376
|
}
|
|
411
377
|
}
|
|
412
|
-
if (
|
|
413
|
-
const
|
|
378
|
+
if (runRecording) {
|
|
379
|
+
const recordingToStop = runRecording;
|
|
380
|
+
runRecording = undefined;
|
|
381
|
+
try {
|
|
382
|
+
const { videos, interruption, releaseFailure } = await this.#stopRunVideoRecording(recordingToStop, traceWriter, artifactWriter, startedAt);
|
|
383
|
+
for (const video of videos)
|
|
384
|
+
if (video.timing)
|
|
385
|
+
videoTimelines.push({ entry: video.entry, timing: video.timing });
|
|
386
|
+
if (releaseFailure) {
|
|
387
|
+
this.#logger.error(`Recording lease cleanup failed: ${releaseFailure}`);
|
|
388
|
+
status = 'fail';
|
|
389
|
+
}
|
|
390
|
+
if (interruption) {
|
|
391
|
+
// The partial video stays registered as evidence; the run still fails, typed.
|
|
392
|
+
captureInterruption = interruption;
|
|
393
|
+
if (completionHudFailure?.error_code === RUNTIME_CONNECTION_CLOSED) {
|
|
394
|
+
completionHudFailure.error_code = CAPTURE_INTERRUPTED;
|
|
395
|
+
completionHudFailure.cause_class = 'environment';
|
|
396
|
+
completionHudFailure.error_details = {
|
|
397
|
+
runtime_error_code: RUNTIME_CONNECTION_CLOSED,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
this.#logger.error(`record.video interrupted: ${interruption.message}`);
|
|
401
|
+
status = 'fail';
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
const message = errorMessage(error);
|
|
406
|
+
await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
|
|
407
|
+
await rm(recordingToStop.stagingRoot, { recursive: true, force: true });
|
|
408
|
+
traceWriter.record({
|
|
409
|
+
nodeId: 'recipe-run:video',
|
|
410
|
+
action: 'record.video',
|
|
411
|
+
startedAt: startedAt.toISOString(),
|
|
412
|
+
endedAt: new Date().toISOString(),
|
|
413
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
414
|
+
ok: false,
|
|
415
|
+
cause_class: recipeFailureCause(error, 'harness'),
|
|
416
|
+
error: message,
|
|
417
|
+
});
|
|
418
|
+
this.#logger.error(`record.video failed: ${message}`);
|
|
419
|
+
status = 'fail';
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const entry of artifactWriter
|
|
423
|
+
.list()
|
|
424
|
+
.filter((artifact) => artifact.type === 'video' && artifact.sha256)) {
|
|
425
|
+
for (const still of artifactWriter.list()) {
|
|
426
|
+
const source = still.metadata?.recordedEvidence;
|
|
427
|
+
if (isRecord(source) && source.videoPath === entry.path)
|
|
428
|
+
source.videoDigest = `sha256:${entry.sha256}`;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
for (const { entry, timing } of videoTimelines) {
|
|
414
432
|
try {
|
|
415
433
|
entry.timelinePath = await writeRecordingTimeline(artifactsDir, entry.path, timing, traceWriter.list());
|
|
416
434
|
artifactWriter.register(entry);
|
|
@@ -438,7 +456,101 @@ class DefaultRecipeRunner {
|
|
|
438
456
|
const endedAt = new Date();
|
|
439
457
|
const tracePath = await traceWriter.write();
|
|
440
458
|
const trace = traceWriter.list();
|
|
459
|
+
for (const artifact of artifactWriter.list()) {
|
|
460
|
+
if (artifact.type !== 'video')
|
|
461
|
+
continue;
|
|
462
|
+
const videoDigest = artifact.sha256
|
|
463
|
+
? `sha256:${artifact.sha256}`
|
|
464
|
+
: await digestFileWithinRoot(artifactsDir, artifact.path);
|
|
465
|
+
segments.push({
|
|
466
|
+
videoPath: artifact.path,
|
|
467
|
+
videoDigest,
|
|
468
|
+
...(artifact.timelinePath ? { timelinePath: artifact.timelinePath } : {}),
|
|
469
|
+
...(artifact.metadata?.gapBefore === true ? { gapBefore: true } : {}),
|
|
470
|
+
});
|
|
471
|
+
if (artifact.timelinePath)
|
|
472
|
+
timelines.set(videoDigest, (await readJsonFile(path.join(artifactsDir, artifact.timelinePath))));
|
|
473
|
+
}
|
|
474
|
+
const pendingClaims = deriveRecipeClaims({
|
|
475
|
+
recipeRunId,
|
|
476
|
+
...evidencePlan,
|
|
477
|
+
trace,
|
|
478
|
+
artifacts: artifactWriter.list(),
|
|
479
|
+
segments,
|
|
480
|
+
timelines,
|
|
481
|
+
});
|
|
482
|
+
const derivativeIssues = [];
|
|
483
|
+
const requiredStills = [...new Set(pendingClaims.flatMap((claim) => claim.assertionIndices))];
|
|
484
|
+
await mkdir(path.join(artifactsDir, 'stills'), { recursive: true });
|
|
485
|
+
for (const traceIndex of requiredStills) {
|
|
486
|
+
const nodeId = trace[traceIndex]?.nodeId;
|
|
487
|
+
if (!trace[traceIndex]?.action.startsWith('ui.') ||
|
|
488
|
+
artifactWriter
|
|
489
|
+
.list()
|
|
490
|
+
.some((artifact) => artifact.type === 'screenshot' &&
|
|
491
|
+
artifact.nodeId === nodeId &&
|
|
492
|
+
isRecord(artifact.metadata?.recordedEvidence)))
|
|
493
|
+
continue;
|
|
494
|
+
const claim = pendingClaims.find((entry) => entry.assertionIndices.includes(traceIndex));
|
|
495
|
+
const moment = claim?.moments.find((entry) => entry.traceIndex === traceIndex);
|
|
496
|
+
const segment = moment
|
|
497
|
+
? segments.find((entry) => entry.videoDigest === moment.videoDigest)
|
|
498
|
+
: undefined;
|
|
499
|
+
const timeline = segment ? timelines.get(segment.videoDigest) : undefined;
|
|
500
|
+
if (!segment || !timeline)
|
|
501
|
+
continue;
|
|
502
|
+
try {
|
|
503
|
+
const still = await materializeEvidenceStill(artifactsDir, segment, timeline, traceIndex, trace);
|
|
504
|
+
if (still)
|
|
505
|
+
artifactWriter.register(still);
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
// The source clip remains usable; retain the failed derivative as package diagnostics.
|
|
509
|
+
artifactWriter.register({
|
|
510
|
+
path: 'recording-derivatives.json',
|
|
511
|
+
type: 'json',
|
|
512
|
+
category: 'diagnostic',
|
|
513
|
+
label: 'Evidence derivative limitations',
|
|
514
|
+
});
|
|
515
|
+
derivativeIssues.push({ nodeId, error: errorMessage(error) });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (derivativeIssues.length)
|
|
519
|
+
await writeFileWithinRoot(artifactsDir, 'recording-derivatives.json', JSON.stringify(derivativeIssues));
|
|
520
|
+
const claims = {
|
|
521
|
+
schemaVersion: 2,
|
|
522
|
+
recipeRunId,
|
|
523
|
+
criteria: deriveRecipeClaims({
|
|
524
|
+
recipeRunId,
|
|
525
|
+
...evidencePlan,
|
|
526
|
+
trace,
|
|
527
|
+
artifacts: artifactWriter.list(),
|
|
528
|
+
segments,
|
|
529
|
+
timelines,
|
|
530
|
+
}),
|
|
531
|
+
recording: {
|
|
532
|
+
status: videoOptions.mode === 'off'
|
|
533
|
+
? recordingRequired
|
|
534
|
+
? 'explicit-off'
|
|
535
|
+
: 'not-required'
|
|
536
|
+
: captureInterruption
|
|
537
|
+
? 'incomplete'
|
|
538
|
+
: segments.length
|
|
539
|
+
? 'complete'
|
|
540
|
+
: 'failed',
|
|
541
|
+
...(videoOptions.reason ? { reason: videoOptions.reason } : {}),
|
|
542
|
+
segments,
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
const claimsPath = await writeFileWithinRoot(artifactsDir, 'acceptance-status.json', `${JSON.stringify(claims, null, 2)}\n`);
|
|
546
|
+
artifactWriter.register({
|
|
547
|
+
path: 'acceptance-status.json',
|
|
548
|
+
type: 'json',
|
|
549
|
+
category: 'system',
|
|
550
|
+
label: 'Derived claim evidence',
|
|
551
|
+
});
|
|
441
552
|
const summary = {
|
|
553
|
+
recipeRunId,
|
|
442
554
|
invocationDigest: digestRecipeDocument(invocation),
|
|
443
555
|
status,
|
|
444
556
|
...summarizeTraceCounts(trace),
|
|
@@ -459,7 +571,14 @@ class DefaultRecipeRunner {
|
|
|
459
571
|
};
|
|
460
572
|
const summaryPath = await summaryWriter.write(summary);
|
|
461
573
|
const artifactManifestPath = await artifactWriter.write(status, this.#runnerProvenance);
|
|
462
|
-
const packageValidation =
|
|
574
|
+
const packageValidation = validateRecordedEvidencePackage({
|
|
575
|
+
invocation,
|
|
576
|
+
recipeRunId,
|
|
577
|
+
claims,
|
|
578
|
+
artifactDigests: Object.fromEntries(segments.map((segment) => [segment.videoPath, segment.videoDigest])),
|
|
579
|
+
timelines: Object.fromEntries(segments
|
|
580
|
+
.filter((segment) => segment.timelinePath)
|
|
581
|
+
.map((segment) => [segment.timelinePath, timelines.get(segment.videoDigest)])),
|
|
463
582
|
recipe,
|
|
464
583
|
resolvedRecipes: Object.fromEntries([...dependencyResolution.recipes.values()].map((entry) => [
|
|
465
584
|
entry.provenance.digest,
|
|
@@ -494,6 +613,8 @@ class DefaultRecipeRunner {
|
|
|
494
613
|
tracePath,
|
|
495
614
|
artifactManifestPath,
|
|
496
615
|
recipePath,
|
|
616
|
+
claimsPath,
|
|
617
|
+
claims,
|
|
497
618
|
...(captureInterruption ? { captureInterruption } : {}),
|
|
498
619
|
};
|
|
499
620
|
}
|
|
@@ -518,7 +639,14 @@ class DefaultRecipeRunner {
|
|
|
518
639
|
await copyFileWithinRoots(dir, 'frame.png', artifactsDir, relative);
|
|
519
640
|
// Keep the provider's source path in its raw sidecar; the action output
|
|
520
641
|
// maps it to the published artifact path without rewriting capture facts.
|
|
521
|
-
return {
|
|
642
|
+
return {
|
|
643
|
+
...evidence,
|
|
644
|
+
sourceOutput: evidence.output,
|
|
645
|
+
output: relative,
|
|
646
|
+
...(evidence.fallbackFrom
|
|
647
|
+
? {}
|
|
648
|
+
: { videoPath: recordedVideoPath(Number(evidence.segmentIndex ?? 0)) }),
|
|
649
|
+
};
|
|
522
650
|
},
|
|
523
651
|
}
|
|
524
652
|
: {}),
|
|
@@ -549,7 +677,7 @@ class DefaultRecipeRunner {
|
|
|
549
677
|
action: 'record.video',
|
|
550
678
|
intent: 'Record the recipe run when motion proof is useful',
|
|
551
679
|
};
|
|
552
|
-
const
|
|
680
|
+
const resolvedTarget = videoOptions.target ??
|
|
553
681
|
(await this.#recording?.targetProvider?.resolveRecordingTarget({
|
|
554
682
|
nodeId: 'recipe-run',
|
|
555
683
|
node,
|
|
@@ -559,16 +687,29 @@ class DefaultRecipeRunner {
|
|
|
559
687
|
env,
|
|
560
688
|
scope: 'run',
|
|
561
689
|
}));
|
|
562
|
-
if (!
|
|
563
|
-
throw new Error('
|
|
690
|
+
if (!resolvedTarget) {
|
|
691
|
+
throw new Error('UI recording requires a target. Provide a project RecordingTargetProvider or disable recording with a reason.');
|
|
564
692
|
}
|
|
565
|
-
const
|
|
693
|
+
const validation = validateRecipeRecordingTarget(resolvedTarget);
|
|
694
|
+
if (validation.status !== 'valid')
|
|
695
|
+
throw new RecipeExecutionError('environment', validation.findings.map((finding) => finding.message).join('; '));
|
|
696
|
+
// Admission and backend start use the same immutable provider-resolved identity.
|
|
697
|
+
const target = Object.freeze({ ...resolvedTarget });
|
|
698
|
+
const relativePath = recordedVideoPath(0);
|
|
566
699
|
const stagingRoot = await mkdtemp(path.join(os.tmpdir(), 'farmslot-recipe-video-'));
|
|
567
700
|
const stagingRelativePath = 'recipe-run.mp4';
|
|
568
701
|
const outputPath = path.join(stagingRoot, stagingRelativePath);
|
|
702
|
+
let admission;
|
|
703
|
+
let releaseFailure;
|
|
569
704
|
let recording;
|
|
570
705
|
try {
|
|
571
|
-
|
|
706
|
+
admission = await this.#recording?.admission?.acquire({
|
|
707
|
+
backend: recorder.name,
|
|
708
|
+
target,
|
|
709
|
+
projectRoot,
|
|
710
|
+
artifactsDir,
|
|
711
|
+
});
|
|
712
|
+
const backend = await startSegmentedRecording(recorder, {
|
|
572
713
|
outputPath,
|
|
573
714
|
target,
|
|
574
715
|
maxFps: videoOptions.maxFps,
|
|
@@ -576,10 +717,42 @@ class DefaultRecipeRunner {
|
|
|
576
717
|
nodeId: 'recipe-run',
|
|
577
718
|
record: 'full_run',
|
|
578
719
|
});
|
|
720
|
+
recording = {
|
|
721
|
+
...backend,
|
|
722
|
+
async stop() {
|
|
723
|
+
let result;
|
|
724
|
+
try {
|
|
725
|
+
result = await backend.stop();
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
try {
|
|
729
|
+
await admission?.release();
|
|
730
|
+
}
|
|
731
|
+
catch (releaseError) {
|
|
732
|
+
throw new AggregateError([error, releaseError], 'Recording stop and admission release failed.');
|
|
733
|
+
}
|
|
734
|
+
throw error;
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
await admission?.release();
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
// Completed originals remain usable; the caller records failed lease cleanup separately.
|
|
741
|
+
releaseFailure = errorMessage(error);
|
|
742
|
+
}
|
|
743
|
+
return result;
|
|
744
|
+
},
|
|
745
|
+
};
|
|
579
746
|
}
|
|
580
747
|
catch (error) {
|
|
581
748
|
await removePartialRunVideoOutput(outputPath, this.#logger);
|
|
582
749
|
await rm(stagingRoot, { recursive: true, force: true });
|
|
750
|
+
try {
|
|
751
|
+
await admission?.release();
|
|
752
|
+
}
|
|
753
|
+
catch (releaseError) {
|
|
754
|
+
throw new AggregateError([error, releaseError], 'Recording start and admission release failed.');
|
|
755
|
+
}
|
|
583
756
|
throw error;
|
|
584
757
|
}
|
|
585
758
|
const entry = {
|
|
@@ -598,7 +771,10 @@ class DefaultRecipeRunner {
|
|
|
598
771
|
...(videoOptions.maxFps != null ? { maxFps: videoOptions.maxFps } : {}),
|
|
599
772
|
};
|
|
600
773
|
return {
|
|
601
|
-
|
|
774
|
+
get releaseFailure() {
|
|
775
|
+
return releaseFailure;
|
|
776
|
+
},
|
|
777
|
+
recording: boundRecording(recording, outputPath, videoOptions),
|
|
602
778
|
entry,
|
|
603
779
|
outputPath,
|
|
604
780
|
stagingRoot,
|
|
@@ -608,37 +784,65 @@ class DefaultRecipeRunner {
|
|
|
608
784
|
}
|
|
609
785
|
async #stopRunVideoRecording(runRecording, traceWriter, artifactWriter, runStartedAt) {
|
|
610
786
|
const result = await runRecording.recording.stop();
|
|
787
|
+
const releaseFailure = runRecording.releaseFailure;
|
|
788
|
+
if (releaseFailure)
|
|
789
|
+
traceWriter.record({
|
|
790
|
+
nodeId: 'recipe-run:video',
|
|
791
|
+
action: 'record.video',
|
|
792
|
+
startedAt: runStartedAt.toISOString(),
|
|
793
|
+
endedAt: new Date().toISOString(),
|
|
794
|
+
durationMs: Date.now() - runStartedAt.getTime(),
|
|
795
|
+
ok: false,
|
|
796
|
+
cause_class: 'environment',
|
|
797
|
+
error_code: 'RECORDING_ADMISSION_RELEASE_FAILED',
|
|
798
|
+
error: releaseFailure,
|
|
799
|
+
});
|
|
800
|
+
if (!result.segments.length)
|
|
801
|
+
throw new Error('Recorder returned no original media segments.');
|
|
611
802
|
const interruption = result.interruption
|
|
612
|
-
? runCaptureInterruption(result.interruption,
|
|
803
|
+
? runCaptureInterruption(result.interruption, recordedVideoPath(result.segments.length - 1))
|
|
613
804
|
: undefined;
|
|
614
|
-
// Record the failure before the timeline, which is bound to the final trace.
|
|
615
805
|
if (interruption)
|
|
616
806
|
traceWriter.record(captureInterruptedTraceEntry(interruption, runStartedAt));
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
807
|
+
const videos = [];
|
|
808
|
+
for (const [index, segment] of result.segments.entries()) {
|
|
809
|
+
const source = normalizeRelativePath(segment.path);
|
|
810
|
+
await assertVideoOutputReady(path.join(runRecording.stagingRoot, source));
|
|
811
|
+
const relativePath = recordedVideoPath(index);
|
|
812
|
+
await copyFileWithinRoots(runRecording.stagingRoot, source, runRecording.artifactsDir, relativePath);
|
|
813
|
+
if (segment.timingEvidencePath && segment.timing) {
|
|
814
|
+
const evidencePath = `${relativePath}.capture.json`;
|
|
815
|
+
await copyFileWithinRoots(runRecording.stagingRoot, segment.timingEvidencePath, runRecording.artifactsDir, evidencePath);
|
|
816
|
+
artifactWriter.register({
|
|
817
|
+
path: evidencePath,
|
|
818
|
+
type: 'json',
|
|
819
|
+
category: 'system',
|
|
820
|
+
label: 'Native recording and snapshot timing',
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const entry = {
|
|
824
|
+
...runRecording.entry,
|
|
825
|
+
path: relativePath,
|
|
826
|
+
sha256: (await digestFileWithinRoot(runRecording.artifactsDir, relativePath)).slice(7),
|
|
827
|
+
...(segment.recorder ? { recorder: segment.recorder } : {}),
|
|
828
|
+
...(segment.gapBefore ? { metadata: { gapBefore: true } } : {}),
|
|
829
|
+
...(result.interruption && index === result.segments.length - 1
|
|
830
|
+
? { interruption: result.interruption }
|
|
831
|
+
: {}),
|
|
832
|
+
...(!segment.timing
|
|
833
|
+
? {
|
|
834
|
+
timelineUnavailableReason: segment.timingUnavailableReason ?? 'Recorder does not provide timeline alignment.',
|
|
835
|
+
}
|
|
836
|
+
: {}),
|
|
837
|
+
};
|
|
838
|
+
artifactWriter.register(entry);
|
|
839
|
+
videos.push({ entry, ...(segment.timing ? { timing: segment.timing } : {}) });
|
|
628
840
|
}
|
|
629
841
|
await rm(runRecording.stagingRoot, { recursive: true, force: true });
|
|
630
|
-
const entry = {
|
|
631
|
-
...runRecording.entry,
|
|
632
|
-
...(result.recorder ? { recorder: result.recorder } : {}),
|
|
633
|
-
...(result.interruption ? { interruption: result.interruption } : {}),
|
|
634
|
-
};
|
|
635
|
-
if (!result.timing)
|
|
636
|
-
entry.timelineUnavailableReason =
|
|
637
|
-
result.timingUnavailableReason ?? 'Recorder does not provide timeline alignment.';
|
|
638
842
|
return {
|
|
639
|
-
|
|
843
|
+
videos,
|
|
640
844
|
...(interruption ? { interruption } : {}),
|
|
641
|
-
...(
|
|
845
|
+
...(releaseFailure ? { releaseFailure } : {}),
|
|
642
846
|
};
|
|
643
847
|
}
|
|
644
848
|
#hudAction() {
|
|
@@ -732,6 +936,9 @@ class DefaultRecipeRunner {
|
|
|
732
936
|
return 'Recipe run';
|
|
733
937
|
}
|
|
734
938
|
}
|
|
939
|
+
function recordedVideoPath(index) {
|
|
940
|
+
return index === 0 ? 'videos/recipe-run.mp4' : `videos/recipe-run-${index}.mp4`;
|
|
941
|
+
}
|
|
735
942
|
function buildRecipeLibrarySummary(resolution) {
|
|
736
943
|
return {
|
|
737
944
|
sources: resolution.sources,
|
|
@@ -759,15 +966,36 @@ function rootRecipeRef(sourceRecipePath, recipes, digest) {
|
|
|
759
966
|
}
|
|
760
967
|
return rootResolutionRef(digest);
|
|
761
968
|
}
|
|
762
|
-
function normalizeVideoRecordingOptions(input) {
|
|
763
|
-
if (!
|
|
969
|
+
function normalizeVideoRecordingOptions(input, needsUi, reason) {
|
|
970
|
+
if (input === undefined && !needsUi)
|
|
764
971
|
return { mode: 'off' };
|
|
765
|
-
if (input ===
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
972
|
+
if (input === false || input === 'off' || (typeof input === 'object' && input.mode === 'off')) {
|
|
973
|
+
const offReason = (typeof input === 'object' ? input.reason : undefined) ?? reason;
|
|
974
|
+
if (needsUi && !offReason?.trim())
|
|
975
|
+
throw new Error('Disabling UI recording requires recordingReason.');
|
|
976
|
+
return { mode: 'off', ...(offReason ? { reason: offReason } : {}) };
|
|
977
|
+
}
|
|
978
|
+
const mode = typeof input === 'string'
|
|
979
|
+
? input
|
|
980
|
+
: typeof input === 'object'
|
|
981
|
+
? (input.mode ?? 'full-run')
|
|
982
|
+
: 'full-run';
|
|
983
|
+
const options = {
|
|
984
|
+
maxFps: DEFAULT_RECORDED_EVIDENCE_POLICY.maxFps,
|
|
985
|
+
maxSize: DEFAULT_RECORDED_EVIDENCE_POLICY.maxSize,
|
|
986
|
+
maxDurationMs: DEFAULT_RECORDED_EVIDENCE_POLICY.maxDurationMs,
|
|
987
|
+
maxBytes: DEFAULT_RECORDED_EVIDENCE_POLICY.maxBytes,
|
|
988
|
+
...(typeof input === 'object' ? input : {}),
|
|
989
|
+
mode: normalizeVideoRecordingMode(mode).mode,
|
|
990
|
+
};
|
|
991
|
+
for (const key of ['maxFps', 'maxSize', 'maxDurationMs', 'maxBytes']) {
|
|
992
|
+
if (!Number.isFinite(options[key]) || options[key] <= 0)
|
|
993
|
+
throw new Error(`Recording ${key} must be finite and positive.`);
|
|
994
|
+
}
|
|
995
|
+
if (options.maxDurationMs > DEFAULT_RECORDED_EVIDENCE_POLICY.maxDurationMs ||
|
|
996
|
+
options.maxBytes > DEFAULT_RECORDED_EVIDENCE_POLICY.maxBytes)
|
|
997
|
+
throw new Error('Recording policy exceeds the execution duration/byte limits.');
|
|
998
|
+
return options;
|
|
771
999
|
}
|
|
772
1000
|
function normalizeVideoRecordingMode(mode) {
|
|
773
1001
|
if (mode === 'off')
|