@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +83 -182
  3. package/dist/adapters/app-lifecycle.d.ts.map +1 -1
  4. package/dist/adapters/app-lifecycle.js +5 -0
  5. package/dist/adapters/app-lifecycle.js.map +1 -1
  6. package/dist/adapters/core.d.ts.map +1 -1
  7. package/dist/adapters/core.js +25 -46
  8. package/dist/adapters/core.js.map +1 -1
  9. package/dist/adapters/ui.d.ts +0 -2
  10. package/dist/adapters/ui.d.ts.map +1 -1
  11. package/dist/adapters/ui.js +5 -2
  12. package/dist/adapters/ui.js.map +1 -1
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +0 -2
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/cli/run-command.d.ts.map +1 -1
  17. package/dist/cli/run-command.js +197 -32
  18. package/dist/cli/run-command.js.map +1 -1
  19. package/dist/cli/support.d.ts +5 -2
  20. package/dist/cli/support.d.ts.map +1 -1
  21. package/dist/cli/support.js +131 -25
  22. package/dist/cli/support.js.map +1 -1
  23. package/dist/cli/validate-command.d.ts.map +1 -1
  24. package/dist/cli/validate-command.js +4 -1
  25. package/dist/cli/validate-command.js.map +1 -1
  26. package/dist/core/compose.d.ts +21 -31
  27. package/dist/core/compose.d.ts.map +1 -1
  28. package/dist/core/compose.js +93 -125
  29. package/dist/core/compose.js.map +1 -1
  30. package/dist/core/execution.d.ts +40 -0
  31. package/dist/core/execution.d.ts.map +1 -0
  32. package/dist/core/execution.js +263 -0
  33. package/dist/core/execution.js.map +1 -0
  34. package/dist/core/graph.d.ts +4 -5
  35. package/dist/core/graph.d.ts.map +1 -1
  36. package/dist/core/graph.js +51 -103
  37. package/dist/core/graph.js.map +1 -1
  38. package/dist/core/hud.d.ts.map +1 -1
  39. package/dist/core/hud.js +2 -6
  40. package/dist/core/hud.js.map +1 -1
  41. package/dist/core/library.d.ts +17 -51
  42. package/dist/core/library.d.ts.map +1 -1
  43. package/dist/core/library.js +144 -155
  44. package/dist/core/library.js.map +1 -1
  45. package/dist/core/parameters.d.ts +5 -0
  46. package/dist/core/parameters.d.ts.map +1 -0
  47. package/dist/core/parameters.js +57 -0
  48. package/dist/core/parameters.js.map +1 -0
  49. package/dist/core/passive-observations.d.ts +2 -5
  50. package/dist/core/passive-observations.d.ts.map +1 -1
  51. package/dist/core/passive-observations.js +6 -40
  52. package/dist/core/passive-observations.js.map +1 -1
  53. package/dist/core/path.d.ts +6 -0
  54. package/dist/core/path.d.ts.map +1 -0
  55. package/dist/core/path.js +126 -0
  56. package/dist/core/path.js.map +1 -0
  57. package/dist/core/recording-cleanup.d.ts +1 -0
  58. package/dist/core/recording-cleanup.d.ts.map +1 -1
  59. package/dist/core/recording-cleanup.js +8 -0
  60. package/dist/core/recording-cleanup.js.map +1 -1
  61. package/dist/core/resolution-error.d.ts +7 -0
  62. package/dist/core/resolution-error.d.ts.map +1 -0
  63. package/dist/core/resolution-error.js +9 -0
  64. package/dist/core/resolution-error.js.map +1 -0
  65. package/dist/core/runner.d.ts.map +1 -1
  66. package/dist/core/runner.js +178 -373
  67. package/dist/core/runner.js.map +1 -1
  68. package/dist/core/trace.d.ts +3 -4
  69. package/dist/core/trace.d.ts.map +1 -1
  70. package/dist/core/trace.js +3 -4
  71. package/dist/core/trace.js.map +1 -1
  72. package/dist/core/trust-error.d.ts +9 -0
  73. package/dist/core/trust-error.d.ts.map +1 -0
  74. package/dist/core/trust-error.js +18 -0
  75. package/dist/core/trust-error.js.map +1 -0
  76. package/dist/core/trust-input.d.ts +20 -0
  77. package/dist/core/trust-input.d.ts.map +1 -0
  78. package/dist/core/trust-input.js +68 -0
  79. package/dist/core/trust-input.js.map +1 -0
  80. package/dist/core/trust.d.ts +20 -0
  81. package/dist/core/trust.d.ts.map +1 -0
  82. package/dist/core/trust.js +231 -0
  83. package/dist/core/trust.js.map +1 -0
  84. package/dist/core/types.d.ts +41 -47
  85. package/dist/core/types.d.ts.map +1 -1
  86. package/dist/index.d.ts +8 -5
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +6 -3
  89. package/dist/index.js.map +1 -1
  90. package/dist/node/writers.d.ts +3 -8
  91. package/dist/node/writers.d.ts.map +1 -1
  92. package/dist/node/writers.js +27 -26
  93. package/dist/node/writers.js.map +1 -1
  94. package/dist/recording/cdp-video-recorder.js +2 -3
  95. package/dist/recording/cdp-video-recorder.js.map +1 -1
  96. package/dist/runtime/cdp.d.ts.map +1 -1
  97. package/dist/runtime/cdp.js +3 -7
  98. package/dist/runtime/cdp.js.map +1 -1
  99. package/package.json +2 -2
  100. package/dist/cli/flows-command.d.ts +0 -3
  101. package/dist/cli/flows-command.d.ts.map +0 -1
  102. package/dist/cli/flows-command.js +0 -313
  103. package/dist/cli/flows-command.js.map +0 -1
  104. package/dist/core/flows.d.ts +0 -45
  105. package/dist/core/flows.d.ts.map +0 -1
  106. package/dist/core/flows.js +0 -359
  107. package/dist/core/flows.js.map +0 -1
  108. package/dist/core/predicates.d.ts +0 -7
  109. package/dist/core/predicates.d.ts.map +0 -1
  110. package/dist/core/predicates.js +0 -102
  111. package/dist/core/predicates.js.map +0 -1
  112. package/dist/core/promote.d.ts +0 -41
  113. package/dist/core/promote.d.ts.map +0 -1
  114. package/dist/core/promote.js +0 -249
  115. package/dist/core/promote.js.map +0 -1
@@ -1,22 +1,24 @@
1
- import { mkdir, stat, writeFile } from 'node:fs/promises';
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 { buildResolvedRecipe } from './compose.js';
7
- import { collectFlows, executeInlineFlowCall } from './flows.js';
8
- import { extractWorkflowGraph, normalizePreconditionResult, resolveNextNode, } from './graph.js';
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 { createEffectiveFlowCatalog, loadRecipeLibraries, } from './library.js';
12
- import { declaredObserverRefsFromManifest, defaultObserverRefsFromManifest, finalizeNodeObservations, resolveObserveRefs, runPassiveObservers, } from './passive-observations.js';
13
- import { evaluateNodeGate } from './predicates.js';
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 { recordSyntheticFailure, traceNodeMetadata } from './trace.js';
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, preconditionMap, options.logger ?? noopLogger, options.hud, options.runner, options.recording);
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, flows, projectRoot, }) {
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 flow of flows.values())
77
- collectWatchLogPaths(paths, flow.nodes);
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 stat(path.join(projectRoot, relativePath));
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
- constructor(actionManifest, adapters, preconditions, logger, hud, runnerProvenance, recording) {
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 run(request) {
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 startedAt = new Date();
122
- await mkdir(artifactsDir, { recursive: true });
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, this.#logger)
131
+ ? await loadRecipeLibraries(request.librarySources, {
132
+ adapter: request.adapter,
133
+ logger: this.#logger,
134
+ })
127
135
  : undefined;
128
- assertRecipeMatchesManifest(recipe, this.#actionManifest, libraryResolution ? { externalFlowIds: new Set(libraryResolution.flows.keys()) } : undefined);
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 recipeLocalFlows = await collectFlows(recipe, {
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
- recipeDir: sourceRecipePath ? path.dirname(sourceRecipePath) : projectRoot,
164
+ artifactsDir,
165
+ env,
166
+ hud: this.#hud,
167
+ recordVideo: request.recordVideo,
133
168
  });
134
- const usedLibraryFlows = new Map();
135
- const { catalog: flowCatalog, overrides: flowOverrides } = createEffectiveFlowCatalog(recipeLocalFlows, libraryResolution, usedLibraryFlows, this.#logger);
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
- flows: flowCatalog,
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
- const outputs = new Map();
198
+ let outputs = new Map();
145
199
  const recipePath = await artifactWriter.copyRecipe(recipe);
146
- // Emit the fully-composed recipe when `uses`/library composition adds flows the
147
- // authored recipe.json does not already inline. buildResolvedRecipe inlines only
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: request.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
- currentNodeId = undefined;
233
+ canExecute = false;
189
234
  }
190
235
  }
191
236
  try {
192
- if (currentNodeId) {
193
- const preconditionStatus = await this.#runPreconditions({
194
- graph,
237
+ if (canExecute) {
238
+ const execution = await executeRecipe({
239
+ ref: rootRef,
195
240
  recipe,
196
- projectRoot,
197
- artifactsDir,
198
- request,
199
- outputs,
200
- artifactWriter,
241
+ params,
242
+ recipes,
243
+ actionManifest: this.#actionManifest,
244
+ adapters: this.#adapters,
201
245
  traceWriter,
202
- runFileOffsets,
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
- if (preconditionStatus === 'fail') {
205
- status = 'fail';
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: request.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
- // Hand the composed recipe and the run outcome to the validator: it validates
517
- // the composition in full only for a passing run (parity with gateway/CLI), and
518
- // leaves a gracefully-failed run's composition unchecked.
519
- ...(resolvedRecipe !== recipe ? { resolvedRecipe } : {}),
520
- // Only a confirmed failure relaxes composition enforcement; an unknown/incomplete
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
- async #runPreconditions({ graph, recipe, projectRoot, artifactsDir, request, outputs, artifactWriter, traceWriter, runFileOffsets, }) {
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(nodeId) {
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 outputPath = path.join(artifactsDir, relativePath);
652
- await mkdir(path.dirname(outputPath), { recursive: true });
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 { recording, entry, outputPath };
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 buildFlowResolutionSummary(resolution, usedLibraryFlows, overrides) {
586
+ function buildRecipeLibrarySummary(resolution) {
784
587
  return {
785
588
  sources: resolution.sources,
786
- used: [...usedLibraryFlows.values()].map((flow) => ({
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 collectShadowedFlows(resolution) {
798
- return [...resolution.flows.values()]
799
- .filter((flow) => flow.shadows.length > 0)
800
- .map((flow) => ({
801
- ref: flow.ref,
802
- source: flow.source,
803
- file: flow.file,
804
- shadows: flow.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' };