@farmslot/recipe-harness 0.1.0 → 0.2.1
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/LICENSE +21 -0
- package/README.md +35 -12
- package/bin/farmslot-recipe.mjs +1 -1
- package/dist/adapters/core.d.ts +1 -1
- package/dist/adapters/core.d.ts.map +1 -1
- package/dist/adapters/core.js +1 -1
- package/dist/adapters/core.js.map +1 -1
- package/dist/adapters/ui.d.ts +1 -1
- package/dist/adapters/ui.d.ts.map +1 -1
- package/dist/{cli.d.ts → cli/index.d.ts} +1 -1
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/{cli.js → cli/index.js} +3 -3
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run-command.d.ts.map +1 -1
- package/dist/cli/run-command.js +30 -2
- package/dist/cli/run-command.js.map +1 -1
- package/dist/cli/support.d.ts +23 -0
- package/dist/cli/support.d.ts.map +1 -0
- package/dist/{cli-support.js → cli/support.js} +27 -3
- package/dist/cli/support.js.map +1 -0
- package/dist/cli/validate-command.js +2 -2
- package/dist/cli/validate-command.js.map +1 -1
- package/dist/core/flows.d.ts +34 -0
- package/dist/core/flows.d.ts.map +1 -0
- package/dist/core/flows.js +325 -0
- package/dist/core/flows.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 +116 -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 +124 -0
- package/dist/core/hud.js.map +1 -0
- package/dist/core/json.d.ts.map +1 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/predicates.d.ts +7 -0
- package/dist/core/predicates.d.ts.map +1 -0
- package/dist/core/predicates.js +102 -0
- package/dist/core/predicates.js.map +1 -0
- package/dist/core/recording-cleanup.d.ts +9 -0
- package/dist/core/recording-cleanup.d.ts.map +1 -0
- package/dist/core/recording-cleanup.js +24 -0
- package/dist/core/recording-cleanup.js.map +1 -0
- package/dist/core/runner.d.ts.map +1 -0
- package/dist/core/runner.js +691 -0
- package/dist/core/runner.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/{types.d.ts → core/types.d.ts} +65 -2
- package/dist/core/types.d.ts.map +1 -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 +4 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -7
- package/dist/index.js.map +1 -1
- package/dist/{writers.d.ts → node/writers.d.ts} +1 -1
- package/dist/node/writers.d.ts.map +1 -0
- package/dist/{writers.js → node/writers.js} +1 -1
- package/dist/node/writers.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 +194 -0
- package/dist/recording/capture-helper.js.map +1 -0
- package/dist/runtime/cdp.d.ts +2 -1
- package/dist/runtime/cdp.d.ts.map +1 -1
- package/dist/runtime/cdp.js +64 -11
- package/dist/runtime/cdp.js.map +1 -1
- package/dist/runtime/react-native-bridge.d.ts +1 -1
- package/dist/runtime/react-native-bridge.d.ts.map +1 -1
- package/package.json +58 -16
- package/CHANGELOG.md +0 -7
- package/dist/cli-support.d.ts +0 -14
- package/dist/cli-support.d.ts.map +0 -1
- package/dist/cli-support.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/json.d.ts.map +0 -1
- package/dist/json.js.map +0 -1
- package/dist/runner.d.ts.map +0 -1
- package/dist/runner.js +0 -1172
- package/dist/runner.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/writers.d.ts.map +0 -1
- package/dist/writers.js.map +0 -1
- /package/dist/{json.d.ts → core/json.d.ts} +0 -0
- /package/dist/{json.js → core/json.js} +0 -0
- /package/dist/{runner.d.ts → core/runner.d.ts} +0 -0
- /package/dist/{types.js → core/types.js} +0 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getRecipeActionManifestActionNames, validateRecipeArtifactPackage, } from '@farmslot/protocol';
|
|
4
|
+
import { JsonArtifactWriter, JsonSummaryWriter, JsonTraceWriter } from '../node/writers.js';
|
|
5
|
+
import { createCaptureHelperVideoRecorder, errorMessage, manifestTarget, } from '../recording/capture-helper.js';
|
|
6
|
+
import { collectFlows, executeInlineFlowCall } from './flows.js';
|
|
7
|
+
import { extractWorkflowGraph, normalizePreconditionResult, resolveNextNode, } from './graph.js';
|
|
8
|
+
import { buildHudNode } from './hud.js';
|
|
9
|
+
import { isRecord, normalizeRelativePath, readJsonFile } from './json.js';
|
|
10
|
+
import { evaluateNodeGate } from './predicates.js';
|
|
11
|
+
import { cleanupAbortedRunVideoRecording, removePartialRunVideoOutput, } from './recording-cleanup.js';
|
|
12
|
+
import { recordSyntheticFailure, traceNodeMetadata } from './trace.js';
|
|
13
|
+
import { assertManifestIsValid, assertRecipeMatchesManifest } from './validation.js';
|
|
14
|
+
const HARNESS_VERSION = '0.1.0';
|
|
15
|
+
const RUNNER_BUILT_IN_ACTIONS = new Set(['call']);
|
|
16
|
+
const DEFAULT_MAX_FLOW_CALL_DEPTH = 8;
|
|
17
|
+
const noopLogger = {
|
|
18
|
+
info() { },
|
|
19
|
+
warn() { },
|
|
20
|
+
error() { },
|
|
21
|
+
};
|
|
22
|
+
export function defineActionAdapter(adapter) {
|
|
23
|
+
if (!adapter.action.trim())
|
|
24
|
+
throw new Error('Action adapter action must be a non-empty string.');
|
|
25
|
+
return adapter;
|
|
26
|
+
}
|
|
27
|
+
export function createRecipeRunner(options) {
|
|
28
|
+
assertManifestIsValid(options.actionManifest);
|
|
29
|
+
const declaredActions = new Set(getRecipeActionManifestActionNames(options.actionManifest));
|
|
30
|
+
const adapterMap = new Map();
|
|
31
|
+
const preconditionMap = new Map();
|
|
32
|
+
const declaredPreconditions = new Set((options.actionManifest.pre_conditions ?? []).map((entry) => entry.id));
|
|
33
|
+
for (const checker of options.preconditions ?? []) {
|
|
34
|
+
if (!checker.id.trim())
|
|
35
|
+
throw new Error('Precondition checker id must be a non-empty string.');
|
|
36
|
+
if (!declaredPreconditions.has(checker.id)) {
|
|
37
|
+
throw new Error(`Precondition checker ${checker.id} is not declared by the action manifest.`);
|
|
38
|
+
}
|
|
39
|
+
if (preconditionMap.has(checker.id)) {
|
|
40
|
+
throw new Error(`Precondition checker ${checker.id} is registered more than once.`);
|
|
41
|
+
}
|
|
42
|
+
preconditionMap.set(checker.id, checker);
|
|
43
|
+
}
|
|
44
|
+
for (const adapter of options.adapters) {
|
|
45
|
+
if (!adapter.action.trim())
|
|
46
|
+
throw new Error('Action adapter action must be a non-empty string.');
|
|
47
|
+
if (!declaredActions.has(adapter.action) && adapter.testOnly !== true) {
|
|
48
|
+
throw new Error(`Adapter ${adapter.action} is not declared by the recipe action manifest.`);
|
|
49
|
+
}
|
|
50
|
+
if (adapterMap.has(adapter.action)) {
|
|
51
|
+
throw new Error(`Adapter ${adapter.action} is registered more than once.`);
|
|
52
|
+
}
|
|
53
|
+
adapterMap.set(adapter.action, adapter);
|
|
54
|
+
}
|
|
55
|
+
for (const action of declaredActions) {
|
|
56
|
+
if (!adapterMap.has(action) && !RUNNER_BUILT_IN_ACTIONS.has(action)) {
|
|
57
|
+
throw new Error(`Manifest action ${action} has no registered adapter.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return new DefaultRecipeRunner(options.actionManifest, adapterMap, preconditionMap, options.logger ?? noopLogger, options.hud, options.runner, options.recording);
|
|
61
|
+
}
|
|
62
|
+
class DefaultRecipeRunner {
|
|
63
|
+
#actionManifest;
|
|
64
|
+
#adapters;
|
|
65
|
+
#preconditions;
|
|
66
|
+
#logger;
|
|
67
|
+
#hud;
|
|
68
|
+
#runnerProvenance;
|
|
69
|
+
#recording;
|
|
70
|
+
constructor(actionManifest, adapters, preconditions, logger, hud, runnerProvenance, recording) {
|
|
71
|
+
this.#actionManifest = actionManifest;
|
|
72
|
+
this.#adapters = adapters;
|
|
73
|
+
this.#preconditions = preconditions;
|
|
74
|
+
this.#logger = logger;
|
|
75
|
+
this.#hud = hud;
|
|
76
|
+
this.#runnerProvenance = runnerProvenance;
|
|
77
|
+
this.#recording = recording;
|
|
78
|
+
}
|
|
79
|
+
async run(request) {
|
|
80
|
+
if (!request.recipePath && request.recipeDocument == null) {
|
|
81
|
+
throw new Error('Recipe run requires recipePath or recipeDocument.');
|
|
82
|
+
}
|
|
83
|
+
const projectRoot = path.resolve(request.projectRoot ?? process.cwd());
|
|
84
|
+
const artifactsDir = path.resolve(request.artifactsDir);
|
|
85
|
+
const startedAt = new Date();
|
|
86
|
+
await mkdir(artifactsDir, { recursive: true });
|
|
87
|
+
const sourceRecipePath = request.recipePath ? path.resolve(request.recipePath) : undefined;
|
|
88
|
+
const recipe = request.recipeDocument ?? (await readJsonFile(sourceRecipePath));
|
|
89
|
+
assertRecipeMatchesManifest(recipe, this.#actionManifest);
|
|
90
|
+
const graph = extractWorkflowGraph(recipe);
|
|
91
|
+
const flowCatalog = await collectFlows(recipe, {
|
|
92
|
+
projectRoot,
|
|
93
|
+
recipeDir: sourceRecipePath ? path.dirname(sourceRecipePath) : projectRoot,
|
|
94
|
+
});
|
|
95
|
+
const artifactWriter = new JsonArtifactWriter(artifactsDir);
|
|
96
|
+
const traceWriter = new JsonTraceWriter(artifactsDir, this.#runnerProvenance);
|
|
97
|
+
const summaryWriter = new JsonSummaryWriter(artifactsDir);
|
|
98
|
+
const outputs = new Map();
|
|
99
|
+
const recipePath = await artifactWriter.copyRecipe(recipe);
|
|
100
|
+
let status = 'unknown';
|
|
101
|
+
let currentNodeId = graph.entry;
|
|
102
|
+
let mainStatus = 'unknown';
|
|
103
|
+
let runningTeardown = false;
|
|
104
|
+
const visited = new Set();
|
|
105
|
+
let transitionCount = 0;
|
|
106
|
+
const maxTransitions = Object.keys(graph.nodes).length * 3;
|
|
107
|
+
const videoOptions = normalizeVideoRecordingOptions(request.recordVideo);
|
|
108
|
+
const videoRecorder = videoOptions.mode !== 'off' ? this.#videoRecorder() : undefined;
|
|
109
|
+
let runRecording;
|
|
110
|
+
if (videoRecorder) {
|
|
111
|
+
try {
|
|
112
|
+
runRecording = await this.#startRunVideoRecording({
|
|
113
|
+
recorder: videoRecorder,
|
|
114
|
+
videoOptions,
|
|
115
|
+
recipe,
|
|
116
|
+
projectRoot,
|
|
117
|
+
artifactsDir,
|
|
118
|
+
env: request.env ?? {},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const message = errorMessage(error);
|
|
123
|
+
traceWriter.record({
|
|
124
|
+
nodeId: 'recipe-run:video',
|
|
125
|
+
action: 'record.video',
|
|
126
|
+
startedAt: startedAt.toISOString(),
|
|
127
|
+
endedAt: new Date().toISOString(),
|
|
128
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
129
|
+
ok: false,
|
|
130
|
+
error: message,
|
|
131
|
+
});
|
|
132
|
+
this.#logger.error(`record.video start failed: ${message}`);
|
|
133
|
+
status = 'fail';
|
|
134
|
+
currentNodeId = undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
if (currentNodeId) {
|
|
139
|
+
const preconditionStatus = await this.#runPreconditions({
|
|
140
|
+
graph,
|
|
141
|
+
recipe,
|
|
142
|
+
projectRoot,
|
|
143
|
+
artifactsDir,
|
|
144
|
+
request,
|
|
145
|
+
outputs,
|
|
146
|
+
artifactWriter,
|
|
147
|
+
traceWriter,
|
|
148
|
+
});
|
|
149
|
+
if (preconditionStatus === 'fail') {
|
|
150
|
+
status = 'fail';
|
|
151
|
+
currentNodeId = undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
while (currentNodeId) {
|
|
155
|
+
const activeNodeId = currentNodeId;
|
|
156
|
+
transitionCount += 1;
|
|
157
|
+
if (transitionCount > maxTransitions) {
|
|
158
|
+
const error = new Error('Recipe graph exceeded its maximum transition count.');
|
|
159
|
+
recordSyntheticFailure(traceWriter, activeNodeId, error);
|
|
160
|
+
status = 'fail';
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
visited.add(activeNodeId);
|
|
164
|
+
const node = graph.nodes[activeNodeId];
|
|
165
|
+
if (!node) {
|
|
166
|
+
const error = new Error(`Recipe node ${activeNodeId} does not exist.`);
|
|
167
|
+
recordSyntheticFailure(traceWriter, activeNodeId, error);
|
|
168
|
+
status = 'fail';
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
const action = String(node.action);
|
|
172
|
+
const adapter = action === 'call' ? undefined : this.#adapters.get(action);
|
|
173
|
+
if (action !== 'call' && !adapter) {
|
|
174
|
+
const error = new Error(`No adapter registered for action ${action}.`);
|
|
175
|
+
recordSyntheticFailure(traceWriter, activeNodeId, error, action);
|
|
176
|
+
status = 'fail';
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
const nodeStartedAt = new Date();
|
|
180
|
+
const context = this.#createExecutionContext({
|
|
181
|
+
nodeId: activeNodeId,
|
|
182
|
+
recipe,
|
|
183
|
+
projectRoot,
|
|
184
|
+
artifactsDir,
|
|
185
|
+
env: request.env ?? {},
|
|
186
|
+
outputs,
|
|
187
|
+
artifactWriter,
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
const gate = evaluateNodeGate(node, context.outputs);
|
|
191
|
+
if (!gate.run) {
|
|
192
|
+
const next = resolveNextNode(node, {});
|
|
193
|
+
traceWriter.record({
|
|
194
|
+
nodeId: activeNodeId,
|
|
195
|
+
action,
|
|
196
|
+
...traceNodeMetadata(node),
|
|
197
|
+
startedAt: nodeStartedAt.toISOString(),
|
|
198
|
+
endedAt: new Date().toISOString(),
|
|
199
|
+
durationMs: Date.now() - nodeStartedAt.getTime(),
|
|
200
|
+
ok: true,
|
|
201
|
+
next,
|
|
202
|
+
output: { skipped: true, reason: gate.reason },
|
|
203
|
+
});
|
|
204
|
+
currentNodeId = next;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const hudStarted = await this.#publishHudProgressOrRecord(traceWriter, 'running', {
|
|
208
|
+
nodeId: activeNodeId,
|
|
209
|
+
action,
|
|
210
|
+
node,
|
|
211
|
+
recipe,
|
|
212
|
+
index: transitionCount,
|
|
213
|
+
total: Object.keys(graph.nodes).length,
|
|
214
|
+
context,
|
|
215
|
+
});
|
|
216
|
+
if (!hudStarted) {
|
|
217
|
+
status = 'fail';
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
const result = action === 'call'
|
|
221
|
+
? await executeInlineFlowCall({
|
|
222
|
+
callNodeId: activeNodeId,
|
|
223
|
+
node,
|
|
224
|
+
context,
|
|
225
|
+
flowCatalog,
|
|
226
|
+
adapters: this.#adapters,
|
|
227
|
+
traceWriter,
|
|
228
|
+
callStack: [],
|
|
229
|
+
maxCallDepth: DEFAULT_MAX_FLOW_CALL_DEPTH,
|
|
230
|
+
publishHudProgress: (hudStatus, flowEvent) => this.#publishHudProgressOrRecord(traceWriter, hudStatus, {
|
|
231
|
+
...flowEvent,
|
|
232
|
+
recipe,
|
|
233
|
+
}),
|
|
234
|
+
})
|
|
235
|
+
: await adapter.execute(node, context);
|
|
236
|
+
if (result.output !== undefined)
|
|
237
|
+
outputs.set(activeNodeId, result.output);
|
|
238
|
+
for (const artifact of result.artifacts ?? [])
|
|
239
|
+
artifactWriter.register(artifact);
|
|
240
|
+
const next = resolveNextNode(node, result);
|
|
241
|
+
traceWriter.record({
|
|
242
|
+
nodeId: activeNodeId,
|
|
243
|
+
action,
|
|
244
|
+
...traceNodeMetadata(node, result),
|
|
245
|
+
startedAt: nodeStartedAt.toISOString(),
|
|
246
|
+
endedAt: new Date().toISOString(),
|
|
247
|
+
durationMs: Date.now() - nodeStartedAt.getTime(),
|
|
248
|
+
ok: true,
|
|
249
|
+
next,
|
|
250
|
+
status: result.status,
|
|
251
|
+
output: result.output,
|
|
252
|
+
});
|
|
253
|
+
const hudCompleted = await this.#publishHudProgressOrRecord(traceWriter, result.status === 'fail' ? 'fail' : 'pass', {
|
|
254
|
+
nodeId: activeNodeId,
|
|
255
|
+
action,
|
|
256
|
+
node,
|
|
257
|
+
recipe,
|
|
258
|
+
index: transitionCount,
|
|
259
|
+
total: Object.keys(graph.nodes).length,
|
|
260
|
+
context,
|
|
261
|
+
});
|
|
262
|
+
if (!hudCompleted) {
|
|
263
|
+
status = 'fail';
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
if (result.status) {
|
|
267
|
+
if (runningTeardown) {
|
|
268
|
+
status = mainStatus === 'fail' ? 'fail' : result.status;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
mainStatus = result.status;
|
|
272
|
+
if (graph.teardownEntry) {
|
|
273
|
+
runningTeardown = true;
|
|
274
|
+
currentNodeId = graph.teardownEntry;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
status = result.status;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
currentNodeId = next;
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
284
|
+
traceWriter.record({
|
|
285
|
+
nodeId: activeNodeId,
|
|
286
|
+
action,
|
|
287
|
+
...traceNodeMetadata(node),
|
|
288
|
+
startedAt: nodeStartedAt.toISOString(),
|
|
289
|
+
endedAt: new Date().toISOString(),
|
|
290
|
+
durationMs: Date.now() - nodeStartedAt.getTime(),
|
|
291
|
+
ok: false,
|
|
292
|
+
error: message,
|
|
293
|
+
});
|
|
294
|
+
this.#logger.error(message);
|
|
295
|
+
await this.#publishHudProgressOrRecord(traceWriter, 'fail', {
|
|
296
|
+
nodeId: activeNodeId,
|
|
297
|
+
action,
|
|
298
|
+
node,
|
|
299
|
+
recipe,
|
|
300
|
+
index: transitionCount,
|
|
301
|
+
total: Object.keys(graph.nodes).length,
|
|
302
|
+
context,
|
|
303
|
+
error: message,
|
|
304
|
+
});
|
|
305
|
+
status = 'fail';
|
|
306
|
+
if (!runningTeardown && graph.teardownEntry) {
|
|
307
|
+
mainStatus = 'fail';
|
|
308
|
+
runningTeardown = true;
|
|
309
|
+
currentNodeId = graph.teardownEntry;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
artifactWriter.register({
|
|
316
|
+
path: 'summary.json',
|
|
317
|
+
type: 'summary',
|
|
318
|
+
label: 'Run summary',
|
|
319
|
+
category: 'system',
|
|
320
|
+
});
|
|
321
|
+
artifactWriter.register({
|
|
322
|
+
path: 'trace.json',
|
|
323
|
+
type: 'trace',
|
|
324
|
+
label: 'Execution trace',
|
|
325
|
+
category: 'system',
|
|
326
|
+
});
|
|
327
|
+
if (status === 'pass') {
|
|
328
|
+
const runHudStartedAt = new Date();
|
|
329
|
+
try {
|
|
330
|
+
await this.#publishRunHud(status, {
|
|
331
|
+
recipe,
|
|
332
|
+
projectRoot,
|
|
333
|
+
artifactsDir,
|
|
334
|
+
env: request.env ?? {},
|
|
335
|
+
outputs,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
340
|
+
traceWriter.record({
|
|
341
|
+
nodeId: 'recipe-complete:hud',
|
|
342
|
+
action: 'app.hud',
|
|
343
|
+
startedAt: runHudStartedAt.toISOString(),
|
|
344
|
+
endedAt: new Date().toISOString(),
|
|
345
|
+
durationMs: Date.now() - runHudStartedAt.getTime(),
|
|
346
|
+
ok: false,
|
|
347
|
+
error: message,
|
|
348
|
+
});
|
|
349
|
+
this.#logger.error(`app.hud complete update failed: ${message}`);
|
|
350
|
+
status = 'fail';
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (runRecording) {
|
|
354
|
+
const recordingToStop = runRecording;
|
|
355
|
+
runRecording = undefined;
|
|
356
|
+
try {
|
|
357
|
+
const videoArtifact = await this.#stopRunVideoRecording(recordingToStop);
|
|
358
|
+
artifactWriter.register(videoArtifact);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
const message = errorMessage(error);
|
|
362
|
+
await removePartialRunVideoOutput(recordingToStop.outputPath, this.#logger);
|
|
363
|
+
traceWriter.record({
|
|
364
|
+
nodeId: 'recipe-run:video',
|
|
365
|
+
action: 'record.video',
|
|
366
|
+
startedAt: startedAt.toISOString(),
|
|
367
|
+
endedAt: new Date().toISOString(),
|
|
368
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
369
|
+
ok: false,
|
|
370
|
+
error: message,
|
|
371
|
+
});
|
|
372
|
+
this.#logger.error(`record.video failed: ${message}`);
|
|
373
|
+
status = 'fail';
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
if (runRecording) {
|
|
379
|
+
const recordingToCleanup = runRecording;
|
|
380
|
+
runRecording = undefined;
|
|
381
|
+
await cleanupAbortedRunVideoRecording(recordingToCleanup, this.#logger);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const endedAt = new Date();
|
|
385
|
+
const tracePath = await traceWriter.write();
|
|
386
|
+
const trace = traceWriter.list();
|
|
387
|
+
const summary = {
|
|
388
|
+
status,
|
|
389
|
+
total: trace.length,
|
|
390
|
+
passed: trace.filter((entry) => entry.ok).length,
|
|
391
|
+
failed: trace.filter((entry) => !entry.ok).length,
|
|
392
|
+
startedAt: startedAt.toISOString(),
|
|
393
|
+
endedAt: endedAt.toISOString(),
|
|
394
|
+
durationMs: endedAt.getTime() - startedAt.getTime(),
|
|
395
|
+
harness: {
|
|
396
|
+
name: '@farmslot/recipe-harness',
|
|
397
|
+
version: HARNESS_VERSION,
|
|
398
|
+
runner_protocol_version: this.#actionManifest.runner_protocol_version,
|
|
399
|
+
action_registry_version: this.#actionManifest.action_registry_version,
|
|
400
|
+
},
|
|
401
|
+
...(this.#runnerProvenance ? { runner: this.#runnerProvenance } : {}),
|
|
402
|
+
};
|
|
403
|
+
const summaryPath = await summaryWriter.write(summary);
|
|
404
|
+
const artifactManifestPath = await artifactWriter.write(status, this.#runnerProvenance);
|
|
405
|
+
const packageValidation = validateRecipeArtifactPackage({
|
|
406
|
+
recipe,
|
|
407
|
+
manifest: {
|
|
408
|
+
version: 1,
|
|
409
|
+
runStatus: status,
|
|
410
|
+
...(this.#runnerProvenance ? { provenance: { runner: this.#runnerProvenance } } : {}),
|
|
411
|
+
artifacts: artifactWriter.list(),
|
|
412
|
+
},
|
|
413
|
+
artifactPaths: [
|
|
414
|
+
'recipe.json',
|
|
415
|
+
'summary.json',
|
|
416
|
+
'trace.json',
|
|
417
|
+
'artifact-manifest.json',
|
|
418
|
+
...artifactWriter.list().map((entry) => entry.path),
|
|
419
|
+
],
|
|
420
|
+
});
|
|
421
|
+
if (packageValidation.status === 'invalid') {
|
|
422
|
+
throw new Error(`Generated artifact package is invalid: ${packageValidation.findings
|
|
423
|
+
.map((finding) => `${finding.code} ${finding.path}`)
|
|
424
|
+
.join(', ')}`);
|
|
425
|
+
}
|
|
426
|
+
return { status, summaryPath, tracePath, artifactManifestPath, recipePath };
|
|
427
|
+
}
|
|
428
|
+
async #runPreconditions({ graph, recipe, projectRoot, artifactsDir, request, outputs, artifactWriter, traceWriter, }) {
|
|
429
|
+
for (const gate of graph.preconditions) {
|
|
430
|
+
const nodeId = `pre_conditions:${gate.id}`;
|
|
431
|
+
const checker = this.#preconditions.get(gate.id);
|
|
432
|
+
if (!checker) {
|
|
433
|
+
recordSyntheticFailure(traceWriter, nodeId, new Error(`Precondition ${gate.id} is declared by the recipe but has no checker registered.`), 'pre_condition');
|
|
434
|
+
return 'fail';
|
|
435
|
+
}
|
|
436
|
+
const startedAt = new Date();
|
|
437
|
+
const context = this.#createExecutionContext({
|
|
438
|
+
nodeId,
|
|
439
|
+
recipe,
|
|
440
|
+
projectRoot,
|
|
441
|
+
artifactsDir,
|
|
442
|
+
env: request.env ?? {},
|
|
443
|
+
outputs,
|
|
444
|
+
artifactWriter,
|
|
445
|
+
});
|
|
446
|
+
try {
|
|
447
|
+
const rawResult = await checker.execute(gate, context);
|
|
448
|
+
const result = normalizePreconditionResult(rawResult);
|
|
449
|
+
if (result.output !== undefined)
|
|
450
|
+
outputs.set(nodeId, result.output);
|
|
451
|
+
if (result.ok === false) {
|
|
452
|
+
throw new Error(result.error ?? `Precondition ${gate.id} failed.`);
|
|
453
|
+
}
|
|
454
|
+
traceWriter.record({
|
|
455
|
+
nodeId,
|
|
456
|
+
action: 'pre_condition',
|
|
457
|
+
startedAt: startedAt.toISOString(),
|
|
458
|
+
endedAt: new Date().toISOString(),
|
|
459
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
460
|
+
ok: true,
|
|
461
|
+
output: result.output,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
466
|
+
traceWriter.record({
|
|
467
|
+
nodeId,
|
|
468
|
+
action: 'pre_condition',
|
|
469
|
+
startedAt: startedAt.toISOString(),
|
|
470
|
+
endedAt: new Date().toISOString(),
|
|
471
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
472
|
+
ok: false,
|
|
473
|
+
error: message,
|
|
474
|
+
});
|
|
475
|
+
return 'fail';
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return 'pass';
|
|
479
|
+
}
|
|
480
|
+
#createExecutionContext({ nodeId, recipe, projectRoot, artifactsDir, env, outputs, artifactWriter, }) {
|
|
481
|
+
return {
|
|
482
|
+
nodeId,
|
|
483
|
+
recipe,
|
|
484
|
+
projectRoot,
|
|
485
|
+
artifactsDir,
|
|
486
|
+
env,
|
|
487
|
+
outputs,
|
|
488
|
+
getOutput(nodeId) {
|
|
489
|
+
if (!outputs.has(nodeId))
|
|
490
|
+
throw new Error(`No output recorded for node ${nodeId}.`);
|
|
491
|
+
return outputs.get(nodeId);
|
|
492
|
+
},
|
|
493
|
+
resolveProjectPath(relativePath) {
|
|
494
|
+
return path.join(projectRoot, normalizeRelativePath(relativePath));
|
|
495
|
+
},
|
|
496
|
+
resolveArtifactPath(relativePath) {
|
|
497
|
+
return path.join(artifactsDir, normalizeRelativePath(relativePath));
|
|
498
|
+
},
|
|
499
|
+
registerArtifact(entry) {
|
|
500
|
+
artifactWriter.register(entry);
|
|
501
|
+
},
|
|
502
|
+
logger: this.#logger,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
#videoRecorder() {
|
|
506
|
+
return this.#recording?.videoRecorder ?? createCaptureHelperVideoRecorder();
|
|
507
|
+
}
|
|
508
|
+
async #startRunVideoRecording({ recorder, videoOptions, recipe, projectRoot, artifactsDir, env, }) {
|
|
509
|
+
const doctor = await recorder.doctor?.();
|
|
510
|
+
if (doctor && !doctor.ok) {
|
|
511
|
+
throw new Error(`${recorder.name} doctor ${doctor.code}: ${doctor.message}${doctor.suggestedFix ? ` Suggested fix: ${doctor.suggestedFix}` : ''}`);
|
|
512
|
+
}
|
|
513
|
+
const node = {
|
|
514
|
+
action: 'record.video',
|
|
515
|
+
intent: 'Record the recipe run when motion proof is useful',
|
|
516
|
+
};
|
|
517
|
+
const target = videoOptions.target ??
|
|
518
|
+
(await this.#recording?.targetProvider?.resolveRecordingTarget({
|
|
519
|
+
nodeId: 'recipe-run',
|
|
520
|
+
node,
|
|
521
|
+
recipe,
|
|
522
|
+
projectRoot,
|
|
523
|
+
artifactsDir,
|
|
524
|
+
env,
|
|
525
|
+
scope: 'run',
|
|
526
|
+
}));
|
|
527
|
+
if (!target) {
|
|
528
|
+
throw new Error('--record-video requires a recording target. Provide a project RecordingTargetProvider or CLI target flags.');
|
|
529
|
+
}
|
|
530
|
+
const relativePath = 'videos/recipe-run.mp4';
|
|
531
|
+
const outputPath = path.join(artifactsDir, relativePath);
|
|
532
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
533
|
+
let recording;
|
|
534
|
+
try {
|
|
535
|
+
recording = await recorder.start({
|
|
536
|
+
outputPath,
|
|
537
|
+
target,
|
|
538
|
+
maxFps: videoOptions.maxFps,
|
|
539
|
+
maxSize: videoOptions.maxSize,
|
|
540
|
+
nodeId: 'recipe-run',
|
|
541
|
+
record: 'full_run',
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
await removePartialRunVideoOutput(outputPath, this.#logger);
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
548
|
+
const entry = {
|
|
549
|
+
path: relativePath,
|
|
550
|
+
type: 'video',
|
|
551
|
+
mimeType: 'video/mp4',
|
|
552
|
+
category: 'proof',
|
|
553
|
+
label: 'Recipe run video',
|
|
554
|
+
record: 'full_run',
|
|
555
|
+
recorder: {
|
|
556
|
+
name: recorder.name,
|
|
557
|
+
...(recorder.version ? { version: recorder.version } : {}),
|
|
558
|
+
...(recorder.platform ? { platform: recorder.platform } : {}),
|
|
559
|
+
target: manifestTarget(target),
|
|
560
|
+
},
|
|
561
|
+
...(videoOptions.maxFps != null ? { maxFps: videoOptions.maxFps } : {}),
|
|
562
|
+
};
|
|
563
|
+
return { recording, entry, outputPath };
|
|
564
|
+
}
|
|
565
|
+
async #stopRunVideoRecording(runRecording) {
|
|
566
|
+
const result = await runRecording.recording.stop();
|
|
567
|
+
await assertVideoOutputReady(runRecording.outputPath);
|
|
568
|
+
return result.recorder
|
|
569
|
+
? {
|
|
570
|
+
...runRecording.entry,
|
|
571
|
+
recorder: result.recorder,
|
|
572
|
+
}
|
|
573
|
+
: runRecording.entry;
|
|
574
|
+
}
|
|
575
|
+
#hudAction() {
|
|
576
|
+
if (this.#hud === false || this.#hud?.enabled === false)
|
|
577
|
+
return undefined;
|
|
578
|
+
return this.#adapters.has('app.hud') ? 'app.hud' : undefined;
|
|
579
|
+
}
|
|
580
|
+
async #publishHudProgressOrRecord(traceWriter, status, event) {
|
|
581
|
+
const startedAt = new Date();
|
|
582
|
+
try {
|
|
583
|
+
await this.#publishHudProgress(status, event);
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
// HUD is first-class when advertised, so rendering failures fail the run.
|
|
588
|
+
// They are still recorded instead of aborting artifact package writing.
|
|
589
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
590
|
+
traceWriter.record({
|
|
591
|
+
nodeId: `${event.nodeId}:hud:${status}`,
|
|
592
|
+
action: 'app.hud',
|
|
593
|
+
startedAt: startedAt.toISOString(),
|
|
594
|
+
endedAt: new Date().toISOString(),
|
|
595
|
+
durationMs: Date.now() - startedAt.getTime(),
|
|
596
|
+
ok: false,
|
|
597
|
+
error: message,
|
|
598
|
+
});
|
|
599
|
+
this.#logger.error(`app.hud ${status} update failed for ${event.nodeId}: ${message}`);
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async #publishHudProgress(status, event) {
|
|
604
|
+
const hudAction = this.#hudAction();
|
|
605
|
+
if (!hudAction || event.action === hudAction)
|
|
606
|
+
return;
|
|
607
|
+
const adapter = this.#adapters.get(hudAction);
|
|
608
|
+
if (!adapter)
|
|
609
|
+
return;
|
|
610
|
+
await adapter.execute(buildHudNode(status, event, this.#hud), event.context);
|
|
611
|
+
}
|
|
612
|
+
async #publishRunHud(status, request) {
|
|
613
|
+
const hudAction = this.#hudAction();
|
|
614
|
+
if (!hudAction)
|
|
615
|
+
return;
|
|
616
|
+
const adapter = this.#adapters.get(hudAction);
|
|
617
|
+
if (!adapter)
|
|
618
|
+
return;
|
|
619
|
+
const context = {
|
|
620
|
+
nodeId: 'recipe-complete',
|
|
621
|
+
recipe: request.recipe,
|
|
622
|
+
projectRoot: request.projectRoot,
|
|
623
|
+
artifactsDir: request.artifactsDir,
|
|
624
|
+
env: request.env,
|
|
625
|
+
outputs: request.outputs,
|
|
626
|
+
getOutput(nodeId) {
|
|
627
|
+
if (!request.outputs.has(nodeId))
|
|
628
|
+
throw new Error(`No output recorded for node ${nodeId}.`);
|
|
629
|
+
return request.outputs.get(nodeId);
|
|
630
|
+
},
|
|
631
|
+
resolveProjectPath(relativePath) {
|
|
632
|
+
return path.join(request.projectRoot, normalizeRelativePath(relativePath));
|
|
633
|
+
},
|
|
634
|
+
resolveArtifactPath(relativePath) {
|
|
635
|
+
return path.join(request.artifactsDir, normalizeRelativePath(relativePath));
|
|
636
|
+
},
|
|
637
|
+
registerArtifact() { },
|
|
638
|
+
logger: this.#logger,
|
|
639
|
+
};
|
|
640
|
+
await adapter.execute({
|
|
641
|
+
action: hudAction,
|
|
642
|
+
title: this.#hudTitle(request.recipe),
|
|
643
|
+
status,
|
|
644
|
+
node_id: 'recipe-complete',
|
|
645
|
+
phase: 'complete',
|
|
646
|
+
flow: 'run',
|
|
647
|
+
detail: status === 'pass' ? 'Recipe completed' : 'Recipe failed',
|
|
648
|
+
text: status === 'pass' ? 'Recipe completed' : 'Recipe failed',
|
|
649
|
+
progress: { complete: true },
|
|
650
|
+
}, context);
|
|
651
|
+
}
|
|
652
|
+
#hudTitle(recipe) {
|
|
653
|
+
if (this.#hud && this.#hud.title)
|
|
654
|
+
return this.#hud.title;
|
|
655
|
+
if (isRecord(recipe) && typeof recipe.title === 'string')
|
|
656
|
+
return recipe.title;
|
|
657
|
+
return 'Recipe run';
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function normalizeVideoRecordingOptions(input) {
|
|
661
|
+
if (!input)
|
|
662
|
+
return { mode: 'off' };
|
|
663
|
+
if (input === true)
|
|
664
|
+
return { mode: 'full-run' };
|
|
665
|
+
if (typeof input === 'string')
|
|
666
|
+
return normalizeVideoRecordingMode(input);
|
|
667
|
+
const mode = input.mode ?? 'full-run';
|
|
668
|
+
return { ...input, mode: normalizeVideoRecordingMode(mode).mode };
|
|
669
|
+
}
|
|
670
|
+
function normalizeVideoRecordingMode(mode) {
|
|
671
|
+
if (mode === 'off')
|
|
672
|
+
return { mode: 'off' };
|
|
673
|
+
if (mode === 'full-run')
|
|
674
|
+
return { mode: 'full-run' };
|
|
675
|
+
if (mode === 'proof-window' || mode === 'proof_window') {
|
|
676
|
+
throw new Error('recordVideo proof-window mode is reserved for future focused clips; use full-run for phase 1.');
|
|
677
|
+
}
|
|
678
|
+
throw new Error(`recordVideo mode must be full-run or off, got ${JSON.stringify(mode)}.`);
|
|
679
|
+
}
|
|
680
|
+
async function assertVideoOutputReady(outputPath) {
|
|
681
|
+
let stats;
|
|
682
|
+
try {
|
|
683
|
+
stats = await stat(outputPath);
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
throw new Error(`Recording output is missing: ${outputPath}`);
|
|
687
|
+
}
|
|
688
|
+
if (stats.size <= 0)
|
|
689
|
+
throw new Error(`Recording output is empty: ${outputPath}`);
|
|
690
|
+
}
|
|
691
|
+
//# sourceMappingURL=runner.js.map
|