@farmslot/recipe-harness 0.7.0 → 0.9.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/README.md +83 -182
- package/dist/adapters/app-lifecycle.d.ts.map +1 -1
- package/dist/adapters/app-lifecycle.js +5 -0
- package/dist/adapters/app-lifecycle.js.map +1 -1
- package/dist/adapters/core.d.ts.map +1 -1
- package/dist/adapters/core.js +25 -46
- package/dist/adapters/core.js.map +1 -1
- package/dist/adapters/ui.d.ts +0 -2
- package/dist/adapters/ui.d.ts.map +1 -1
- package/dist/adapters/ui.js +5 -2
- package/dist/adapters/ui.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +0 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/run-command.d.ts.map +1 -1
- package/dist/cli/run-command.js +197 -32
- package/dist/cli/run-command.js.map +1 -1
- package/dist/cli/support.d.ts +5 -2
- package/dist/cli/support.d.ts.map +1 -1
- package/dist/cli/support.js +131 -25
- package/dist/cli/support.js.map +1 -1
- package/dist/cli/validate-command.d.ts.map +1 -1
- package/dist/cli/validate-command.js +4 -1
- package/dist/cli/validate-command.js.map +1 -1
- package/dist/core/compose.d.ts +21 -31
- package/dist/core/compose.d.ts.map +1 -1
- package/dist/core/compose.js +93 -125
- package/dist/core/compose.js.map +1 -1
- package/dist/core/execution.d.ts +40 -0
- package/dist/core/execution.d.ts.map +1 -0
- package/dist/core/execution.js +263 -0
- package/dist/core/execution.js.map +1 -0
- package/dist/core/graph.d.ts +4 -5
- package/dist/core/graph.d.ts.map +1 -1
- package/dist/core/graph.js +51 -103
- package/dist/core/graph.js.map +1 -1
- package/dist/core/hud.d.ts.map +1 -1
- package/dist/core/hud.js +2 -6
- package/dist/core/hud.js.map +1 -1
- package/dist/core/library.d.ts +17 -51
- package/dist/core/library.d.ts.map +1 -1
- package/dist/core/library.js +144 -155
- package/dist/core/library.js.map +1 -1
- 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 +2 -5
- package/dist/core/passive-observations.d.ts.map +1 -1
- package/dist/core/passive-observations.js +6 -40
- package/dist/core/passive-observations.js.map +1 -1
- package/dist/core/path.d.ts +6 -0
- package/dist/core/path.d.ts.map +1 -0
- package/dist/core/path.js +126 -0
- package/dist/core/path.js.map +1 -0
- package/dist/core/recording-cleanup.d.ts +1 -0
- package/dist/core/recording-cleanup.d.ts.map +1 -1
- package/dist/core/recording-cleanup.js +8 -0
- package/dist/core/recording-cleanup.js.map +1 -1
- 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.map +1 -1
- package/dist/core/runner.js +178 -373
- package/dist/core/runner.js.map +1 -1
- package/dist/core/trace.d.ts +3 -4
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/trace.js +3 -4
- package/dist/core/trace.js.map +1 -1
- 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 +20 -0
- package/dist/core/trust.d.ts.map +1 -0
- package/dist/core/trust.js +231 -0
- package/dist/core/trust.js.map +1 -0
- package/dist/core/types.d.ts +41 -47
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/node/writers.d.ts +3 -8
- package/dist/node/writers.d.ts.map +1 -1
- package/dist/node/writers.js +27 -26
- package/dist/node/writers.js.map +1 -1
- package/dist/recording/cdp-video-recorder.js +2 -3
- package/dist/recording/cdp-video-recorder.js.map +1 -1
- package/dist/runtime/cdp.d.ts.map +1 -1
- package/dist/runtime/cdp.js +3 -7
- package/dist/runtime/cdp.js.map +1 -1
- package/package.json +2 -2
- package/dist/cli/flows-command.d.ts +0 -3
- package/dist/cli/flows-command.d.ts.map +0 -1
- package/dist/cli/flows-command.js +0 -313
- package/dist/cli/flows-command.js.map +0 -1
- package/dist/core/flows.d.ts +0 -45
- package/dist/core/flows.d.ts.map +0 -1
- package/dist/core/flows.js +0 -359
- package/dist/core/flows.js.map +0 -1
- package/dist/core/predicates.d.ts +0 -7
- package/dist/core/predicates.d.ts.map +0 -1
- package/dist/core/predicates.js +0 -102
- package/dist/core/predicates.js.map +0 -1
- package/dist/core/promote.d.ts +0 -41
- package/dist/core/promote.d.ts.map +0 -1
- package/dist/core/promote.js +0 -249
- package/dist/core/promote.js.map +0 -1
package/dist/core/runner.js
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { getRecipeActionManifestActionNames, validateRecipeArtifactPackage, } from '@farmslot/protocol';
|
|
4
5
|
import { JsonArtifactWriter, JsonSummaryWriter, JsonTraceWriter } from '../node/writers.js';
|
|
5
6
|
import { createCaptureHelperVideoRecorder, errorMessage, manifestTarget, } from '../recording/capture-helper.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { extractWorkflowGraph
|
|
7
|
+
import { resolveRecipeDependencies, rootResolutionRef, validateRecipeDependencyParams, } from './compose.js';
|
|
8
|
+
import { executeRecipe } from './execution.js';
|
|
9
|
+
import { extractWorkflowGraph } from './graph.js';
|
|
9
10
|
import { buildHudNode } from './hud.js';
|
|
10
11
|
import { isRecord, normalizeRelativePath, readJsonFile } from './json.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { loadRecipeLibraries, } from './library.js';
|
|
13
|
+
import { resolveRecipeParams } from './parameters.js';
|
|
14
|
+
import { defaultObserverRefsFromManifest } from './passive-observations.js';
|
|
15
|
+
import { copyFileWithinRoots, statFileWithinRoot } from './path.js';
|
|
14
16
|
import { cleanupAbortedRunVideoRecording, removePartialRunVideoOutput, } from './recording-cleanup.js';
|
|
15
|
-
import {
|
|
17
|
+
import { buildRecipeExecutionPlan, enforceRecipeExecutionPlan, recipeSourceForRequest, verifyExecutableSource, } from './trust.js';
|
|
18
|
+
import { RECIPE_TRUST_ENV } from './trust-input.js';
|
|
16
19
|
import { assertManifestIsValid, assertRecipeMatchesManifest } from './validation.js';
|
|
17
20
|
const HARNESS_VERSION = '0.1.0';
|
|
18
|
-
const RUNNER_BUILT_IN_ACTIONS = new Set(['call']);
|
|
19
|
-
const DEFAULT_MAX_FLOW_CALL_DEPTH = 8;
|
|
21
|
+
const RUNNER_BUILT_IN_ACTIONS = new Set(['call', 'end']);
|
|
20
22
|
const noopLogger = {
|
|
21
23
|
info() { },
|
|
22
24
|
warn() { },
|
|
@@ -31,19 +33,6 @@ export function createRecipeRunner(options) {
|
|
|
31
33
|
assertManifestIsValid(options.actionManifest);
|
|
32
34
|
const declaredActions = new Set(getRecipeActionManifestActionNames(options.actionManifest));
|
|
33
35
|
const adapterMap = new Map();
|
|
34
|
-
const preconditionMap = new Map();
|
|
35
|
-
const declaredPreconditions = new Set((options.actionManifest.pre_conditions ?? []).map((entry) => entry.id));
|
|
36
|
-
for (const checker of options.preconditions ?? []) {
|
|
37
|
-
if (!checker.id.trim())
|
|
38
|
-
throw new Error('Precondition checker id must be a non-empty string.');
|
|
39
|
-
if (!declaredPreconditions.has(checker.id)) {
|
|
40
|
-
throw new Error(`Precondition checker ${checker.id} is not declared by the action manifest.`);
|
|
41
|
-
}
|
|
42
|
-
if (preconditionMap.has(checker.id)) {
|
|
43
|
-
throw new Error(`Precondition checker ${checker.id} is registered more than once.`);
|
|
44
|
-
}
|
|
45
|
-
preconditionMap.set(checker.id, checker);
|
|
46
|
-
}
|
|
47
36
|
for (const adapter of options.adapters) {
|
|
48
37
|
if (!adapter.action.trim())
|
|
49
38
|
throw new Error('Action adapter action must be a non-empty string.');
|
|
@@ -60,7 +49,7 @@ export function createRecipeRunner(options) {
|
|
|
60
49
|
throw new Error(`Manifest action ${action} has no registered adapter.`);
|
|
61
50
|
}
|
|
62
51
|
}
|
|
63
|
-
return new DefaultRecipeRunner(options.actionManifest, adapterMap,
|
|
52
|
+
return new DefaultRecipeRunner(options.actionManifest, adapterMap, options.logger ?? noopLogger, options.hud, options.runner, options.recording, options.defaultSource, options.blockedCapabilities);
|
|
64
53
|
}
|
|
65
54
|
function collectWatchLogPaths(paths, nodes) {
|
|
66
55
|
for (const node of Object.values(nodes)) {
|
|
@@ -69,15 +58,16 @@ function collectWatchLogPaths(paths, nodes) {
|
|
|
69
58
|
paths.add(normalizeRelativePath(node.path).split(path.sep).join('/'));
|
|
70
59
|
}
|
|
71
60
|
}
|
|
72
|
-
async function collectRunFileOffsets({ graph,
|
|
61
|
+
async function collectRunFileOffsets({ graph, recipes, projectRoot, }) {
|
|
73
62
|
const offsets = new Map();
|
|
74
63
|
const paths = new Set();
|
|
75
64
|
collectWatchLogPaths(paths, graph.nodes);
|
|
76
|
-
for (const
|
|
77
|
-
collectWatchLogPaths(paths,
|
|
65
|
+
for (const recipe of recipes.values()) {
|
|
66
|
+
collectWatchLogPaths(paths, extractWorkflowGraph(recipe.document).nodes);
|
|
67
|
+
}
|
|
78
68
|
for (const relativePath of paths) {
|
|
79
69
|
try {
|
|
80
|
-
const info = await
|
|
70
|
+
const info = await statFileWithinRoot(projectRoot, relativePath);
|
|
81
71
|
offsets.set(relativePath, info.size);
|
|
82
72
|
}
|
|
83
73
|
catch (error) {
|
|
@@ -94,73 +84,128 @@ function isNodeFileMissingError(error) {
|
|
|
94
84
|
class DefaultRecipeRunner {
|
|
95
85
|
#actionManifest;
|
|
96
86
|
#adapters;
|
|
97
|
-
#preconditions;
|
|
98
87
|
#logger;
|
|
99
88
|
#defaultObserverRefs;
|
|
100
|
-
#declaredObserverRefs;
|
|
101
89
|
#hud;
|
|
102
90
|
#runnerProvenance;
|
|
103
91
|
#recording;
|
|
104
|
-
|
|
92
|
+
#defaultSource;
|
|
93
|
+
#blockedCapabilities;
|
|
94
|
+
constructor(actionManifest, adapters, logger, hud, runnerProvenance, recording, defaultSource, blockedCapabilities) {
|
|
105
95
|
this.#actionManifest = actionManifest;
|
|
106
96
|
this.#adapters = adapters;
|
|
107
|
-
this.#preconditions = preconditions;
|
|
108
97
|
this.#logger = logger;
|
|
109
98
|
this.#defaultObserverRefs = defaultObserverRefsFromManifest(actionManifest);
|
|
110
|
-
this.#declaredObserverRefs = declaredObserverRefsFromManifest(actionManifest);
|
|
111
99
|
this.#hud = hud;
|
|
112
100
|
this.#runnerProvenance = runnerProvenance;
|
|
113
101
|
this.#recording = recording;
|
|
102
|
+
this.#defaultSource = defaultSource;
|
|
103
|
+
this.#blockedCapabilities = blockedCapabilities;
|
|
114
104
|
}
|
|
115
|
-
async
|
|
105
|
+
async preflight(request) {
|
|
106
|
+
const { executionPlan } = await this.#resolveRequest(request);
|
|
107
|
+
enforceRecipeExecutionPlan(executionPlan, request, this.#blockedCapabilities);
|
|
108
|
+
return executionPlan;
|
|
109
|
+
}
|
|
110
|
+
async #resolveRequest(request) {
|
|
116
111
|
if (!request.recipePath && request.recipeDocument == null) {
|
|
117
112
|
throw new Error('Recipe run requires recipePath or recipeDocument.');
|
|
118
113
|
}
|
|
119
114
|
const projectRoot = path.resolve(request.projectRoot ?? process.cwd());
|
|
120
115
|
const artifactsDir = path.resolve(request.artifactsDir);
|
|
121
|
-
const
|
|
122
|
-
|
|
116
|
+
const env = {
|
|
117
|
+
...(request.inheritProcessEnv === false ? {} : process.env),
|
|
118
|
+
...(request.env ?? {}),
|
|
119
|
+
};
|
|
120
|
+
const trustEnv = new Set(Object.values(RECIPE_TRUST_ENV));
|
|
121
|
+
for (const [key, value] of Object.entries(env)) {
|
|
122
|
+
if (value === undefined || trustEnv.has(key))
|
|
123
|
+
delete env[key];
|
|
124
|
+
}
|
|
123
125
|
const sourceRecipePath = request.recipePath ? path.resolve(request.recipePath) : undefined;
|
|
124
126
|
const recipe = request.recipeDocument ?? (await readJsonFile(sourceRecipePath));
|
|
127
|
+
if (!isRecord(recipe))
|
|
128
|
+
throw new Error('Recipe document must be an object.');
|
|
129
|
+
const recipeSource = recipeSourceForRequest(request, recipe, sourceRecipePath, this.#defaultSource);
|
|
125
130
|
const libraryResolution = request.librarySources && request.librarySources.length > 0
|
|
126
|
-
? await loadRecipeLibraries(request.librarySources,
|
|
131
|
+
? await loadRecipeLibraries(request.librarySources, {
|
|
132
|
+
adapter: request.adapter,
|
|
133
|
+
logger: this.#logger,
|
|
134
|
+
})
|
|
127
135
|
: undefined;
|
|
128
|
-
|
|
136
|
+
const recipes = libraryResolution?.recipes ?? new Map();
|
|
137
|
+
const externalRecipeIds = new Set(recipes.keys());
|
|
138
|
+
assertRecipeMatchesManifest(recipe, this.#actionManifest, { externalRecipeIds });
|
|
129
139
|
const graph = extractWorkflowGraph(recipe);
|
|
130
|
-
const
|
|
140
|
+
const rootRef = rootRecipeRef(sourceRecipePath, recipes, recipeSource.digest);
|
|
141
|
+
const dependencyResolution = resolveRecipeDependencies({
|
|
142
|
+
rootRef,
|
|
143
|
+
root: recipe,
|
|
144
|
+
rootSource: recipeSource,
|
|
145
|
+
recipes,
|
|
146
|
+
});
|
|
147
|
+
for (const resolved of dependencyResolution.recipes.values()) {
|
|
148
|
+
assertRecipeMatchesManifest(resolved.document, this.#actionManifest, { externalRecipeIds });
|
|
149
|
+
}
|
|
150
|
+
const params = resolveRecipeParams(rootRef, recipe, request.params ?? {});
|
|
151
|
+
validateRecipeDependencyParams({
|
|
152
|
+
root: recipe,
|
|
153
|
+
params,
|
|
154
|
+
recipes: dependencyResolution.recipes,
|
|
155
|
+
});
|
|
156
|
+
const executionPlan = buildRecipeExecutionPlan({
|
|
157
|
+
recipe,
|
|
158
|
+
params,
|
|
159
|
+
source: recipeSource,
|
|
160
|
+
recipes: dependencyResolution.recipes,
|
|
161
|
+
adapters: this.#adapters,
|
|
162
|
+
actionManifest: this.#actionManifest,
|
|
131
163
|
projectRoot,
|
|
132
|
-
|
|
164
|
+
artifactsDir,
|
|
165
|
+
env,
|
|
166
|
+
hud: this.#hud,
|
|
167
|
+
recordVideo: request.recordVideo,
|
|
133
168
|
});
|
|
134
|
-
|
|
135
|
-
|
|
169
|
+
return {
|
|
170
|
+
projectRoot,
|
|
171
|
+
artifactsDir,
|
|
172
|
+
sourceRecipePath,
|
|
173
|
+
recipe,
|
|
174
|
+
rootRef,
|
|
175
|
+
params,
|
|
176
|
+
recipeSource,
|
|
177
|
+
env,
|
|
178
|
+
libraryResolution,
|
|
179
|
+
graph,
|
|
180
|
+
recipes: dependencyResolution.recipes,
|
|
181
|
+
dependencyResolution,
|
|
182
|
+
executionPlan,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async run(request) {
|
|
186
|
+
const { projectRoot, artifactsDir, recipe, rootRef, params, env, libraryResolution, graph, recipes, dependencyResolution, executionPlan, } = await this.#resolveRequest(request);
|
|
187
|
+
enforceRecipeExecutionPlan(executionPlan, request, this.#blockedCapabilities);
|
|
188
|
+
const startedAt = new Date();
|
|
189
|
+
await mkdir(artifactsDir, { recursive: true });
|
|
136
190
|
const runFileOffsets = await collectRunFileOffsets({
|
|
137
191
|
graph,
|
|
138
|
-
|
|
192
|
+
recipes,
|
|
139
193
|
projectRoot,
|
|
140
194
|
});
|
|
141
195
|
const artifactWriter = new JsonArtifactWriter(artifactsDir);
|
|
142
196
|
const traceWriter = new JsonTraceWriter(artifactsDir, this.#runnerProvenance);
|
|
143
197
|
const summaryWriter = new JsonSummaryWriter(artifactsDir);
|
|
144
|
-
|
|
198
|
+
let outputs = new Map();
|
|
145
199
|
const recipePath = await artifactWriter.copyRecipe(recipe);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// the reachable flows (never the whole library) and drops `uses`, so the result
|
|
149
|
-
// is self-contained; it returns the same reference when there is nothing to add.
|
|
150
|
-
const resolvedRecipe = buildResolvedRecipe(recipe, flowCatalog);
|
|
151
|
-
if (resolvedRecipe !== recipe) {
|
|
152
|
-
await artifactWriter.writeResolvedRecipe(resolvedRecipe);
|
|
200
|
+
for (const dependency of dependencyResolution.recipes.values()) {
|
|
201
|
+
await artifactWriter.copyResolvedRecipe(dependency.provenance.digest, dependency.document);
|
|
153
202
|
}
|
|
203
|
+
await artifactWriter.writeRecipeResolution(dependencyResolution.document);
|
|
154
204
|
let status = 'unknown';
|
|
155
|
-
let currentNodeId = graph.entry;
|
|
156
|
-
let mainStatus = 'unknown';
|
|
157
|
-
let runningTeardown = false;
|
|
158
|
-
const visited = new Set();
|
|
159
|
-
let transitionCount = 0;
|
|
160
|
-
const maxTransitions = Object.keys(graph.nodes).length * 3;
|
|
161
205
|
const videoOptions = normalizeVideoRecordingOptions(request.recordVideo);
|
|
162
206
|
const videoRecorder = videoOptions.mode !== 'off' ? this.#videoRecorder() : undefined;
|
|
163
207
|
let runRecording;
|
|
208
|
+
let canExecute = true;
|
|
164
209
|
if (videoRecorder) {
|
|
165
210
|
try {
|
|
166
211
|
runRecording = await this.#startRunVideoRecording({
|
|
@@ -169,7 +214,7 @@ class DefaultRecipeRunner {
|
|
|
169
214
|
recipe,
|
|
170
215
|
projectRoot,
|
|
171
216
|
artifactsDir,
|
|
172
|
-
env
|
|
217
|
+
env,
|
|
173
218
|
});
|
|
174
219
|
}
|
|
175
220
|
catch (error) {
|
|
@@ -185,212 +230,40 @@ class DefaultRecipeRunner {
|
|
|
185
230
|
});
|
|
186
231
|
this.#logger.error(`record.video start failed: ${message}`);
|
|
187
232
|
status = 'fail';
|
|
188
|
-
|
|
233
|
+
canExecute = false;
|
|
189
234
|
}
|
|
190
235
|
}
|
|
191
236
|
try {
|
|
192
|
-
if (
|
|
193
|
-
const
|
|
194
|
-
|
|
237
|
+
if (canExecute) {
|
|
238
|
+
const execution = await executeRecipe({
|
|
239
|
+
ref: rootRef,
|
|
195
240
|
recipe,
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
artifactWriter,
|
|
241
|
+
params,
|
|
242
|
+
recipes,
|
|
243
|
+
actionManifest: this.#actionManifest,
|
|
244
|
+
adapters: this.#adapters,
|
|
201
245
|
traceWriter,
|
|
202
|
-
|
|
246
|
+
logger: this.#logger,
|
|
247
|
+
defaultObserverRefs: this.#defaultObserverRefs,
|
|
248
|
+
createContext: (nodeId, activeRecipe, activeOutputs, getOutput) => this.#createExecutionContext({
|
|
249
|
+
nodeId,
|
|
250
|
+
recipe: activeRecipe,
|
|
251
|
+
projectRoot,
|
|
252
|
+
artifactsDir,
|
|
253
|
+
env,
|
|
254
|
+
outputs: activeOutputs,
|
|
255
|
+
getOutput,
|
|
256
|
+
artifactWriter,
|
|
257
|
+
runFileOffsets,
|
|
258
|
+
}),
|
|
259
|
+
registerArtifacts: (artifacts) => {
|
|
260
|
+
for (const artifact of artifacts)
|
|
261
|
+
artifactWriter.register(artifact);
|
|
262
|
+
},
|
|
263
|
+
publishHudProgress: (hudStatus, event) => this.#publishHudProgressOrRecord(traceWriter, hudStatus, event),
|
|
203
264
|
});
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
currentNodeId = undefined;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
while (currentNodeId) {
|
|
210
|
-
const activeNodeId = currentNodeId;
|
|
211
|
-
transitionCount += 1;
|
|
212
|
-
if (transitionCount > maxTransitions) {
|
|
213
|
-
const error = new Error('Recipe graph exceeded its maximum transition count.');
|
|
214
|
-
recordSyntheticFailure(traceWriter, activeNodeId, error);
|
|
215
|
-
status = 'fail';
|
|
216
|
-
break;
|
|
217
|
-
}
|
|
218
|
-
visited.add(activeNodeId);
|
|
219
|
-
const node = graph.nodes[activeNodeId];
|
|
220
|
-
if (!node) {
|
|
221
|
-
const error = new Error(`Recipe node ${activeNodeId} does not exist.`);
|
|
222
|
-
recordSyntheticFailure(traceWriter, activeNodeId, error);
|
|
223
|
-
status = 'fail';
|
|
224
|
-
break;
|
|
225
|
-
}
|
|
226
|
-
const action = String(node.action);
|
|
227
|
-
const adapter = action === 'call' ? undefined : this.#adapters.get(action);
|
|
228
|
-
if (action !== 'call' && !adapter) {
|
|
229
|
-
const error = new Error(`No adapter registered for action ${action}.`);
|
|
230
|
-
recordSyntheticFailure(traceWriter, activeNodeId, error, action);
|
|
231
|
-
status = 'fail';
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
|
-
const nodeStartedAt = new Date();
|
|
235
|
-
const context = this.#createExecutionContext({
|
|
236
|
-
nodeId: activeNodeId,
|
|
237
|
-
recipe,
|
|
238
|
-
projectRoot,
|
|
239
|
-
artifactsDir,
|
|
240
|
-
env: request.env ?? {},
|
|
241
|
-
outputs,
|
|
242
|
-
artifactWriter,
|
|
243
|
-
runFileOffsets,
|
|
244
|
-
});
|
|
245
|
-
try {
|
|
246
|
-
const gate = evaluateNodeGate(node, context.outputs);
|
|
247
|
-
if (!gate.run) {
|
|
248
|
-
const next = resolveNextNode(node, {});
|
|
249
|
-
traceWriter.record({
|
|
250
|
-
nodeId: activeNodeId,
|
|
251
|
-
action,
|
|
252
|
-
...traceNodeMetadata(node),
|
|
253
|
-
startedAt: nodeStartedAt.toISOString(),
|
|
254
|
-
endedAt: new Date().toISOString(),
|
|
255
|
-
durationMs: Date.now() - nodeStartedAt.getTime(),
|
|
256
|
-
ok: true,
|
|
257
|
-
next,
|
|
258
|
-
output: { skipped: true, reason: gate.reason },
|
|
259
|
-
});
|
|
260
|
-
currentNodeId = next;
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
const hudStarted = await this.#publishHudProgressOrRecord(traceWriter, 'running', {
|
|
264
|
-
nodeId: activeNodeId,
|
|
265
|
-
action,
|
|
266
|
-
node,
|
|
267
|
-
recipe,
|
|
268
|
-
index: transitionCount,
|
|
269
|
-
total: Object.keys(graph.nodes).length,
|
|
270
|
-
context,
|
|
271
|
-
});
|
|
272
|
-
if (!hudStarted) {
|
|
273
|
-
status = 'fail';
|
|
274
|
-
break;
|
|
275
|
-
}
|
|
276
|
-
const result = action === 'call'
|
|
277
|
-
? await executeInlineFlowCall({
|
|
278
|
-
callNodeId: activeNodeId,
|
|
279
|
-
node,
|
|
280
|
-
context,
|
|
281
|
-
flowCatalog,
|
|
282
|
-
adapters: this.#adapters,
|
|
283
|
-
traceWriter,
|
|
284
|
-
callStack: [],
|
|
285
|
-
maxCallDepth: DEFAULT_MAX_FLOW_CALL_DEPTH,
|
|
286
|
-
logger: this.#logger,
|
|
287
|
-
defaultObserverRefs: this.#defaultObserverRefs,
|
|
288
|
-
declaredObserverRefs: this.#declaredObserverRefs,
|
|
289
|
-
publishHudProgress: (hudStatus, flowEvent) => this.#publishHudProgressOrRecord(traceWriter, hudStatus, {
|
|
290
|
-
...flowEvent,
|
|
291
|
-
recipe,
|
|
292
|
-
}),
|
|
293
|
-
})
|
|
294
|
-
: await adapter.execute(node, context);
|
|
295
|
-
const observeRefs = resolveObserveRefs(action, node, this.#defaultObserverRefs, this.#declaredObserverRefs);
|
|
296
|
-
const observationResult = action !== 'call' && (result.status == null || result.status === 'pass')
|
|
297
|
-
? await runPassiveObservers({
|
|
298
|
-
action,
|
|
299
|
-
node,
|
|
300
|
-
adapter: adapter,
|
|
301
|
-
context,
|
|
302
|
-
logger: this.#logger,
|
|
303
|
-
refs: observeRefs,
|
|
304
|
-
})
|
|
305
|
-
: {};
|
|
306
|
-
if (result.output !== undefined)
|
|
307
|
-
outputs.set(activeNodeId, result.output);
|
|
308
|
-
for (const artifact of result.artifacts ?? [])
|
|
309
|
-
artifactWriter.register(artifact);
|
|
310
|
-
const next = resolveNextNode(node, result);
|
|
311
|
-
const { observations, observationWarnings } = finalizeNodeObservations({
|
|
312
|
-
nodeId: activeNodeId,
|
|
313
|
-
node,
|
|
314
|
-
result,
|
|
315
|
-
observationResult,
|
|
316
|
-
observeRefs,
|
|
317
|
-
});
|
|
318
|
-
traceWriter.record({
|
|
319
|
-
nodeId: activeNodeId,
|
|
320
|
-
action,
|
|
321
|
-
...traceNodeMetadata(node, result),
|
|
322
|
-
startedAt: nodeStartedAt.toISOString(),
|
|
323
|
-
endedAt: new Date().toISOString(),
|
|
324
|
-
durationMs: Date.now() - nodeStartedAt.getTime(),
|
|
325
|
-
ok: true,
|
|
326
|
-
next,
|
|
327
|
-
status: result.status,
|
|
328
|
-
case: result.case,
|
|
329
|
-
output: result.output,
|
|
330
|
-
...(observations ? { observations } : {}),
|
|
331
|
-
...(observationWarnings.length ? { observationWarnings } : {}),
|
|
332
|
-
});
|
|
333
|
-
const hudCompleted = await this.#publishHudProgressOrRecord(traceWriter, result.status === 'fail' ? 'fail' : 'pass', {
|
|
334
|
-
nodeId: activeNodeId,
|
|
335
|
-
action,
|
|
336
|
-
node,
|
|
337
|
-
recipe,
|
|
338
|
-
index: transitionCount,
|
|
339
|
-
total: Object.keys(graph.nodes).length,
|
|
340
|
-
context,
|
|
341
|
-
});
|
|
342
|
-
if (!hudCompleted) {
|
|
343
|
-
status = 'fail';
|
|
344
|
-
break;
|
|
345
|
-
}
|
|
346
|
-
if (result.status) {
|
|
347
|
-
if (runningTeardown) {
|
|
348
|
-
status = mainStatus === 'fail' ? 'fail' : result.status;
|
|
349
|
-
break;
|
|
350
|
-
}
|
|
351
|
-
mainStatus = result.status;
|
|
352
|
-
if (graph.teardownEntry) {
|
|
353
|
-
runningTeardown = true;
|
|
354
|
-
currentNodeId = graph.teardownEntry;
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
status = result.status;
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
currentNodeId = next;
|
|
361
|
-
}
|
|
362
|
-
catch (error) {
|
|
363
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
364
|
-
traceWriter.record({
|
|
365
|
-
nodeId: activeNodeId,
|
|
366
|
-
action,
|
|
367
|
-
...traceNodeMetadata(node),
|
|
368
|
-
startedAt: nodeStartedAt.toISOString(),
|
|
369
|
-
endedAt: new Date().toISOString(),
|
|
370
|
-
durationMs: Date.now() - nodeStartedAt.getTime(),
|
|
371
|
-
ok: false,
|
|
372
|
-
error: message,
|
|
373
|
-
});
|
|
374
|
-
this.#logger.error(message);
|
|
375
|
-
await this.#publishHudProgressOrRecord(traceWriter, 'fail', {
|
|
376
|
-
nodeId: activeNodeId,
|
|
377
|
-
action,
|
|
378
|
-
node,
|
|
379
|
-
recipe,
|
|
380
|
-
index: transitionCount,
|
|
381
|
-
total: Object.keys(graph.nodes).length,
|
|
382
|
-
context,
|
|
383
|
-
error: message,
|
|
384
|
-
});
|
|
385
|
-
status = 'fail';
|
|
386
|
-
if (!runningTeardown && graph.teardownEntry) {
|
|
387
|
-
mainStatus = 'fail';
|
|
388
|
-
runningTeardown = true;
|
|
389
|
-
currentNodeId = graph.teardownEntry;
|
|
390
|
-
continue;
|
|
391
|
-
}
|
|
392
|
-
break;
|
|
393
|
-
}
|
|
265
|
+
status = execution.status;
|
|
266
|
+
outputs = execution.outputs;
|
|
394
267
|
}
|
|
395
268
|
artifactWriter.register({
|
|
396
269
|
path: 'summary.json',
|
|
@@ -404,30 +277,6 @@ class DefaultRecipeRunner {
|
|
|
404
277
|
label: 'Execution trace',
|
|
405
278
|
category: 'system',
|
|
406
279
|
});
|
|
407
|
-
// Any resolution activity — a library flow used, a recipe-local override,
|
|
408
|
-
// or cross-source shadowing — gets the artifact, so an all-overridden run
|
|
409
|
-
// still leaves reviewable evidence of what the library would have provided.
|
|
410
|
-
const shadowedFlows = libraryResolution ? collectShadowedFlows(libraryResolution) : [];
|
|
411
|
-
if (libraryResolution &&
|
|
412
|
-
(usedLibraryFlows.size > 0 || flowOverrides.length > 0 || shadowedFlows.length > 0)) {
|
|
413
|
-
await writeFile(path.join(artifactsDir, 'resolved-flows.json'), `${JSON.stringify({
|
|
414
|
-
schema_version: 1,
|
|
415
|
-
kind: 'recipe-resolved-flows',
|
|
416
|
-
sources: libraryResolution.sources,
|
|
417
|
-
overrides: flowOverrides,
|
|
418
|
-
shadowed: shadowedFlows,
|
|
419
|
-
flows: Object.fromEntries([...usedLibraryFlows.values()].map((flow) => [
|
|
420
|
-
flow.ref,
|
|
421
|
-
{ source: flow.source, file: flow.file, definition: flow.raw },
|
|
422
|
-
])),
|
|
423
|
-
}, null, 2)}\n`);
|
|
424
|
-
artifactWriter.register({
|
|
425
|
-
path: 'resolved-flows.json',
|
|
426
|
-
type: 'json',
|
|
427
|
-
label: 'Library flow resolution for this run',
|
|
428
|
-
category: 'system',
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
280
|
if (status === 'pass') {
|
|
432
281
|
const runHudStartedAt = new Date();
|
|
433
282
|
try {
|
|
@@ -435,7 +284,7 @@ class DefaultRecipeRunner {
|
|
|
435
284
|
recipe,
|
|
436
285
|
projectRoot,
|
|
437
286
|
artifactsDir,
|
|
438
|
-
env
|
|
287
|
+
env,
|
|
439
288
|
outputs,
|
|
440
289
|
});
|
|
441
290
|
}
|
|
@@ -464,6 +313,7 @@ class DefaultRecipeRunner {
|
|
|
464
313
|
catch (error) {
|
|
465
314
|
const message = errorMessage(error);
|
|
466
315
|
await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
|
|
316
|
+
await rm(recordingToStop.stagingRoot, { recursive: true, force: true });
|
|
467
317
|
traceWriter.record({
|
|
468
318
|
nodeId: 'recipe-run:video',
|
|
469
319
|
action: 'record.video',
|
|
@@ -504,22 +354,18 @@ class DefaultRecipeRunner {
|
|
|
504
354
|
},
|
|
505
355
|
...(this.#runnerProvenance ? { runner: this.#runnerProvenance } : {}),
|
|
506
356
|
...(libraryResolution
|
|
507
|
-
? {
|
|
508
|
-
flowResolution: buildFlowResolutionSummary(libraryResolution, usedLibraryFlows, flowOverrides),
|
|
509
|
-
}
|
|
357
|
+
? { recipeLibraries: buildRecipeLibrarySummary(libraryResolution) }
|
|
510
358
|
: {}),
|
|
511
359
|
};
|
|
512
360
|
const summaryPath = await summaryWriter.write(summary);
|
|
513
361
|
const artifactManifestPath = await artifactWriter.write(status, this.#runnerProvenance);
|
|
514
362
|
const packageValidation = validateRecipeArtifactPackage({
|
|
515
363
|
recipe,
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
// status still requires the composition to be proven.
|
|
522
|
-
runPassed: status !== 'fail',
|
|
364
|
+
resolvedRecipes: Object.fromEntries([...dependencyResolution.recipes.values()].map((entry) => [
|
|
365
|
+
entry.provenance.digest,
|
|
366
|
+
entry.document,
|
|
367
|
+
])),
|
|
368
|
+
recipeResolution: dependencyResolution.document,
|
|
523
369
|
manifest: {
|
|
524
370
|
version: 1,
|
|
525
371
|
runStatus: status,
|
|
@@ -531,6 +377,7 @@ class DefaultRecipeRunner {
|
|
|
531
377
|
'summary.json',
|
|
532
378
|
'trace.json',
|
|
533
379
|
'artifact-manifest.json',
|
|
380
|
+
'recipe-resolution.json',
|
|
534
381
|
...artifactWriter.list().map((entry) => entry.path),
|
|
535
382
|
],
|
|
536
383
|
});
|
|
@@ -541,60 +388,7 @@ class DefaultRecipeRunner {
|
|
|
541
388
|
}
|
|
542
389
|
return { status, summaryPath, tracePath, artifactManifestPath, recipePath };
|
|
543
390
|
}
|
|
544
|
-
|
|
545
|
-
for (const gate of graph.preconditions) {
|
|
546
|
-
const nodeId = `pre_conditions:${gate.id}`;
|
|
547
|
-
const checker = this.#preconditions.get(gate.id);
|
|
548
|
-
if (!checker) {
|
|
549
|
-
recordSyntheticFailure(traceWriter, nodeId, new Error(`Precondition ${gate.id} is declared by the recipe but has no checker registered.`), 'pre_condition');
|
|
550
|
-
return 'fail';
|
|
551
|
-
}
|
|
552
|
-
const startedAt = new Date();
|
|
553
|
-
const context = this.#createExecutionContext({
|
|
554
|
-
nodeId,
|
|
555
|
-
recipe,
|
|
556
|
-
projectRoot,
|
|
557
|
-
artifactsDir,
|
|
558
|
-
env: request.env ?? {},
|
|
559
|
-
outputs,
|
|
560
|
-
artifactWriter,
|
|
561
|
-
runFileOffsets,
|
|
562
|
-
});
|
|
563
|
-
try {
|
|
564
|
-
const rawResult = await checker.execute(gate, context);
|
|
565
|
-
const result = normalizePreconditionResult(rawResult);
|
|
566
|
-
if (result.output !== undefined)
|
|
567
|
-
outputs.set(nodeId, result.output);
|
|
568
|
-
if (result.ok === false) {
|
|
569
|
-
throw new Error(result.error ?? `Precondition ${gate.id} failed.`);
|
|
570
|
-
}
|
|
571
|
-
traceWriter.record({
|
|
572
|
-
nodeId,
|
|
573
|
-
action: 'pre_condition',
|
|
574
|
-
startedAt: startedAt.toISOString(),
|
|
575
|
-
endedAt: new Date().toISOString(),
|
|
576
|
-
durationMs: Date.now() - startedAt.getTime(),
|
|
577
|
-
ok: true,
|
|
578
|
-
output: result.output,
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
catch (error) {
|
|
582
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
583
|
-
traceWriter.record({
|
|
584
|
-
nodeId,
|
|
585
|
-
action: 'pre_condition',
|
|
586
|
-
startedAt: startedAt.toISOString(),
|
|
587
|
-
endedAt: new Date().toISOString(),
|
|
588
|
-
durationMs: Date.now() - startedAt.getTime(),
|
|
589
|
-
ok: false,
|
|
590
|
-
error: message,
|
|
591
|
-
});
|
|
592
|
-
return 'fail';
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
return 'pass';
|
|
596
|
-
}
|
|
597
|
-
#createExecutionContext({ nodeId, recipe, projectRoot, artifactsDir, env, outputs, artifactWriter, runFileOffsets, }) {
|
|
391
|
+
#createExecutionContext({ nodeId, recipe, projectRoot, artifactsDir, env, outputs, getOutput, artifactWriter, runFileOffsets, }) {
|
|
598
392
|
return {
|
|
599
393
|
nodeId,
|
|
600
394
|
recipe,
|
|
@@ -602,11 +396,7 @@ class DefaultRecipeRunner {
|
|
|
602
396
|
artifactsDir,
|
|
603
397
|
env,
|
|
604
398
|
outputs,
|
|
605
|
-
getOutput
|
|
606
|
-
if (!outputs.has(nodeId))
|
|
607
|
-
throw new Error(`No output recorded for node ${nodeId}.`);
|
|
608
|
-
return outputs.get(nodeId);
|
|
609
|
-
},
|
|
399
|
+
getOutput,
|
|
610
400
|
resolveProjectPath(relativePath) {
|
|
611
401
|
return path.join(projectRoot, normalizeRelativePath(relativePath));
|
|
612
402
|
},
|
|
@@ -648,8 +438,9 @@ class DefaultRecipeRunner {
|
|
|
648
438
|
throw new Error('--record-video requires a recording target. Provide a project RecordingTargetProvider or CLI target flags.');
|
|
649
439
|
}
|
|
650
440
|
const relativePath = 'videos/recipe-run.mp4';
|
|
651
|
-
const
|
|
652
|
-
|
|
441
|
+
const stagingRoot = await mkdtemp(path.join(os.tmpdir(), 'farmslot-recipe-video-'));
|
|
442
|
+
const stagingRelativePath = 'recipe-run.mp4';
|
|
443
|
+
const outputPath = path.join(stagingRoot, stagingRelativePath);
|
|
653
444
|
let recording;
|
|
654
445
|
try {
|
|
655
446
|
recording = await recorder.start({
|
|
@@ -663,6 +454,7 @@ class DefaultRecipeRunner {
|
|
|
663
454
|
}
|
|
664
455
|
catch (error) {
|
|
665
456
|
await removePartialRunVideoOutput(outputPath, this.#logger);
|
|
457
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
666
458
|
throw error;
|
|
667
459
|
}
|
|
668
460
|
const entry = {
|
|
@@ -680,11 +472,20 @@ class DefaultRecipeRunner {
|
|
|
680
472
|
},
|
|
681
473
|
...(videoOptions.maxFps != null ? { maxFps: videoOptions.maxFps } : {}),
|
|
682
474
|
};
|
|
683
|
-
return {
|
|
475
|
+
return {
|
|
476
|
+
recording,
|
|
477
|
+
entry,
|
|
478
|
+
outputPath,
|
|
479
|
+
stagingRoot,
|
|
480
|
+
stagingRelativePath,
|
|
481
|
+
artifactsDir,
|
|
482
|
+
};
|
|
684
483
|
}
|
|
685
484
|
async #stopRunVideoRecording(runRecording) {
|
|
686
485
|
const result = await runRecording.recording.stop();
|
|
687
486
|
await assertVideoOutputReady(runRecording.outputPath);
|
|
487
|
+
await copyFileWithinRoots(runRecording.stagingRoot, runRecording.stagingRelativePath, runRecording.artifactsDir, runRecording.entry.path);
|
|
488
|
+
await rm(runRecording.stagingRoot, { recursive: true, force: true });
|
|
688
489
|
return result.recorder
|
|
689
490
|
? {
|
|
690
491
|
...runRecording.entry,
|
|
@@ -727,6 +528,7 @@ class DefaultRecipeRunner {
|
|
|
727
528
|
const adapter = this.#adapters.get(hudAction);
|
|
728
529
|
if (!adapter)
|
|
729
530
|
return;
|
|
531
|
+
await verifyExecutableSource(adapter, `Action ${hudAction}`);
|
|
730
532
|
await adapter.execute(buildHudNode(status, event, this.#hud), event.context);
|
|
731
533
|
}
|
|
732
534
|
async #publishRunHud(status, request) {
|
|
@@ -736,6 +538,7 @@ class DefaultRecipeRunner {
|
|
|
736
538
|
const adapter = this.#adapters.get(hudAction);
|
|
737
539
|
if (!adapter)
|
|
738
540
|
return;
|
|
541
|
+
await verifyExecutableSource(adapter, `Action ${hudAction}`);
|
|
739
542
|
const context = {
|
|
740
543
|
nodeId: 'recipe-complete',
|
|
741
544
|
recipe: request.recipe,
|
|
@@ -780,30 +583,32 @@ class DefaultRecipeRunner {
|
|
|
780
583
|
return 'Recipe run';
|
|
781
584
|
}
|
|
782
585
|
}
|
|
783
|
-
function
|
|
586
|
+
function buildRecipeLibrarySummary(resolution) {
|
|
784
587
|
return {
|
|
785
588
|
sources: resolution.sources,
|
|
786
|
-
|
|
787
|
-
ref: flow.ref,
|
|
788
|
-
source: flow.source,
|
|
789
|
-
file: flow.file,
|
|
790
|
-
...(flow.shadows.length > 0 ? { shadows: flow.shadows } : {}),
|
|
791
|
-
...(flow.lastVerified ? { lastVerified: flow.lastVerified } : {}),
|
|
792
|
-
})),
|
|
793
|
-
overrides,
|
|
794
|
-
shadowed: collectShadowedFlows(resolution),
|
|
589
|
+
shadowed: collectShadowedRecipes(resolution),
|
|
795
590
|
};
|
|
796
591
|
}
|
|
797
|
-
function
|
|
798
|
-
return [...resolution.
|
|
799
|
-
.filter((
|
|
800
|
-
.map((
|
|
801
|
-
ref:
|
|
802
|
-
source:
|
|
803
|
-
file:
|
|
804
|
-
shadows:
|
|
592
|
+
function collectShadowedRecipes(resolution) {
|
|
593
|
+
return [...resolution.recipes.values()]
|
|
594
|
+
.filter((recipe) => recipe.shadows.length > 0)
|
|
595
|
+
.map((recipe) => ({
|
|
596
|
+
ref: recipe.ref,
|
|
597
|
+
source: recipe.source,
|
|
598
|
+
file: recipe.file,
|
|
599
|
+
shadows: recipe.shadows,
|
|
805
600
|
}));
|
|
806
601
|
}
|
|
602
|
+
function rootRecipeRef(sourceRecipePath, recipes, digest) {
|
|
603
|
+
if (sourceRecipePath) {
|
|
604
|
+
const resolvedPath = path.resolve(sourceRecipePath);
|
|
605
|
+
const match = [...recipes.values()].find((recipe) => path.resolve(recipe.path) === resolvedPath);
|
|
606
|
+
if (match)
|
|
607
|
+
return match.ref;
|
|
608
|
+
return rootResolutionRef(digest);
|
|
609
|
+
}
|
|
610
|
+
return rootResolutionRef(digest);
|
|
611
|
+
}
|
|
807
612
|
function normalizeVideoRecordingOptions(input) {
|
|
808
613
|
if (!input)
|
|
809
614
|
return { mode: 'off' };
|