@farmslot/recipe-harness 0.2.0 → 0.2.2
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 +5 -1
- package/dist/cli/run-command.d.ts.map +1 -1
- package/dist/cli/run-command.js +29 -1
- package/dist/cli/run-command.js.map +1 -1
- package/dist/cli/support.d.ts +9 -0
- package/dist/cli/support.d.ts.map +1 -1
- package/dist/cli/support.js +24 -0
- package/dist/cli/support.js.map +1 -1
- package/dist/core/flows.d.ts +12 -1
- package/dist/core/flows.d.ts.map +1 -1
- package/dist/core/flows.js +53 -2
- package/dist/core/flows.js.map +1 -1
- package/dist/core/hud.d.ts.map +1 -1
- package/dist/core/hud.js +13 -18
- package/dist/core/hud.js.map +1 -1
- 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 -1
- package/dist/core/runner.js +355 -196
- package/dist/core/runner.js.map +1 -1
- package/dist/core/trace.d.ts +1 -1
- package/dist/core/trace.d.ts.map +1 -1
- package/dist/core/trace.js +2 -0
- package/dist/core/trace.js.map +1 -1
- package/dist/core/types.d.ts +65 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- 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 +1 -0
- package/dist/runtime/cdp.d.ts.map +1 -1
- package/dist/runtime/cdp.js +63 -10
- package/dist/runtime/cdp.js.map +1 -1
- package/package.json +7 -6
- package/CHANGELOG.md +0 -15
package/dist/core/runner.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { mkdir } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getRecipeActionManifestActionNames, validateRecipeArtifactPackage, } from '@farmslot/protocol';
|
|
4
4
|
import { JsonArtifactWriter, JsonSummaryWriter, JsonTraceWriter } from '../node/writers.js';
|
|
5
|
+
import { createCaptureHelperVideoRecorder, errorMessage, manifestTarget, } from '../recording/capture-helper.js';
|
|
5
6
|
import { collectFlows, executeInlineFlowCall } from './flows.js';
|
|
6
7
|
import { extractWorkflowGraph, normalizePreconditionResult, resolveNextNode, } from './graph.js';
|
|
7
8
|
import { buildHudNode } from './hud.js';
|
|
8
9
|
import { isRecord, normalizeRelativePath, readJsonFile } from './json.js';
|
|
9
10
|
import { evaluateNodeGate } from './predicates.js';
|
|
11
|
+
import { cleanupAbortedRunVideoRecording, removePartialRunVideoOutput, } from './recording-cleanup.js';
|
|
10
12
|
import { recordSyntheticFailure, traceNodeMetadata } from './trace.js';
|
|
11
13
|
import { assertManifestIsValid, assertRecipeMatchesManifest } from './validation.js';
|
|
12
14
|
const HARNESS_VERSION = '0.1.0';
|
|
@@ -55,7 +57,7 @@ export function createRecipeRunner(options) {
|
|
|
55
57
|
throw new Error(`Manifest action ${action} has no registered adapter.`);
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
|
-
return new DefaultRecipeRunner(options.actionManifest, adapterMap, preconditionMap, options.logger ?? noopLogger, options.hud, options.runner);
|
|
60
|
+
return new DefaultRecipeRunner(options.actionManifest, adapterMap, preconditionMap, options.logger ?? noopLogger, options.hud, options.runner, options.recording);
|
|
59
61
|
}
|
|
60
62
|
class DefaultRecipeRunner {
|
|
61
63
|
#actionManifest;
|
|
@@ -64,13 +66,15 @@ class DefaultRecipeRunner {
|
|
|
64
66
|
#logger;
|
|
65
67
|
#hud;
|
|
66
68
|
#runnerProvenance;
|
|
67
|
-
|
|
69
|
+
#recording;
|
|
70
|
+
constructor(actionManifest, adapters, preconditions, logger, hud, runnerProvenance, recording) {
|
|
68
71
|
this.#actionManifest = actionManifest;
|
|
69
72
|
this.#adapters = adapters;
|
|
70
73
|
this.#preconditions = preconditions;
|
|
71
74
|
this.#logger = logger;
|
|
72
75
|
this.#hud = hud;
|
|
73
76
|
this.#runnerProvenance = runnerProvenance;
|
|
77
|
+
this.#recording = recording;
|
|
74
78
|
}
|
|
75
79
|
async run(request) {
|
|
76
80
|
if (!request.recipePath && request.recipeDocument == null) {
|
|
@@ -100,227 +104,281 @@ class DefaultRecipeRunner {
|
|
|
100
104
|
const visited = new Set();
|
|
101
105
|
let transitionCount = 0;
|
|
102
106
|
const maxTransitions = Object.keys(graph.nodes).length * 3;
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
while (currentNodeId) {
|
|
118
|
-
const activeNodeId = currentNodeId;
|
|
119
|
-
transitionCount += 1;
|
|
120
|
-
if (transitionCount > maxTransitions) {
|
|
121
|
-
const error = new Error('Recipe graph exceeded its maximum transition count.');
|
|
122
|
-
recordSyntheticFailure(traceWriter, activeNodeId, error);
|
|
123
|
-
status = 'fail';
|
|
124
|
-
break;
|
|
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
|
+
});
|
|
125
120
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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}`);
|
|
131
133
|
status = 'fail';
|
|
132
|
-
|
|
134
|
+
currentNodeId = undefined;
|
|
133
135
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
153
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
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);
|
|
170
241
|
traceWriter.record({
|
|
171
242
|
nodeId: activeNodeId,
|
|
172
243
|
action,
|
|
173
|
-
...traceNodeMetadata(node),
|
|
244
|
+
...traceNodeMetadata(node, result),
|
|
174
245
|
startedAt: nodeStartedAt.toISOString(),
|
|
175
246
|
endedAt: new Date().toISOString(),
|
|
176
247
|
durationMs: Date.now() - nodeStartedAt.getTime(),
|
|
177
248
|
ok: true,
|
|
178
249
|
next,
|
|
179
|
-
|
|
250
|
+
status: result.status,
|
|
251
|
+
output: result.output,
|
|
180
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
|
+
}
|
|
181
280
|
currentNodeId = next;
|
|
182
|
-
continue;
|
|
183
281
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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,
|
|
200
298
|
node,
|
|
299
|
+
recipe,
|
|
300
|
+
index: transitionCount,
|
|
301
|
+
total: Object.keys(graph.nodes).length,
|
|
201
302
|
context,
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
traceWriter,
|
|
205
|
-
callStack: [],
|
|
206
|
-
maxCallDepth: DEFAULT_MAX_FLOW_CALL_DEPTH,
|
|
207
|
-
})
|
|
208
|
-
: await adapter.execute(node, context);
|
|
209
|
-
if (result.output !== undefined)
|
|
210
|
-
outputs.set(activeNodeId, result.output);
|
|
211
|
-
for (const artifact of result.artifacts ?? [])
|
|
212
|
-
artifactWriter.register(artifact);
|
|
213
|
-
const next = resolveNextNode(node, result);
|
|
214
|
-
traceWriter.record({
|
|
215
|
-
nodeId: activeNodeId,
|
|
216
|
-
action,
|
|
217
|
-
...traceNodeMetadata(node, result),
|
|
218
|
-
startedAt: nodeStartedAt.toISOString(),
|
|
219
|
-
endedAt: new Date().toISOString(),
|
|
220
|
-
durationMs: Date.now() - nodeStartedAt.getTime(),
|
|
221
|
-
ok: true,
|
|
222
|
-
next,
|
|
223
|
-
status: result.status,
|
|
224
|
-
output: result.output,
|
|
225
|
-
});
|
|
226
|
-
const hudCompleted = await this.#publishHudProgressOrRecord(traceWriter, result.status === 'fail' ? 'fail' : 'pass', {
|
|
227
|
-
nodeId: activeNodeId,
|
|
228
|
-
action,
|
|
229
|
-
node,
|
|
230
|
-
recipe,
|
|
231
|
-
index: transitionCount,
|
|
232
|
-
total: Object.keys(graph.nodes).length,
|
|
233
|
-
context,
|
|
234
|
-
});
|
|
235
|
-
if (!hudCompleted) {
|
|
303
|
+
error: message,
|
|
304
|
+
});
|
|
236
305
|
status = 'fail';
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (result.status) {
|
|
240
|
-
if (runningTeardown) {
|
|
241
|
-
status = mainStatus === 'fail' ? 'fail' : result.status;
|
|
242
|
-
break;
|
|
243
|
-
}
|
|
244
|
-
mainStatus = result.status;
|
|
245
|
-
if (graph.teardownEntry) {
|
|
306
|
+
if (!runningTeardown && graph.teardownEntry) {
|
|
307
|
+
mainStatus = 'fail';
|
|
246
308
|
runningTeardown = true;
|
|
247
309
|
currentNodeId = graph.teardownEntry;
|
|
248
310
|
continue;
|
|
249
311
|
}
|
|
250
|
-
status = result.status;
|
|
251
312
|
break;
|
|
252
313
|
}
|
|
253
|
-
currentNodeId = next;
|
|
254
314
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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';
|
|
284
351
|
}
|
|
285
|
-
break;
|
|
286
352
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
});
|
|
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
|
+
}
|
|
310
375
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
endedAt: new Date().toISOString(),
|
|
318
|
-
durationMs: Date.now() - runHudStartedAt.getTime(),
|
|
319
|
-
ok: false,
|
|
320
|
-
error: message,
|
|
321
|
-
});
|
|
322
|
-
this.#logger.error(`app.hud complete update failed: ${message}`);
|
|
323
|
-
status = 'fail';
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
if (runRecording) {
|
|
379
|
+
const recordingToCleanup = runRecording;
|
|
380
|
+
runRecording = undefined;
|
|
381
|
+
await cleanupAbortedRunVideoRecording(recordingToCleanup, this.#logger);
|
|
324
382
|
}
|
|
325
383
|
}
|
|
326
384
|
const endedAt = new Date();
|
|
@@ -444,6 +502,76 @@ class DefaultRecipeRunner {
|
|
|
444
502
|
logger: this.#logger,
|
|
445
503
|
};
|
|
446
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
|
+
}
|
|
447
575
|
#hudAction() {
|
|
448
576
|
if (this.#hud === false || this.#hud?.enabled === false)
|
|
449
577
|
return undefined;
|
|
@@ -529,4 +657,35 @@ class DefaultRecipeRunner {
|
|
|
529
657
|
return 'Recipe run';
|
|
530
658
|
}
|
|
531
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
|
+
}
|
|
532
691
|
//# sourceMappingURL=runner.js.map
|