@farmslot/recipe-runner 0.23.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 +255 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/bin/farmslot-visual-review.mjs +152 -0
- package/dist/adapters/app-lifecycle.d.ts +41 -0
- package/dist/adapters/app-lifecycle.d.ts.map +1 -0
- package/dist/adapters/app-lifecycle.js +224 -0
- package/dist/adapters/app-lifecycle.js.map +1 -0
- package/dist/adapters/core.d.ts +5 -0
- package/dist/adapters/core.d.ts.map +1 -0
- package/dist/adapters/core.js +433 -0
- package/dist/adapters/core.js.map +1 -0
- package/dist/adapters/gesture.d.ts +12 -0
- package/dist/adapters/gesture.d.ts.map +1 -0
- package/dist/adapters/gesture.js +76 -0
- package/dist/adapters/gesture.js.map +1 -0
- package/dist/adapters/ui.d.ts +33 -0
- package/dist/adapters/ui.d.ts.map +1 -0
- package/dist/adapters/ui.js +130 -0
- package/dist/adapters/ui.js.map +1 -0
- package/dist/cli/error-output.d.ts +6 -0
- package/dist/cli/error-output.d.ts.map +1 -0
- package/dist/cli/error-output.js +19 -0
- package/dist/cli/error-output.js.map +1 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +29 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run-command.d.ts +8 -0
- package/dist/cli/run-command.d.ts.map +1 -0
- package/dist/cli/run-command.js +253 -0
- package/dist/cli/run-command.js.map +1 -0
- package/dist/cli/support.d.ts +31 -0
- package/dist/cli/support.d.ts.map +1 -0
- package/dist/cli/support.js +297 -0
- package/dist/cli/support.js.map +1 -0
- package/dist/cli/validate-command.d.ts +4 -0
- package/dist/cli/validate-command.d.ts.map +1 -0
- package/dist/cli/validate-command.js +52 -0
- package/dist/cli/validate-command.js.map +1 -0
- package/dist/core/compose.d.ts +23 -0
- package/dist/core/compose.d.ts.map +1 -0
- package/dist/core/compose.js +104 -0
- package/dist/core/compose.js.map +1 -0
- package/dist/core/execution.d.ts +44 -0
- package/dist/core/execution.d.ts.map +1 -0
- package/dist/core/execution.js +304 -0
- package/dist/core/execution.js.map +1 -0
- package/dist/core/failure.d.ts +23 -0
- package/dist/core/failure.d.ts.map +1 -0
- package/dist/core/failure.js +43 -0
- package/dist/core/failure.js.map +1 -0
- package/dist/core/graph.d.ts +12 -0
- package/dist/core/graph.d.ts.map +1 -0
- package/dist/core/graph.js +65 -0
- package/dist/core/graph.js.map +1 -0
- package/dist/core/hud.d.ts +11 -0
- package/dist/core/hud.d.ts.map +1 -0
- package/dist/core/hud.js +120 -0
- package/dist/core/hud.js.map +1 -0
- package/dist/core/invocation.d.ts +13 -0
- package/dist/core/invocation.d.ts.map +1 -0
- package/dist/core/invocation.js +68 -0
- package/dist/core/invocation.js.map +1 -0
- package/dist/core/json.d.ts +9 -0
- package/dist/core/json.d.ts.map +1 -0
- package/dist/core/json.js +74 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/library-manifest.d.ts +53 -0
- package/dist/core/library-manifest.d.ts.map +1 -0
- package/dist/core/library-manifest.js +263 -0
- package/dist/core/library-manifest.js.map +1 -0
- package/dist/core/library.d.ts +80 -0
- package/dist/core/library.d.ts.map +1 -0
- package/dist/core/library.js +321 -0
- package/dist/core/library.js.map +1 -0
- package/dist/core/parameters.d.ts +5 -0
- package/dist/core/parameters.d.ts.map +1 -0
- package/dist/core/parameters.js +57 -0
- package/dist/core/parameters.js.map +1 -0
- package/dist/core/passive-observations.d.ts +25 -0
- package/dist/core/passive-observations.d.ts.map +1 -0
- package/dist/core/passive-observations.js +62 -0
- package/dist/core/passive-observations.js.map +1 -0
- package/dist/core/path.d.ts +7 -0
- package/dist/core/path.d.ts.map +1 -0
- package/dist/core/path.js +147 -0
- package/dist/core/path.js.map +1 -0
- package/dist/core/recording-cleanup.d.ts +10 -0
- package/dist/core/recording-cleanup.d.ts.map +1 -0
- package/dist/core/recording-cleanup.js +32 -0
- package/dist/core/recording-cleanup.js.map +1 -0
- package/dist/core/resolution-error.d.ts +7 -0
- package/dist/core/resolution-error.d.ts.map +1 -0
- package/dist/core/resolution-error.js +9 -0
- package/dist/core/resolution-error.js.map +1 -0
- package/dist/core/runner.d.ts +4 -0
- package/dist/core/runner.d.ts.map +1 -0
- package/dist/core/runner.js +738 -0
- package/dist/core/runner.js.map +1 -0
- package/dist/core/scroll-to.d.ts +111 -0
- package/dist/core/scroll-to.d.ts.map +1 -0
- package/dist/core/scroll-to.js +353 -0
- package/dist/core/scroll-to.js.map +1 -0
- package/dist/core/suite.d.ts +25 -0
- package/dist/core/suite.d.ts.map +1 -0
- package/dist/core/suite.js +76 -0
- package/dist/core/suite.js.map +1 -0
- package/dist/core/trace.d.ts +5 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +25 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/core/trust-error.d.ts +9 -0
- package/dist/core/trust-error.d.ts.map +1 -0
- package/dist/core/trust-error.js +18 -0
- package/dist/core/trust-error.js.map +1 -0
- package/dist/core/trust-input.d.ts +20 -0
- package/dist/core/trust-input.d.ts.map +1 -0
- package/dist/core/trust-input.js +68 -0
- package/dist/core/trust-input.js.map +1 -0
- package/dist/core/trust.d.ts +28 -0
- package/dist/core/trust.d.ts.map +1 -0
- package/dist/core/trust.js +284 -0
- package/dist/core/trust.js.map +1 -0
- package/dist/core/types.d.ts +319 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/validation.d.ts +4 -0
- package/dist/core/validation.d.ts.map +1 -0
- package/dist/core/validation.js +18 -0
- package/dist/core/validation.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/node/writers.d.ts +27 -0
- package/dist/node/writers.d.ts.map +1 -0
- package/dist/node/writers.js +99 -0
- package/dist/node/writers.js.map +1 -0
- package/dist/recording/android-mirror.d.ts +10 -0
- package/dist/recording/android-mirror.d.ts.map +1 -0
- package/dist/recording/android-mirror.js +180 -0
- package/dist/recording/android-mirror.js.map +1 -0
- package/dist/recording/capture-helper-timing.d.ts +4 -0
- package/dist/recording/capture-helper-timing.d.ts.map +1 -0
- package/dist/recording/capture-helper-timing.js +47 -0
- package/dist/recording/capture-helper-timing.js.map +1 -0
- package/dist/recording/capture-helper.d.ts +10 -0
- package/dist/recording/capture-helper.d.ts.map +1 -0
- package/dist/recording/capture-helper.js +335 -0
- package/dist/recording/capture-helper.js.map +1 -0
- package/dist/recording/cdp-video-recorder.d.ts +9 -0
- package/dist/recording/cdp-video-recorder.d.ts.map +1 -0
- package/dist/recording/cdp-video-recorder.js +319 -0
- package/dist/recording/cdp-video-recorder.js.map +1 -0
- package/dist/recording/frame-sampler.d.ts +19 -0
- package/dist/recording/frame-sampler.d.ts.map +1 -0
- package/dist/recording/frame-sampler.js +29 -0
- package/dist/recording/frame-sampler.js.map +1 -0
- package/dist/recording/timeline.d.ts +15 -0
- package/dist/recording/timeline.d.ts.map +1 -0
- package/dist/recording/timeline.js +88 -0
- package/dist/recording/timeline.js.map +1 -0
- package/dist/runtime/browser-extension.d.ts +17 -0
- package/dist/runtime/browser-extension.d.ts.map +1 -0
- package/dist/runtime/browser-extension.js +42 -0
- package/dist/runtime/browser-extension.js.map +1 -0
- package/dist/runtime/cdp.d.ts +138 -0
- package/dist/runtime/cdp.d.ts.map +1 -0
- package/dist/runtime/cdp.js +1429 -0
- package/dist/runtime/cdp.js.map +1 -0
- package/dist/runtime/decision-types.d.ts +23 -0
- package/dist/runtime/decision-types.d.ts.map +1 -0
- package/dist/runtime/decision-types.js +2 -0
- package/dist/runtime/decision-types.js.map +1 -0
- package/dist/runtime/deps-readiness.d.ts +21 -0
- package/dist/runtime/deps-readiness.d.ts.map +1 -0
- package/dist/runtime/deps-readiness.js +141 -0
- package/dist/runtime/deps-readiness.js.map +1 -0
- package/dist/runtime/log-analysis.d.ts +48 -0
- package/dist/runtime/log-analysis.d.ts.map +1 -0
- package/dist/runtime/log-analysis.js +128 -0
- package/dist/runtime/log-analysis.js.map +1 -0
- package/dist/runtime/metro-probe.d.ts +5 -0
- package/dist/runtime/metro-probe.d.ts.map +1 -0
- package/dist/runtime/metro-probe.js +24 -0
- package/dist/runtime/metro-probe.js.map +1 -0
- package/dist/runtime/operation.d.ts +32 -0
- package/dist/runtime/operation.d.ts.map +1 -0
- package/dist/runtime/operation.js +184 -0
- package/dist/runtime/operation.js.map +1 -0
- package/dist/runtime/orchestrate-up.d.ts +22 -0
- package/dist/runtime/orchestrate-up.d.ts.map +1 -0
- package/dist/runtime/orchestrate-up.js +35 -0
- package/dist/runtime/orchestrate-up.js.map +1 -0
- package/dist/runtime/react-native-bridge.d.ts +17 -0
- package/dist/runtime/react-native-bridge.d.ts.map +1 -0
- package/dist/runtime/react-native-bridge.js +57 -0
- package/dist/runtime/react-native-bridge.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +163 -0
- package/test/fixtures/scroll-to-visible/README.md +39 -0
- package/visual-review/feedback-draft.mjs +82 -0
- package/visual-review/index.d.mts +41 -0
- package/visual-review/index.mjs +4 -0
- package/visual-review/recipe-board.mjs +142 -0
- package/visual-review/review-board.mjs +981 -0
- package/visual-review/server.mjs +69 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { digestRecipeDocument, getRecipeActionManifestActionNames, validateRecipeArtifactPackage, } from '@farmslot/protocol';
|
|
5
|
+
import { JsonArtifactWriter, JsonSummaryWriter, JsonTraceWriter } from '../node/writers.js';
|
|
6
|
+
import { createCaptureHelperVideoRecorder, errorMessage, manifestTarget, } from '../recording/capture-helper.js';
|
|
7
|
+
import { writeRecordingTimeline } from '../recording/timeline.js';
|
|
8
|
+
import { RECIPE_RUNNER_VERSION } from '../version.js';
|
|
9
|
+
import { resolveRecipeDependencies, rootResolutionRef, validateRecipeDependencyParams, } from './compose.js';
|
|
10
|
+
import { executeRecipe } from './execution.js';
|
|
11
|
+
import { RecipeExecutionError, recipeFailureCause } from './failure.js';
|
|
12
|
+
import { extractWorkflowGraph } from './graph.js';
|
|
13
|
+
import { buildHudNode } from './hud.js';
|
|
14
|
+
import { createRecipeInvocation } from './invocation.js';
|
|
15
|
+
import { isRecord, normalizeRelativePath, readJsonFile } from './json.js';
|
|
16
|
+
import { loadRecipeLibraries, logRecipeLibraryShadows, logRecipeLibrarySources, } from './library.js';
|
|
17
|
+
import { resolveRecipeParams } from './parameters.js';
|
|
18
|
+
import { defaultObserverRefsFromManifest } from './passive-observations.js';
|
|
19
|
+
import { copyFileWithinRoots, statFileWithinRoot } from './path.js';
|
|
20
|
+
import { cleanupAbortedRunVideoRecording, removePartialRunVideoOutput, } from './recording-cleanup.js';
|
|
21
|
+
import { RecipeResolutionError } from './resolution-error.js';
|
|
22
|
+
import { buildRecipeExecutionPlan, enforceRecipeExecutionPlan, recipeSourceForRequest, verifyExecutableSource, } from './trust.js';
|
|
23
|
+
import { RECIPE_TRUST_ENV } from './trust-input.js';
|
|
24
|
+
import { assertManifestIsValid, assertRecipeMatchesManifest } from './validation.js';
|
|
25
|
+
const RUNNER_BUILT_IN_ACTIONS = new Set(['call', 'end']);
|
|
26
|
+
const noopLogger = {
|
|
27
|
+
info() { },
|
|
28
|
+
warn() { },
|
|
29
|
+
error() { },
|
|
30
|
+
};
|
|
31
|
+
export function defineActionAdapter(adapter) {
|
|
32
|
+
if (!adapter.action.trim())
|
|
33
|
+
throw new Error('Action adapter action must be a non-empty string.');
|
|
34
|
+
return adapter;
|
|
35
|
+
}
|
|
36
|
+
export function createRecipeRunner(options) {
|
|
37
|
+
assertManifestIsValid(options.actionManifest);
|
|
38
|
+
const declaredActions = new Set(getRecipeActionManifestActionNames(options.actionManifest));
|
|
39
|
+
const adapterMap = new Map();
|
|
40
|
+
for (const adapter of options.adapters) {
|
|
41
|
+
if (!adapter.action.trim())
|
|
42
|
+
throw new Error('Action adapter action must be a non-empty string.');
|
|
43
|
+
if (!declaredActions.has(adapter.action) && adapter.testOnly !== true) {
|
|
44
|
+
throw new Error(`Adapter ${adapter.action} is not declared by the recipe action manifest.`);
|
|
45
|
+
}
|
|
46
|
+
if (adapterMap.has(adapter.action)) {
|
|
47
|
+
throw new Error(`Adapter ${adapter.action} is registered more than once.`);
|
|
48
|
+
}
|
|
49
|
+
adapterMap.set(adapter.action, adapter);
|
|
50
|
+
}
|
|
51
|
+
for (const action of declaredActions) {
|
|
52
|
+
if (!adapterMap.has(action) && !RUNNER_BUILT_IN_ACTIONS.has(action)) {
|
|
53
|
+
throw new Error(`Manifest action ${action} has no registered adapter.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return new DefaultRecipeRunner(options.actionManifest, adapterMap, options.logger ?? noopLogger, options.hud, options.runner, options.recording, options.defaultSource, options.blockedCapabilities);
|
|
57
|
+
}
|
|
58
|
+
function collectWatchLogPaths(paths, nodes) {
|
|
59
|
+
for (const node of Object.values(nodes)) {
|
|
60
|
+
if (!isRecord(node) || node.action !== 'watch_logs' || typeof node.path !== 'string')
|
|
61
|
+
continue;
|
|
62
|
+
paths.add(normalizeRelativePath(node.path).split(path.sep).join('/'));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function collectRunFileOffsets({ graph, recipes, projectRoot, }) {
|
|
66
|
+
const offsets = new Map();
|
|
67
|
+
const paths = new Set();
|
|
68
|
+
collectWatchLogPaths(paths, graph.nodes);
|
|
69
|
+
for (const recipe of recipes.values()) {
|
|
70
|
+
if (recipe.aliasFor)
|
|
71
|
+
continue;
|
|
72
|
+
collectWatchLogPaths(paths, extractWorkflowGraph(recipe.document).nodes);
|
|
73
|
+
}
|
|
74
|
+
for (const relativePath of paths) {
|
|
75
|
+
try {
|
|
76
|
+
const info = await statFileWithinRoot(projectRoot, relativePath);
|
|
77
|
+
offsets.set(relativePath, info.size);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!isNodeFileMissingError(error))
|
|
81
|
+
throw error;
|
|
82
|
+
offsets.set(relativePath, 0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return offsets;
|
|
86
|
+
}
|
|
87
|
+
function isNodeFileMissingError(error) {
|
|
88
|
+
return isRecord(error) && error.code === 'ENOENT';
|
|
89
|
+
}
|
|
90
|
+
class DefaultRecipeRunner {
|
|
91
|
+
#actionManifest;
|
|
92
|
+
#adapters;
|
|
93
|
+
#logger;
|
|
94
|
+
#defaultObserverRefs;
|
|
95
|
+
#hud;
|
|
96
|
+
#runnerProvenance;
|
|
97
|
+
#recording;
|
|
98
|
+
#defaultSource;
|
|
99
|
+
#blockedCapabilities;
|
|
100
|
+
constructor(actionManifest, adapters, logger, hud, runnerProvenance, recording, defaultSource, blockedCapabilities) {
|
|
101
|
+
this.#actionManifest = actionManifest;
|
|
102
|
+
this.#adapters = adapters;
|
|
103
|
+
this.#logger = logger;
|
|
104
|
+
this.#defaultObserverRefs = defaultObserverRefsFromManifest(actionManifest);
|
|
105
|
+
this.#hud = hud;
|
|
106
|
+
this.#runnerProvenance = runnerProvenance;
|
|
107
|
+
this.#recording = recording;
|
|
108
|
+
this.#defaultSource = defaultSource;
|
|
109
|
+
this.#blockedCapabilities = blockedCapabilities;
|
|
110
|
+
}
|
|
111
|
+
async preflight(request) {
|
|
112
|
+
const { executionPlan } = await this.#resolveRequest(request);
|
|
113
|
+
enforceRecipeExecutionPlan(executionPlan, request, this.#blockedCapabilities);
|
|
114
|
+
return executionPlan;
|
|
115
|
+
}
|
|
116
|
+
async #resolveRequest(request) {
|
|
117
|
+
if (!request.recipePath && request.recipeDocument == null) {
|
|
118
|
+
throw new Error('Recipe run requires recipePath or recipeDocument.');
|
|
119
|
+
}
|
|
120
|
+
const projectRoot = path.resolve(request.projectRoot ?? process.cwd());
|
|
121
|
+
const artifactsDir = path.resolve(request.artifactsDir);
|
|
122
|
+
const env = {
|
|
123
|
+
...(request.inheritProcessEnv === false ? {} : process.env),
|
|
124
|
+
...(request.env ?? {}),
|
|
125
|
+
};
|
|
126
|
+
const trustEnv = new Set(Object.values(RECIPE_TRUST_ENV));
|
|
127
|
+
for (const [key, value] of Object.entries(env)) {
|
|
128
|
+
if (value === undefined || trustEnv.has(key))
|
|
129
|
+
delete env[key];
|
|
130
|
+
}
|
|
131
|
+
const sourceRecipePath = request.recipePath ? path.resolve(request.recipePath) : undefined;
|
|
132
|
+
const recipe = request.recipeDocument ?? (await readJsonFile(sourceRecipePath));
|
|
133
|
+
if (!isRecord(recipe))
|
|
134
|
+
throw new Error('Recipe document must be an object.');
|
|
135
|
+
const recipeSource = recipeSourceForRequest(request, recipe, sourceRecipePath, this.#defaultSource);
|
|
136
|
+
const libraryResolution = request.librarySources && request.librarySources.length > 0
|
|
137
|
+
? await loadRecipeLibraries(request.librarySources, {
|
|
138
|
+
adapter: request.adapter,
|
|
139
|
+
...(request.packageVersions ? { packageVersions: request.packageVersions } : {}),
|
|
140
|
+
})
|
|
141
|
+
: undefined;
|
|
142
|
+
if (libraryResolution)
|
|
143
|
+
logRecipeLibrarySources(this.#logger, libraryResolution);
|
|
144
|
+
const recipes = libraryResolution?.recipes ?? new Map();
|
|
145
|
+
const externalRecipeIds = new Set(recipes.keys());
|
|
146
|
+
assertRecipeMatchesManifest(recipe, this.#actionManifest, {
|
|
147
|
+
externalRecipeIds,
|
|
148
|
+
adapter: request.adapter,
|
|
149
|
+
});
|
|
150
|
+
const graph = extractWorkflowGraph(recipe);
|
|
151
|
+
if (request.stopAfterNode !== undefined) {
|
|
152
|
+
const stoppable = [...graph.mainNodeIds].filter((nodeId) => graph.nodes[nodeId]?.action !== 'end');
|
|
153
|
+
if (!stoppable.includes(request.stopAfterNode)) {
|
|
154
|
+
throw new RecipeResolutionError('RECIPE_STOP_AFTER_NODE_INVALID', `stop-after-node ${request.stopAfterNode} is not a non-terminal node reachable from workflow.entry.`, `choose one of: ${stoppable.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const rootRef = rootRecipeRef(sourceRecipePath, recipes, recipeSource.digest);
|
|
158
|
+
const dependencyResolution = resolveRecipeDependencies({
|
|
159
|
+
rootRef,
|
|
160
|
+
root: recipe,
|
|
161
|
+
rootSource: recipeSource,
|
|
162
|
+
recipes,
|
|
163
|
+
});
|
|
164
|
+
if (libraryResolution) {
|
|
165
|
+
// Warn only about shadowed refs this run executes, not every winner in the libraries.
|
|
166
|
+
logRecipeLibraryShadows(this.#logger, libraryResolution, new Set([rootRef, ...dependencyResolution.recipes.keys()]));
|
|
167
|
+
}
|
|
168
|
+
if (request.selection) {
|
|
169
|
+
const { name, resolvedBy, source, file } = request.selection;
|
|
170
|
+
this.#logger.info(`Recipe ${name} selected by ${resolvedBy}: ${source} · ${file}`);
|
|
171
|
+
}
|
|
172
|
+
for (const resolved of dependencyResolution.recipes.values()) {
|
|
173
|
+
assertRecipeMatchesManifest(resolved.document, this.#actionManifest, {
|
|
174
|
+
externalRecipeIds,
|
|
175
|
+
adapter: request.adapter,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const params = resolveRecipeParams(rootRef, recipe, request.params ?? {});
|
|
179
|
+
validateRecipeDependencyParams({
|
|
180
|
+
root: recipe,
|
|
181
|
+
params,
|
|
182
|
+
recipes: dependencyResolution.recipes,
|
|
183
|
+
});
|
|
184
|
+
const executionPlan = buildRecipeExecutionPlan({
|
|
185
|
+
recipe,
|
|
186
|
+
params,
|
|
187
|
+
source: recipeSource,
|
|
188
|
+
recipes: dependencyResolution.recipes,
|
|
189
|
+
adapters: this.#adapters,
|
|
190
|
+
actionManifest: this.#actionManifest,
|
|
191
|
+
defaultObserverRefs: this.#defaultObserverRefs,
|
|
192
|
+
projectRoot,
|
|
193
|
+
artifactsDir,
|
|
194
|
+
env,
|
|
195
|
+
hud: this.#hud,
|
|
196
|
+
recordVideo: request.recordVideo,
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
projectRoot,
|
|
200
|
+
artifactsDir,
|
|
201
|
+
sourceRecipePath,
|
|
202
|
+
recipe,
|
|
203
|
+
rootRef,
|
|
204
|
+
params,
|
|
205
|
+
recipeSource,
|
|
206
|
+
env,
|
|
207
|
+
libraryResolution,
|
|
208
|
+
graph,
|
|
209
|
+
recipes: dependencyResolution.recipes,
|
|
210
|
+
dependencyResolution,
|
|
211
|
+
executionPlan,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async run(request) {
|
|
215
|
+
const { projectRoot, artifactsDir, recipe, rootRef, params, env, libraryResolution, graph, recipes, dependencyResolution, executionPlan, } = await this.#resolveRequest(request);
|
|
216
|
+
enforceRecipeExecutionPlan(executionPlan, request, this.#blockedCapabilities);
|
|
217
|
+
const startedAt = new Date();
|
|
218
|
+
await mkdir(artifactsDir, { recursive: true });
|
|
219
|
+
const runFileOffsets = await collectRunFileOffsets({
|
|
220
|
+
graph,
|
|
221
|
+
recipes,
|
|
222
|
+
projectRoot,
|
|
223
|
+
});
|
|
224
|
+
const artifactWriter = new JsonArtifactWriter(artifactsDir);
|
|
225
|
+
const traceWriter = new JsonTraceWriter(artifactsDir, this.#runnerProvenance);
|
|
226
|
+
const summaryWriter = new JsonSummaryWriter(artifactsDir);
|
|
227
|
+
let outputs = new Map();
|
|
228
|
+
const recipePath = await artifactWriter.copyRecipe(recipe);
|
|
229
|
+
const invocation = createRecipeInvocation(recipe, params, startedAt.toISOString());
|
|
230
|
+
await artifactWriter.writeInvocation(invocation);
|
|
231
|
+
for (const dependency of dependencyResolution.recipes.values()) {
|
|
232
|
+
await artifactWriter.copyResolvedRecipe(dependency.provenance.digest, dependency.document);
|
|
233
|
+
}
|
|
234
|
+
await artifactWriter.writeRecipeResolution(dependencyResolution.document);
|
|
235
|
+
let status = 'unknown';
|
|
236
|
+
const videoOptions = normalizeVideoRecordingOptions(request.recordVideo);
|
|
237
|
+
const videoRecorder = videoOptions.mode !== 'off' ? this.#videoRecorder() : undefined;
|
|
238
|
+
let runRecording;
|
|
239
|
+
let canExecute = true;
|
|
240
|
+
if (videoRecorder) {
|
|
241
|
+
try {
|
|
242
|
+
runRecording = await this.#startRunVideoRecording({
|
|
243
|
+
recorder: videoRecorder,
|
|
244
|
+
videoOptions,
|
|
245
|
+
recipe,
|
|
246
|
+
projectRoot,
|
|
247
|
+
artifactsDir,
|
|
248
|
+
env,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
const message = errorMessage(error);
|
|
253
|
+
const causeClass = recipeFailureCause(error, 'harness');
|
|
254
|
+
traceWriter.record({
|
|
255
|
+
nodeId: 'recipe-run:video',
|
|
256
|
+
action: 'record.video',
|
|
257
|
+
startedAt: startedAt.toISOString(),
|
|
258
|
+
endedAt: new Date().toISOString(),
|
|
259
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
260
|
+
ok: false,
|
|
261
|
+
cause_class: causeClass,
|
|
262
|
+
error: message,
|
|
263
|
+
});
|
|
264
|
+
this.#logger.error(`record.video start failed: ${message}`);
|
|
265
|
+
status = 'fail';
|
|
266
|
+
canExecute = false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
if (canExecute) {
|
|
271
|
+
const execution = await executeRecipe({
|
|
272
|
+
ref: rootRef,
|
|
273
|
+
adapter: request.adapter,
|
|
274
|
+
recipe,
|
|
275
|
+
params,
|
|
276
|
+
recipes,
|
|
277
|
+
actionManifest: this.#actionManifest,
|
|
278
|
+
adapters: this.#adapters,
|
|
279
|
+
traceWriter,
|
|
280
|
+
logger: this.#logger,
|
|
281
|
+
defaultObserverRefs: this.#defaultObserverRefs,
|
|
282
|
+
createContext: (nodeId, activeRecipe, activeOutputs, getOutput) => this.#createExecutionContext({
|
|
283
|
+
nodeId,
|
|
284
|
+
recipe: activeRecipe,
|
|
285
|
+
projectRoot,
|
|
286
|
+
artifactsDir,
|
|
287
|
+
env,
|
|
288
|
+
outputs: activeOutputs,
|
|
289
|
+
getOutput,
|
|
290
|
+
artifactWriter,
|
|
291
|
+
runFileOffsets,
|
|
292
|
+
recording: runRecording,
|
|
293
|
+
}),
|
|
294
|
+
registerArtifacts: (artifacts) => {
|
|
295
|
+
for (const artifact of artifacts)
|
|
296
|
+
artifactWriter.register(artifact);
|
|
297
|
+
},
|
|
298
|
+
publishHudProgress: (hudStatus, event) => this.#publishHudProgressOrRecord(traceWriter, hudStatus, event),
|
|
299
|
+
...(request.stopAfterNode !== undefined ? { stopAfterNode: request.stopAfterNode } : {}),
|
|
300
|
+
});
|
|
301
|
+
status = execution.status;
|
|
302
|
+
outputs = execution.outputs;
|
|
303
|
+
}
|
|
304
|
+
artifactWriter.register({
|
|
305
|
+
path: 'summary.json',
|
|
306
|
+
type: 'summary',
|
|
307
|
+
label: 'Run summary',
|
|
308
|
+
category: 'system',
|
|
309
|
+
});
|
|
310
|
+
artifactWriter.register({
|
|
311
|
+
path: 'trace.json',
|
|
312
|
+
type: 'trace',
|
|
313
|
+
label: 'Execution trace',
|
|
314
|
+
category: 'system',
|
|
315
|
+
});
|
|
316
|
+
if (status === 'pass') {
|
|
317
|
+
const runHudStartedAt = new Date();
|
|
318
|
+
try {
|
|
319
|
+
await this.#publishRunHud(status, {
|
|
320
|
+
recipe,
|
|
321
|
+
projectRoot,
|
|
322
|
+
artifactsDir,
|
|
323
|
+
env,
|
|
324
|
+
outputs,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
329
|
+
traceWriter.record({
|
|
330
|
+
nodeId: 'recipe-complete:hud',
|
|
331
|
+
action: 'app.hud',
|
|
332
|
+
startedAt: runHudStartedAt.toISOString(),
|
|
333
|
+
endedAt: new Date().toISOString(),
|
|
334
|
+
durationMs: Date.now() - runHudStartedAt.getTime(),
|
|
335
|
+
ok: false,
|
|
336
|
+
cause_class: 'harness',
|
|
337
|
+
error: message,
|
|
338
|
+
});
|
|
339
|
+
this.#logger.error(`app.hud complete update failed: ${message}`);
|
|
340
|
+
status = 'fail';
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (runRecording) {
|
|
344
|
+
const recordingToStop = runRecording;
|
|
345
|
+
runRecording = undefined;
|
|
346
|
+
try {
|
|
347
|
+
const videoArtifact = await this.#stopRunVideoRecording(recordingToStop, traceWriter.list(), artifactWriter);
|
|
348
|
+
artifactWriter.register(videoArtifact);
|
|
349
|
+
if (videoArtifact.timelinePath)
|
|
350
|
+
artifactWriter.register({
|
|
351
|
+
path: videoArtifact.timelinePath,
|
|
352
|
+
type: 'json',
|
|
353
|
+
category: 'system',
|
|
354
|
+
label: 'Recording frames and action markers',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
const message = errorMessage(error);
|
|
359
|
+
await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
|
|
360
|
+
await rm(recordingToStop.stagingRoot, { recursive: true, force: true });
|
|
361
|
+
traceWriter.record({
|
|
362
|
+
nodeId: 'recipe-run:video',
|
|
363
|
+
action: 'record.video',
|
|
364
|
+
startedAt: startedAt.toISOString(),
|
|
365
|
+
endedAt: new Date().toISOString(),
|
|
366
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
367
|
+
ok: false,
|
|
368
|
+
cause_class: recipeFailureCause(error, 'harness'),
|
|
369
|
+
error: message,
|
|
370
|
+
});
|
|
371
|
+
this.#logger.error(`record.video failed: ${message}`);
|
|
372
|
+
status = 'fail';
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
if (runRecording) {
|
|
378
|
+
const recordingToCleanup = runRecording;
|
|
379
|
+
runRecording = undefined;
|
|
380
|
+
await cleanupAbortedRunVideoRecording(recordingToCleanup, this.#logger);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const endedAt = new Date();
|
|
384
|
+
const tracePath = await traceWriter.write();
|
|
385
|
+
const trace = traceWriter.list();
|
|
386
|
+
const causeCounts = {
|
|
387
|
+
subject: trace.filter((entry) => !entry.ok && entry.cause_class === 'subject').length,
|
|
388
|
+
harness: trace.filter((entry) => !entry.ok && entry.cause_class === 'harness').length,
|
|
389
|
+
environment: trace.filter((entry) => !entry.ok && entry.cause_class === 'environment').length,
|
|
390
|
+
unknown: trace.filter((entry) => !entry.ok && entry.cause_class === 'unknown').length,
|
|
391
|
+
};
|
|
392
|
+
const summary = {
|
|
393
|
+
invocationDigest: digestRecipeDocument(invocation),
|
|
394
|
+
status,
|
|
395
|
+
total: trace.length,
|
|
396
|
+
passed: trace.filter((entry) => entry.ok).length,
|
|
397
|
+
failed: trace.filter((entry) => !entry.ok).length,
|
|
398
|
+
cause_counts: causeCounts,
|
|
399
|
+
startedAt: startedAt.toISOString(),
|
|
400
|
+
endedAt: endedAt.toISOString(),
|
|
401
|
+
durationMs: endedAt.getTime() - startedAt.getTime(),
|
|
402
|
+
harness: {
|
|
403
|
+
name: '@farmslot/recipe-runner',
|
|
404
|
+
version: RECIPE_RUNNER_VERSION,
|
|
405
|
+
action_manifest_schema: this.#actionManifest.$schema,
|
|
406
|
+
},
|
|
407
|
+
...(this.#runnerProvenance ? { runner: this.#runnerProvenance } : {}),
|
|
408
|
+
...(libraryResolution
|
|
409
|
+
? { recipeLibraries: buildRecipeLibrarySummary(libraryResolution) }
|
|
410
|
+
: {}),
|
|
411
|
+
...(request.selection ? { recipeSelection: request.selection } : {}),
|
|
412
|
+
...(request.stopAfterNode !== undefined ? { stopAfterNode: request.stopAfterNode } : {}),
|
|
413
|
+
};
|
|
414
|
+
const summaryPath = await summaryWriter.write(summary);
|
|
415
|
+
const artifactManifestPath = await artifactWriter.write(status, this.#runnerProvenance);
|
|
416
|
+
const packageValidation = validateRecipeArtifactPackage({
|
|
417
|
+
recipe,
|
|
418
|
+
resolvedRecipes: Object.fromEntries([...dependencyResolution.recipes.values()].map((entry) => [
|
|
419
|
+
entry.provenance.digest,
|
|
420
|
+
entry.document,
|
|
421
|
+
])),
|
|
422
|
+
recipeResolution: dependencyResolution.document,
|
|
423
|
+
trace,
|
|
424
|
+
summary,
|
|
425
|
+
manifest: {
|
|
426
|
+
version: 1,
|
|
427
|
+
runStatus: status,
|
|
428
|
+
...(this.#runnerProvenance ? { provenance: { runner: this.#runnerProvenance } } : {}),
|
|
429
|
+
artifacts: artifactWriter.list(),
|
|
430
|
+
},
|
|
431
|
+
artifactPaths: [
|
|
432
|
+
'recipe.json',
|
|
433
|
+
'summary.json',
|
|
434
|
+
'trace.json',
|
|
435
|
+
'artifact-manifest.json',
|
|
436
|
+
'recipe-resolution.json',
|
|
437
|
+
...artifactWriter.list().map((entry) => entry.path),
|
|
438
|
+
],
|
|
439
|
+
});
|
|
440
|
+
if (packageValidation.status === 'invalid') {
|
|
441
|
+
throw new Error(`Generated artifact package is invalid: ${packageValidation.findings
|
|
442
|
+
.map((finding) => `${finding.code} ${finding.path}`)
|
|
443
|
+
.join(', ')}`);
|
|
444
|
+
}
|
|
445
|
+
return { status, summaryPath, tracePath, artifactManifestPath, recipePath };
|
|
446
|
+
}
|
|
447
|
+
#createExecutionContext({ nodeId, recipe, projectRoot, artifactsDir, env, outputs, getOutput, artifactWriter, runFileOffsets, recording, }) {
|
|
448
|
+
return {
|
|
449
|
+
nodeId,
|
|
450
|
+
recipe,
|
|
451
|
+
projectRoot,
|
|
452
|
+
artifactsDir,
|
|
453
|
+
env,
|
|
454
|
+
outputs,
|
|
455
|
+
getOutput,
|
|
456
|
+
...(recording?.recording.snapshot
|
|
457
|
+
? {
|
|
458
|
+
captureRecordingSnapshot: async (relativePath) => {
|
|
459
|
+
const relative = normalizeRelativePath(relativePath);
|
|
460
|
+
if (!relative.endsWith('.png'))
|
|
461
|
+
throw new Error('Recording snapshots must use a PNG artifact path.');
|
|
462
|
+
const dir = await mkdtemp(path.join(recording.stagingRoot, 'snapshot-'));
|
|
463
|
+
const staged = path.join(dir, 'frame.png');
|
|
464
|
+
const evidence = await recording.recording.snapshot(staged);
|
|
465
|
+
await copyFileWithinRoots(dir, 'frame.png', artifactsDir, relative);
|
|
466
|
+
// Keep the provider's source path in its raw sidecar; the action output
|
|
467
|
+
// maps it to the published artifact path without rewriting capture facts.
|
|
468
|
+
return { ...evidence, sourceOutput: evidence.output, output: relative };
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
: {}),
|
|
472
|
+
resolveProjectPath(relativePath) {
|
|
473
|
+
return path.join(projectRoot, normalizeRelativePath(relativePath));
|
|
474
|
+
},
|
|
475
|
+
resolveArtifactPath(relativePath) {
|
|
476
|
+
return path.join(artifactsDir, normalizeRelativePath(relativePath));
|
|
477
|
+
},
|
|
478
|
+
getRunFileOffset(relativePath) {
|
|
479
|
+
return runFileOffsets.get(normalizeRelativePath(relativePath).split(path.sep).join('/'));
|
|
480
|
+
},
|
|
481
|
+
registerArtifact(entry) {
|
|
482
|
+
artifactWriter.register(entry);
|
|
483
|
+
},
|
|
484
|
+
logger: this.#logger,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
#videoRecorder() {
|
|
488
|
+
return this.#recording?.videoRecorder ?? createCaptureHelperVideoRecorder();
|
|
489
|
+
}
|
|
490
|
+
async #startRunVideoRecording({ recorder, videoOptions, recipe, projectRoot, artifactsDir, env, }) {
|
|
491
|
+
const doctor = await recorder.doctor?.();
|
|
492
|
+
if (doctor && !doctor.ok) {
|
|
493
|
+
throw new RecipeExecutionError('environment', `${recorder.name} doctor ${doctor.code}: ${doctor.message}${doctor.suggestedFix ? ` Suggested fix: ${doctor.suggestedFix}` : ''}`);
|
|
494
|
+
}
|
|
495
|
+
const node = {
|
|
496
|
+
action: 'record.video',
|
|
497
|
+
intent: 'Record the recipe run when motion proof is useful',
|
|
498
|
+
};
|
|
499
|
+
const target = videoOptions.target ??
|
|
500
|
+
(await this.#recording?.targetProvider?.resolveRecordingTarget({
|
|
501
|
+
nodeId: 'recipe-run',
|
|
502
|
+
node,
|
|
503
|
+
recipe,
|
|
504
|
+
projectRoot,
|
|
505
|
+
artifactsDir,
|
|
506
|
+
env,
|
|
507
|
+
scope: 'run',
|
|
508
|
+
}));
|
|
509
|
+
if (!target) {
|
|
510
|
+
throw new Error('--record-video requires a recording target. Provide a project RecordingTargetProvider or CLI target flags.');
|
|
511
|
+
}
|
|
512
|
+
const relativePath = 'videos/recipe-run.mp4';
|
|
513
|
+
const stagingRoot = await mkdtemp(path.join(os.tmpdir(), 'farmslot-recipe-video-'));
|
|
514
|
+
const stagingRelativePath = 'recipe-run.mp4';
|
|
515
|
+
const outputPath = path.join(stagingRoot, stagingRelativePath);
|
|
516
|
+
let recording;
|
|
517
|
+
try {
|
|
518
|
+
recording = await recorder.start({
|
|
519
|
+
outputPath,
|
|
520
|
+
target,
|
|
521
|
+
maxFps: videoOptions.maxFps,
|
|
522
|
+
maxSize: videoOptions.maxSize,
|
|
523
|
+
nodeId: 'recipe-run',
|
|
524
|
+
record: 'full_run',
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
await removePartialRunVideoOutput(outputPath, this.#logger);
|
|
529
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
const entry = {
|
|
533
|
+
path: relativePath,
|
|
534
|
+
type: 'video',
|
|
535
|
+
mimeType: 'video/mp4',
|
|
536
|
+
category: 'proof',
|
|
537
|
+
label: 'Recipe run video',
|
|
538
|
+
record: 'full_run',
|
|
539
|
+
recorder: {
|
|
540
|
+
name: recorder.name,
|
|
541
|
+
...(recorder.version ? { version: recorder.version } : {}),
|
|
542
|
+
...(recorder.platform ? { platform: recorder.platform } : {}),
|
|
543
|
+
target: manifestTarget(target),
|
|
544
|
+
},
|
|
545
|
+
...(videoOptions.maxFps != null ? { maxFps: videoOptions.maxFps } : {}),
|
|
546
|
+
};
|
|
547
|
+
return {
|
|
548
|
+
recording,
|
|
549
|
+
entry,
|
|
550
|
+
outputPath,
|
|
551
|
+
stagingRoot,
|
|
552
|
+
stagingRelativePath,
|
|
553
|
+
artifactsDir,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
async #stopRunVideoRecording(runRecording, trace, artifactWriter) {
|
|
557
|
+
const result = await runRecording.recording.stop();
|
|
558
|
+
await assertVideoOutputReady(runRecording.outputPath);
|
|
559
|
+
await copyFileWithinRoots(runRecording.stagingRoot, runRecording.stagingRelativePath, runRecording.artifactsDir, runRecording.entry.path);
|
|
560
|
+
if (result.timingEvidencePath && result.timing) {
|
|
561
|
+
const evidencePath = `${runRecording.entry.path}.capture.json`;
|
|
562
|
+
await copyFileWithinRoots(runRecording.stagingRoot, result.timingEvidencePath, runRecording.artifactsDir, evidencePath);
|
|
563
|
+
artifactWriter.register({
|
|
564
|
+
path: evidencePath,
|
|
565
|
+
type: 'json',
|
|
566
|
+
category: 'system',
|
|
567
|
+
label: 'Native recording and snapshot timing',
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
await rm(runRecording.stagingRoot, { recursive: true, force: true });
|
|
571
|
+
const entry = {
|
|
572
|
+
...runRecording.entry,
|
|
573
|
+
...(result.recorder ? { recorder: result.recorder } : {}),
|
|
574
|
+
};
|
|
575
|
+
if (result.timing) {
|
|
576
|
+
try {
|
|
577
|
+
entry.timelinePath = await writeRecordingTimeline(runRecording.artifactsDir, entry.path, result.timing, trace);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
// Video is retained; its optional timing capability failed explicitly.
|
|
581
|
+
entry.timelineUnavailableReason = errorMessage(error);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
else
|
|
585
|
+
entry.timelineUnavailableReason =
|
|
586
|
+
result.timingUnavailableReason ?? 'Recorder does not provide timeline alignment.';
|
|
587
|
+
return entry;
|
|
588
|
+
}
|
|
589
|
+
#hudAction() {
|
|
590
|
+
if (this.#hud === false || this.#hud?.enabled === false)
|
|
591
|
+
return undefined;
|
|
592
|
+
return this.#adapters.has('app.hud') ? 'app.hud' : undefined;
|
|
593
|
+
}
|
|
594
|
+
async #publishHudProgressOrRecord(traceWriter, status, event) {
|
|
595
|
+
const startedAt = new Date();
|
|
596
|
+
try {
|
|
597
|
+
await this.#publishHudProgress(status, event);
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
// HUD is first-class when advertised, so rendering failures fail the run.
|
|
602
|
+
// They are still recorded instead of aborting artifact package writing.
|
|
603
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
604
|
+
traceWriter.record({
|
|
605
|
+
nodeId: `${event.nodeId}:hud:${status}`,
|
|
606
|
+
action: 'app.hud',
|
|
607
|
+
startedAt: startedAt.toISOString(),
|
|
608
|
+
endedAt: new Date().toISOString(),
|
|
609
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
610
|
+
ok: false,
|
|
611
|
+
cause_class: 'harness',
|
|
612
|
+
error: message,
|
|
613
|
+
});
|
|
614
|
+
this.#logger.error(`app.hud ${status} update failed for ${event.nodeId}: ${message}`);
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
async #publishHudProgress(status, event) {
|
|
619
|
+
const hudAction = this.#hudAction();
|
|
620
|
+
if (!hudAction || event.action === hudAction)
|
|
621
|
+
return;
|
|
622
|
+
const adapter = this.#adapters.get(hudAction);
|
|
623
|
+
if (!adapter)
|
|
624
|
+
return;
|
|
625
|
+
await verifyExecutableSource(adapter, `Action ${hudAction}`);
|
|
626
|
+
await adapter.execute(buildHudNode(status, event, this.#hud), event.context);
|
|
627
|
+
}
|
|
628
|
+
async #publishRunHud(status, request) {
|
|
629
|
+
const hudAction = this.#hudAction();
|
|
630
|
+
if (!hudAction)
|
|
631
|
+
return;
|
|
632
|
+
const adapter = this.#adapters.get(hudAction);
|
|
633
|
+
if (!adapter)
|
|
634
|
+
return;
|
|
635
|
+
await verifyExecutableSource(adapter, `Action ${hudAction}`);
|
|
636
|
+
const context = {
|
|
637
|
+
nodeId: 'recipe-complete',
|
|
638
|
+
recipe: request.recipe,
|
|
639
|
+
projectRoot: request.projectRoot,
|
|
640
|
+
artifactsDir: request.artifactsDir,
|
|
641
|
+
env: request.env,
|
|
642
|
+
outputs: request.outputs,
|
|
643
|
+
getOutput(nodeId) {
|
|
644
|
+
if (!request.outputs.has(nodeId))
|
|
645
|
+
throw new Error(`No output recorded for node ${nodeId}.`);
|
|
646
|
+
return request.outputs.get(nodeId);
|
|
647
|
+
},
|
|
648
|
+
resolveProjectPath(relativePath) {
|
|
649
|
+
return path.join(request.projectRoot, normalizeRelativePath(relativePath));
|
|
650
|
+
},
|
|
651
|
+
resolveArtifactPath(relativePath) {
|
|
652
|
+
return path.join(request.artifactsDir, normalizeRelativePath(relativePath));
|
|
653
|
+
},
|
|
654
|
+
getRunFileOffset() {
|
|
655
|
+
return undefined;
|
|
656
|
+
},
|
|
657
|
+
registerArtifact() { },
|
|
658
|
+
logger: this.#logger,
|
|
659
|
+
};
|
|
660
|
+
await adapter.execute({
|
|
661
|
+
action: hudAction,
|
|
662
|
+
title: this.#hudTitle(request.recipe),
|
|
663
|
+
status,
|
|
664
|
+
node_id: 'recipe-complete',
|
|
665
|
+
phase: 'complete',
|
|
666
|
+
flow: 'run',
|
|
667
|
+
detail: status === 'pass' ? 'Recipe completed' : 'Recipe failed',
|
|
668
|
+
text: status === 'pass' ? 'Recipe completed' : 'Recipe failed',
|
|
669
|
+
progress: { complete: true },
|
|
670
|
+
}, context);
|
|
671
|
+
}
|
|
672
|
+
#hudTitle(recipe) {
|
|
673
|
+
if (this.#hud && this.#hud.title)
|
|
674
|
+
return this.#hud.title;
|
|
675
|
+
if (isRecord(recipe) && typeof recipe.title === 'string')
|
|
676
|
+
return recipe.title;
|
|
677
|
+
return 'Recipe run';
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function buildRecipeLibrarySummary(resolution) {
|
|
681
|
+
return {
|
|
682
|
+
sources: resolution.sources,
|
|
683
|
+
shadowed: collectShadowedRecipes(resolution),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
function collectShadowedRecipes(resolution) {
|
|
687
|
+
return [...resolution.recipes.values()]
|
|
688
|
+
.filter((recipe) => !recipe.aliasFor)
|
|
689
|
+
.filter((recipe) => recipe.shadows.length > 0)
|
|
690
|
+
.map((recipe) => ({
|
|
691
|
+
ref: recipe.ref,
|
|
692
|
+
source: recipe.source,
|
|
693
|
+
file: recipe.file,
|
|
694
|
+
shadows: recipe.shadows,
|
|
695
|
+
}));
|
|
696
|
+
}
|
|
697
|
+
function rootRecipeRef(sourceRecipePath, recipes, digest) {
|
|
698
|
+
if (sourceRecipePath) {
|
|
699
|
+
const resolvedPath = path.resolve(sourceRecipePath);
|
|
700
|
+
const match = [...recipes.values()].find((recipe) => !recipe.aliasFor && path.resolve(recipe.path) === resolvedPath);
|
|
701
|
+
if (match)
|
|
702
|
+
return match.ref;
|
|
703
|
+
return rootResolutionRef(digest);
|
|
704
|
+
}
|
|
705
|
+
return rootResolutionRef(digest);
|
|
706
|
+
}
|
|
707
|
+
function normalizeVideoRecordingOptions(input) {
|
|
708
|
+
if (!input)
|
|
709
|
+
return { mode: 'off' };
|
|
710
|
+
if (input === true)
|
|
711
|
+
return { mode: 'full-run' };
|
|
712
|
+
if (typeof input === 'string')
|
|
713
|
+
return normalizeVideoRecordingMode(input);
|
|
714
|
+
const mode = input.mode ?? 'full-run';
|
|
715
|
+
return { ...input, mode: normalizeVideoRecordingMode(mode).mode };
|
|
716
|
+
}
|
|
717
|
+
function normalizeVideoRecordingMode(mode) {
|
|
718
|
+
if (mode === 'off')
|
|
719
|
+
return { mode: 'off' };
|
|
720
|
+
if (mode === 'full-run')
|
|
721
|
+
return { mode: 'full-run' };
|
|
722
|
+
if (mode === 'proof-window' || mode === 'proof_window') {
|
|
723
|
+
throw new Error('recordVideo proof-window mode is reserved for future focused clips; use full-run for phase 1.');
|
|
724
|
+
}
|
|
725
|
+
throw new Error(`recordVideo mode must be full-run or off, got ${JSON.stringify(mode)}.`);
|
|
726
|
+
}
|
|
727
|
+
async function assertVideoOutputReady(outputPath) {
|
|
728
|
+
let stats;
|
|
729
|
+
try {
|
|
730
|
+
stats = await stat(outputPath);
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
throw new Error(`Recording output is missing: ${outputPath}`);
|
|
734
|
+
}
|
|
735
|
+
if (stats.size <= 0)
|
|
736
|
+
throw new Error(`Recording output is empty: ${outputPath}`);
|
|
737
|
+
}
|
|
738
|
+
//# sourceMappingURL=runner.js.map
|