@farmslot/recipe-harness 0.1.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 (60) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +207 -0
  3. package/bin/farmslot-recipe.mjs +10 -0
  4. package/dist/adapters/core.d.ts +5 -0
  5. package/dist/adapters/core.d.ts.map +1 -0
  6. package/dist/adapters/core.js +438 -0
  7. package/dist/adapters/core.js.map +1 -0
  8. package/dist/adapters/ui.d.ts +23 -0
  9. package/dist/adapters/ui.d.ts.map +1 -0
  10. package/dist/adapters/ui.js +41 -0
  11. package/dist/adapters/ui.js.map +1 -0
  12. package/dist/cli/run-command.d.ts +3 -0
  13. package/dist/cli/run-command.d.ts.map +1 -0
  14. package/dist/cli/run-command.js +41 -0
  15. package/dist/cli/run-command.js.map +1 -0
  16. package/dist/cli/validate-command.d.ts +3 -0
  17. package/dist/cli/validate-command.d.ts.map +1 -0
  18. package/dist/cli/validate-command.js +31 -0
  19. package/dist/cli/validate-command.js.map +1 -0
  20. package/dist/cli-support.d.ts +14 -0
  21. package/dist/cli-support.d.ts.map +1 -0
  22. package/dist/cli-support.js +94 -0
  23. package/dist/cli-support.js.map +1 -0
  24. package/dist/cli.d.ts +4 -0
  25. package/dist/cli.d.ts.map +1 -0
  26. package/dist/cli.js +29 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/index.d.ts +15 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +10 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/json.d.ts +9 -0
  33. package/dist/json.d.ts.map +1 -0
  34. package/dist/json.js +74 -0
  35. package/dist/json.js.map +1 -0
  36. package/dist/runner.d.ts +4 -0
  37. package/dist/runner.d.ts.map +1 -0
  38. package/dist/runner.js +1172 -0
  39. package/dist/runner.js.map +1 -0
  40. package/dist/runtime/browser-extension.d.ts +17 -0
  41. package/dist/runtime/browser-extension.d.ts.map +1 -0
  42. package/dist/runtime/browser-extension.js +42 -0
  43. package/dist/runtime/browser-extension.js.map +1 -0
  44. package/dist/runtime/cdp.d.ts +86 -0
  45. package/dist/runtime/cdp.d.ts.map +1 -0
  46. package/dist/runtime/cdp.js +512 -0
  47. package/dist/runtime/cdp.js.map +1 -0
  48. package/dist/runtime/react-native-bridge.d.ts +17 -0
  49. package/dist/runtime/react-native-bridge.d.ts.map +1 -0
  50. package/dist/runtime/react-native-bridge.js +24 -0
  51. package/dist/runtime/react-native-bridge.js.map +1 -0
  52. package/dist/types.d.ts +143 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +2 -0
  55. package/dist/types.js.map +1 -0
  56. package/dist/writers.d.ts +23 -0
  57. package/dist/writers.d.ts.map +1 -0
  58. package/dist/writers.js +72 -0
  59. package/dist/writers.js.map +1 -0
  60. package/package.json +79 -0
package/dist/runner.js ADDED
@@ -0,0 +1,1172 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { getRecipeActionManifestActionNames, validateRecipeActionManifestDocument, validateRecipeArtifactPackage, validateRecipeWithManifest, } from '@farmslot/protocol';
5
+ import { isRecord, normalizeRelativePath, readJsonFile } from './json.js';
6
+ import { JsonArtifactWriter, JsonSummaryWriter, JsonTraceWriter } from './writers.js';
7
+ const HARNESS_VERSION = '0.1.0';
8
+ const RUNNER_BUILT_IN_ACTIONS = new Set(['call']);
9
+ const DEFAULT_MAX_FLOW_CALL_DEPTH = 8;
10
+ const noopLogger = {
11
+ info() { },
12
+ warn() { },
13
+ error() { },
14
+ };
15
+ export function defineActionAdapter(adapter) {
16
+ if (!adapter.action.trim())
17
+ throw new Error('Action adapter action must be a non-empty string.');
18
+ return adapter;
19
+ }
20
+ export function createRecipeRunner(options) {
21
+ assertManifestIsValid(options.actionManifest);
22
+ const declaredActions = new Set(getRecipeActionManifestActionNames(options.actionManifest));
23
+ const adapterMap = new Map();
24
+ const preconditionMap = new Map();
25
+ const declaredPreconditions = new Set((options.actionManifest.pre_conditions ?? []).map((entry) => entry.id));
26
+ for (const checker of options.preconditions ?? []) {
27
+ if (!checker.id.trim())
28
+ throw new Error('Precondition checker id must be a non-empty string.');
29
+ if (!declaredPreconditions.has(checker.id)) {
30
+ throw new Error(`Precondition checker ${checker.id} is not declared by the action manifest.`);
31
+ }
32
+ if (preconditionMap.has(checker.id)) {
33
+ throw new Error(`Precondition checker ${checker.id} is registered more than once.`);
34
+ }
35
+ preconditionMap.set(checker.id, checker);
36
+ }
37
+ for (const adapter of options.adapters) {
38
+ if (!adapter.action.trim())
39
+ throw new Error('Action adapter action must be a non-empty string.');
40
+ if (!declaredActions.has(adapter.action) && adapter.testOnly !== true) {
41
+ throw new Error(`Adapter ${adapter.action} is not declared by the recipe action manifest.`);
42
+ }
43
+ if (adapterMap.has(adapter.action)) {
44
+ throw new Error(`Adapter ${adapter.action} is registered more than once.`);
45
+ }
46
+ adapterMap.set(adapter.action, adapter);
47
+ }
48
+ for (const action of declaredActions) {
49
+ if (!adapterMap.has(action) && !RUNNER_BUILT_IN_ACTIONS.has(action)) {
50
+ throw new Error(`Manifest action ${action} has no registered adapter.`);
51
+ }
52
+ }
53
+ return new DefaultRecipeRunner(options.actionManifest, adapterMap, preconditionMap, options.logger ?? noopLogger, options.hud, options.runner);
54
+ }
55
+ class DefaultRecipeRunner {
56
+ #actionManifest;
57
+ #adapters;
58
+ #preconditions;
59
+ #logger;
60
+ #hud;
61
+ #runnerProvenance;
62
+ constructor(actionManifest, adapters, preconditions, logger, hud, runnerProvenance) {
63
+ this.#actionManifest = actionManifest;
64
+ this.#adapters = adapters;
65
+ this.#preconditions = preconditions;
66
+ this.#logger = logger;
67
+ this.#hud = hud;
68
+ this.#runnerProvenance = runnerProvenance;
69
+ }
70
+ async run(request) {
71
+ if (!request.recipePath && request.recipeDocument == null) {
72
+ throw new Error('Recipe run requires recipePath or recipeDocument.');
73
+ }
74
+ const projectRoot = path.resolve(request.projectRoot ?? process.cwd());
75
+ const artifactsDir = path.resolve(request.artifactsDir);
76
+ const startedAt = new Date();
77
+ await mkdir(artifactsDir, { recursive: true });
78
+ const sourceRecipePath = request.recipePath ? path.resolve(request.recipePath) : undefined;
79
+ const recipe = request.recipeDocument ?? (await readJsonFile(sourceRecipePath));
80
+ assertRecipeMatchesManifest(recipe, this.#actionManifest);
81
+ const graph = extractWorkflowGraph(recipe);
82
+ const flowCatalog = await collectFlows(recipe, {
83
+ projectRoot,
84
+ recipeDir: sourceRecipePath ? path.dirname(sourceRecipePath) : projectRoot,
85
+ });
86
+ const artifactWriter = new JsonArtifactWriter(artifactsDir);
87
+ const traceWriter = new JsonTraceWriter(artifactsDir, this.#runnerProvenance);
88
+ const summaryWriter = new JsonSummaryWriter(artifactsDir);
89
+ const outputs = new Map();
90
+ const recipePath = await artifactWriter.copyRecipe(recipe);
91
+ let status = 'unknown';
92
+ let currentNodeId = graph.entry;
93
+ let mainStatus = 'unknown';
94
+ let runningTeardown = false;
95
+ const visited = new Set();
96
+ let transitionCount = 0;
97
+ const maxTransitions = Object.keys(graph.nodes).length * 3;
98
+ const preconditionStatus = await this.#runPreconditions({
99
+ graph,
100
+ recipe,
101
+ projectRoot,
102
+ artifactsDir,
103
+ request,
104
+ outputs,
105
+ artifactWriter,
106
+ traceWriter,
107
+ });
108
+ if (preconditionStatus === 'fail') {
109
+ status = 'fail';
110
+ currentNodeId = undefined;
111
+ }
112
+ while (currentNodeId) {
113
+ const activeNodeId = currentNodeId;
114
+ transitionCount += 1;
115
+ if (transitionCount > maxTransitions) {
116
+ const error = new Error('Recipe graph exceeded its maximum transition count.');
117
+ recordSyntheticFailure(traceWriter, activeNodeId, error);
118
+ status = 'fail';
119
+ break;
120
+ }
121
+ visited.add(activeNodeId);
122
+ const node = graph.nodes[activeNodeId];
123
+ if (!node) {
124
+ const error = new Error(`Recipe node ${activeNodeId} does not exist.`);
125
+ recordSyntheticFailure(traceWriter, activeNodeId, error);
126
+ status = 'fail';
127
+ break;
128
+ }
129
+ const action = String(node.action);
130
+ const adapter = action === 'call' ? undefined : this.#adapters.get(action);
131
+ if (action !== 'call' && !adapter) {
132
+ const error = new Error(`No adapter registered for action ${action}.`);
133
+ recordSyntheticFailure(traceWriter, activeNodeId, error, action);
134
+ status = 'fail';
135
+ break;
136
+ }
137
+ const nodeStartedAt = new Date();
138
+ const context = {
139
+ nodeId: activeNodeId,
140
+ recipe,
141
+ projectRoot,
142
+ artifactsDir,
143
+ env: request.env ?? {},
144
+ outputs,
145
+ getOutput(nodeId) {
146
+ if (!outputs.has(nodeId))
147
+ throw new Error(`No output recorded for node ${nodeId}.`);
148
+ return outputs.get(nodeId);
149
+ },
150
+ resolveProjectPath(relativePath) {
151
+ return path.join(projectRoot, normalizeRelativePath(relativePath));
152
+ },
153
+ resolveArtifactPath(relativePath) {
154
+ return path.join(artifactsDir, normalizeRelativePath(relativePath));
155
+ },
156
+ registerArtifact(entry) {
157
+ artifactWriter.register(entry);
158
+ },
159
+ logger: this.#logger,
160
+ };
161
+ try {
162
+ const gate = evaluateNodeGate(node, context.outputs);
163
+ if (!gate.run) {
164
+ const next = resolveNextNode(node, {});
165
+ traceWriter.record({
166
+ nodeId: activeNodeId,
167
+ action,
168
+ ...traceNodeMetadata(node),
169
+ startedAt: nodeStartedAt.toISOString(),
170
+ endedAt: new Date().toISOString(),
171
+ durationMs: Date.now() - nodeStartedAt.getTime(),
172
+ ok: true,
173
+ next,
174
+ output: { skipped: true, reason: gate.reason },
175
+ });
176
+ currentNodeId = next;
177
+ continue;
178
+ }
179
+ const hudStarted = await this.#publishHudProgressOrRecord(traceWriter, 'running', {
180
+ nodeId: activeNodeId,
181
+ action,
182
+ node,
183
+ recipe,
184
+ index: transitionCount,
185
+ total: Object.keys(graph.nodes).length,
186
+ context,
187
+ });
188
+ if (!hudStarted) {
189
+ status = 'fail';
190
+ break;
191
+ }
192
+ const result = action === 'call'
193
+ ? await executeInlineFlowCall({
194
+ callNodeId: activeNodeId,
195
+ node,
196
+ context,
197
+ flowCatalog,
198
+ adapters: this.#adapters,
199
+ traceWriter,
200
+ callStack: [],
201
+ maxCallDepth: DEFAULT_MAX_FLOW_CALL_DEPTH,
202
+ })
203
+ : await adapter.execute(node, context);
204
+ if (result.output !== undefined)
205
+ outputs.set(activeNodeId, result.output);
206
+ for (const artifact of result.artifacts ?? [])
207
+ artifactWriter.register(artifact);
208
+ const next = resolveNextNode(node, result);
209
+ traceWriter.record({
210
+ nodeId: activeNodeId,
211
+ action,
212
+ ...traceNodeMetadata(node, result),
213
+ startedAt: nodeStartedAt.toISOString(),
214
+ endedAt: new Date().toISOString(),
215
+ durationMs: Date.now() - nodeStartedAt.getTime(),
216
+ ok: true,
217
+ next,
218
+ status: result.status,
219
+ output: result.output,
220
+ });
221
+ const hudCompleted = await this.#publishHudProgressOrRecord(traceWriter, result.status === 'fail' ? 'fail' : 'pass', {
222
+ nodeId: activeNodeId,
223
+ action,
224
+ node,
225
+ recipe,
226
+ index: transitionCount,
227
+ total: Object.keys(graph.nodes).length,
228
+ context,
229
+ });
230
+ if (!hudCompleted) {
231
+ status = 'fail';
232
+ break;
233
+ }
234
+ if (result.status) {
235
+ if (runningTeardown) {
236
+ status = mainStatus === 'fail' ? 'fail' : result.status;
237
+ break;
238
+ }
239
+ mainStatus = result.status;
240
+ if (graph.teardownEntry) {
241
+ runningTeardown = true;
242
+ currentNodeId = graph.teardownEntry;
243
+ continue;
244
+ }
245
+ status = result.status;
246
+ break;
247
+ }
248
+ currentNodeId = next;
249
+ }
250
+ catch (error) {
251
+ const message = error instanceof Error ? error.message : String(error);
252
+ traceWriter.record({
253
+ nodeId: activeNodeId,
254
+ action,
255
+ ...traceNodeMetadata(node),
256
+ startedAt: nodeStartedAt.toISOString(),
257
+ endedAt: new Date().toISOString(),
258
+ durationMs: Date.now() - nodeStartedAt.getTime(),
259
+ ok: false,
260
+ error: message,
261
+ });
262
+ this.#logger.error(message);
263
+ await this.#publishHudProgressOrRecord(traceWriter, 'fail', {
264
+ nodeId: activeNodeId,
265
+ action,
266
+ node,
267
+ recipe,
268
+ index: transitionCount,
269
+ total: Object.keys(graph.nodes).length,
270
+ context,
271
+ error: message,
272
+ });
273
+ status = 'fail';
274
+ if (!runningTeardown && graph.teardownEntry) {
275
+ mainStatus = 'fail';
276
+ runningTeardown = true;
277
+ currentNodeId = graph.teardownEntry;
278
+ continue;
279
+ }
280
+ break;
281
+ }
282
+ }
283
+ artifactWriter.register({
284
+ path: 'summary.json',
285
+ type: 'summary',
286
+ label: 'Run summary',
287
+ category: 'system',
288
+ });
289
+ artifactWriter.register({
290
+ path: 'trace.json',
291
+ type: 'trace',
292
+ label: 'Execution trace',
293
+ category: 'system',
294
+ });
295
+ if (status === 'pass') {
296
+ const runHudStartedAt = new Date();
297
+ try {
298
+ await this.#publishRunHud(status, {
299
+ recipe,
300
+ projectRoot,
301
+ artifactsDir,
302
+ env: request.env ?? {},
303
+ outputs,
304
+ });
305
+ }
306
+ catch (error) {
307
+ const message = error instanceof Error ? error.message : String(error);
308
+ traceWriter.record({
309
+ nodeId: 'recipe-complete:hud',
310
+ action: 'app.hud',
311
+ startedAt: runHudStartedAt.toISOString(),
312
+ endedAt: new Date().toISOString(),
313
+ durationMs: Date.now() - runHudStartedAt.getTime(),
314
+ ok: false,
315
+ error: message,
316
+ });
317
+ this.#logger.error(`app.hud complete update failed: ${message}`);
318
+ status = 'fail';
319
+ }
320
+ }
321
+ const endedAt = new Date();
322
+ const tracePath = await traceWriter.write();
323
+ const trace = traceWriter.list();
324
+ const summary = {
325
+ status,
326
+ total: trace.length,
327
+ passed: trace.filter((entry) => entry.ok).length,
328
+ failed: trace.filter((entry) => !entry.ok).length,
329
+ startedAt: startedAt.toISOString(),
330
+ endedAt: endedAt.toISOString(),
331
+ durationMs: endedAt.getTime() - startedAt.getTime(),
332
+ harness: {
333
+ name: '@farmslot/recipe-harness',
334
+ version: HARNESS_VERSION,
335
+ runner_protocol_version: this.#actionManifest.runner_protocol_version,
336
+ action_registry_version: this.#actionManifest.action_registry_version,
337
+ },
338
+ ...(this.#runnerProvenance ? { runner: this.#runnerProvenance } : {}),
339
+ };
340
+ const summaryPath = await summaryWriter.write(summary);
341
+ const artifactManifestPath = await artifactWriter.write(status, this.#runnerProvenance);
342
+ const packageValidation = validateRecipeArtifactPackage({
343
+ recipe,
344
+ manifest: {
345
+ version: 1,
346
+ runStatus: status,
347
+ ...(this.#runnerProvenance ? { provenance: { runner: this.#runnerProvenance } } : {}),
348
+ artifacts: artifactWriter.list(),
349
+ },
350
+ artifactPaths: [
351
+ 'recipe.json',
352
+ 'summary.json',
353
+ 'trace.json',
354
+ 'artifact-manifest.json',
355
+ ...artifactWriter.list().map((entry) => entry.path),
356
+ ],
357
+ });
358
+ if (packageValidation.status === 'invalid') {
359
+ throw new Error(`Generated artifact package is invalid: ${packageValidation.findings
360
+ .map((finding) => `${finding.code} ${finding.path}`)
361
+ .join(', ')}`);
362
+ }
363
+ return { status, summaryPath, tracePath, artifactManifestPath, recipePath };
364
+ }
365
+ async #runPreconditions({ graph, recipe, projectRoot, artifactsDir, request, outputs, artifactWriter, traceWriter, }) {
366
+ for (const gate of graph.preconditions) {
367
+ const nodeId = `pre_conditions:${gate.id}`;
368
+ const checker = this.#preconditions.get(gate.id);
369
+ if (!checker) {
370
+ recordSyntheticFailure(traceWriter, nodeId, new Error(`Precondition ${gate.id} is declared by the recipe but has no checker registered.`), 'pre_condition');
371
+ return 'fail';
372
+ }
373
+ const startedAt = new Date();
374
+ const context = this.#createExecutionContext({
375
+ nodeId,
376
+ recipe,
377
+ projectRoot,
378
+ artifactsDir,
379
+ env: request.env ?? {},
380
+ outputs,
381
+ artifactWriter,
382
+ });
383
+ try {
384
+ const rawResult = await checker.execute(gate, context);
385
+ const result = normalizePreconditionResult(rawResult);
386
+ if (result.output !== undefined)
387
+ outputs.set(nodeId, result.output);
388
+ if (result.ok === false) {
389
+ throw new Error(result.error ?? `Precondition ${gate.id} failed.`);
390
+ }
391
+ traceWriter.record({
392
+ nodeId,
393
+ action: 'pre_condition',
394
+ startedAt: startedAt.toISOString(),
395
+ endedAt: new Date().toISOString(),
396
+ durationMs: Date.now() - startedAt.getTime(),
397
+ ok: true,
398
+ output: result.output,
399
+ });
400
+ }
401
+ catch (error) {
402
+ const message = error instanceof Error ? error.message : String(error);
403
+ traceWriter.record({
404
+ nodeId,
405
+ action: 'pre_condition',
406
+ startedAt: startedAt.toISOString(),
407
+ endedAt: new Date().toISOString(),
408
+ durationMs: Date.now() - startedAt.getTime(),
409
+ ok: false,
410
+ error: message,
411
+ });
412
+ return 'fail';
413
+ }
414
+ }
415
+ return 'pass';
416
+ }
417
+ #createExecutionContext({ nodeId, recipe, projectRoot, artifactsDir, env, outputs, artifactWriter, }) {
418
+ return {
419
+ nodeId,
420
+ recipe,
421
+ projectRoot,
422
+ artifactsDir,
423
+ env,
424
+ outputs,
425
+ getOutput(nodeId) {
426
+ if (!outputs.has(nodeId))
427
+ throw new Error(`No output recorded for node ${nodeId}.`);
428
+ return outputs.get(nodeId);
429
+ },
430
+ resolveProjectPath(relativePath) {
431
+ return path.join(projectRoot, normalizeRelativePath(relativePath));
432
+ },
433
+ resolveArtifactPath(relativePath) {
434
+ return path.join(artifactsDir, normalizeRelativePath(relativePath));
435
+ },
436
+ registerArtifact(entry) {
437
+ artifactWriter.register(entry);
438
+ },
439
+ logger: this.#logger,
440
+ };
441
+ }
442
+ #hudAction() {
443
+ if (this.#hud === false || this.#hud?.enabled === false)
444
+ return undefined;
445
+ return this.#adapters.has('app.hud') ? 'app.hud' : undefined;
446
+ }
447
+ async #publishHudProgressOrRecord(traceWriter, status, event) {
448
+ const startedAt = new Date();
449
+ try {
450
+ await this.#publishHudProgress(status, event);
451
+ return true;
452
+ }
453
+ catch (error) {
454
+ // HUD is first-class when advertised, so rendering failures fail the run.
455
+ // They are still recorded instead of aborting artifact package writing.
456
+ const message = error instanceof Error ? error.message : String(error);
457
+ traceWriter.record({
458
+ nodeId: `${event.nodeId}:hud:${status}`,
459
+ action: 'app.hud',
460
+ startedAt: startedAt.toISOString(),
461
+ endedAt: new Date().toISOString(),
462
+ durationMs: Date.now() - startedAt.getTime(),
463
+ ok: false,
464
+ error: message,
465
+ });
466
+ this.#logger.error(`app.hud ${status} update failed for ${event.nodeId}: ${message}`);
467
+ return false;
468
+ }
469
+ }
470
+ async #publishHudProgress(status, event) {
471
+ const hudAction = this.#hudAction();
472
+ if (!hudAction || event.action === hudAction)
473
+ return;
474
+ const adapter = this.#adapters.get(hudAction);
475
+ if (!adapter)
476
+ return;
477
+ await adapter.execute(buildHudNode(status, event, this.#hud), event.context);
478
+ }
479
+ async #publishRunHud(status, request) {
480
+ const hudAction = this.#hudAction();
481
+ if (!hudAction)
482
+ return;
483
+ const adapter = this.#adapters.get(hudAction);
484
+ if (!adapter)
485
+ return;
486
+ const context = {
487
+ nodeId: 'recipe-complete',
488
+ recipe: request.recipe,
489
+ projectRoot: request.projectRoot,
490
+ artifactsDir: request.artifactsDir,
491
+ env: request.env,
492
+ outputs: request.outputs,
493
+ getOutput(nodeId) {
494
+ if (!request.outputs.has(nodeId))
495
+ throw new Error(`No output recorded for node ${nodeId}.`);
496
+ return request.outputs.get(nodeId);
497
+ },
498
+ resolveProjectPath(relativePath) {
499
+ return path.join(request.projectRoot, normalizeRelativePath(relativePath));
500
+ },
501
+ resolveArtifactPath(relativePath) {
502
+ return path.join(request.artifactsDir, normalizeRelativePath(relativePath));
503
+ },
504
+ registerArtifact() { },
505
+ logger: this.#logger,
506
+ };
507
+ await adapter.execute({
508
+ action: hudAction,
509
+ title: this.#hudTitle(request.recipe),
510
+ status,
511
+ node_id: 'recipe-complete',
512
+ phase: 'complete',
513
+ flow: 'run',
514
+ detail: status === 'pass' ? 'Recipe completed' : 'Recipe failed',
515
+ text: status === 'pass' ? 'Recipe completed' : 'Recipe failed',
516
+ progress: { complete: true },
517
+ }, context);
518
+ }
519
+ #hudTitle(recipe) {
520
+ if (this.#hud && this.#hud.title)
521
+ return this.#hud.title;
522
+ if (isRecord(recipe) && typeof recipe.title === 'string')
523
+ return recipe.title;
524
+ return 'Recipe run';
525
+ }
526
+ }
527
+ function buildHudNode(status, event, options) {
528
+ const title = options && options.title
529
+ ? options.title
530
+ : isRecord(event.recipe) && typeof event.recipe.title === 'string'
531
+ ? event.recipe.title
532
+ : 'Recipe run';
533
+ const flow = hudFlow(event.action, event.node);
534
+ const subIntent = hudSubIntent(event.node);
535
+ const text = hudText(event.node);
536
+ const detail = normalizeHudSecondary(hudDetail(event.node), text, subIntent || flow);
537
+ const phase = typeof event.node.phase === 'string' && event.node.phase.trim()
538
+ ? event.node.phase
539
+ : lifecyclePhase(status);
540
+ return {
541
+ action: 'app.hud',
542
+ title,
543
+ status,
544
+ node_id: event.nodeId,
545
+ phase,
546
+ flow: flow || undefined,
547
+ detail,
548
+ text,
549
+ sub_intent: subIntent || undefined,
550
+ action_name: event.action,
551
+ proofTarget: event.node.proofTarget,
552
+ record: event.node.record,
553
+ intent: text,
554
+ error: event.error ? hudErrorSummary(event.error) : undefined,
555
+ display: isRecord(event.node.display) ? event.node.display : hudDisplay(options),
556
+ progress: {
557
+ current: event.index,
558
+ total: event.total,
559
+ },
560
+ };
561
+ }
562
+ function hudDisplay(options) {
563
+ if (!options || !options.display)
564
+ return {};
565
+ return { ...options.display };
566
+ }
567
+ function lifecyclePhase(status) {
568
+ if (status === 'running')
569
+ return 'running';
570
+ if (status === 'fail')
571
+ return 'failed';
572
+ return 'passed';
573
+ }
574
+ function hudFlow(_action, node) {
575
+ for (const key of ['flow', 'domain', 'group']) {
576
+ const value = node[key];
577
+ if (typeof value === 'string' && value.trim())
578
+ return value;
579
+ }
580
+ return '';
581
+ }
582
+ function hudSubIntent(node) {
583
+ for (const key of ['sub_intent', 'subIntent']) {
584
+ const value = node[key];
585
+ if (typeof value === 'string' && value.trim())
586
+ return value;
587
+ }
588
+ return '';
589
+ }
590
+ function hudText(node) {
591
+ for (const key of ['intent', 'label', 'title', 'description', 'text']) {
592
+ const value = node[key];
593
+ if (typeof value === 'string' && value.trim())
594
+ return value;
595
+ }
596
+ return 'Executing recipe step';
597
+ }
598
+ function hudDetail(node) {
599
+ const explicitDetail = node.detail;
600
+ if (typeof explicitDetail === 'string' && explicitDetail.trim())
601
+ return explicitDetail;
602
+ const fragments = [];
603
+ for (const key of ['target', 'destination', 'selector', 'test_id', 'testID']) {
604
+ const value = node[key];
605
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
606
+ fragments.push(`${key}=${String(value)}`);
607
+ }
608
+ }
609
+ return fragments.length ? fragments.join(' · ') : undefined;
610
+ }
611
+ function normalizeHudSecondary(value, intent, flow) {
612
+ const normalized = value?.trim();
613
+ if (!normalized)
614
+ return undefined;
615
+ if (normalized === intent || normalized === flow)
616
+ return undefined;
617
+ return normalized;
618
+ }
619
+ function hudErrorSummary(error) {
620
+ const nestedError = error.match(/"error":"([^"]+)"/)?.[1];
621
+ const lines = error
622
+ .split('\n')
623
+ .map((line) => line.trim())
624
+ .filter((line) => {
625
+ if (!line)
626
+ return false;
627
+ if (line.includes("Warning: The 'NO_COLOR' env"))
628
+ return false;
629
+ if (line.startsWith('(Use `node --trace-warnings'))
630
+ return false;
631
+ if (line.startsWith('file://'))
632
+ return false;
633
+ if (line.startsWith('at '))
634
+ return false;
635
+ if (line.startsWith('Node.js '))
636
+ return false;
637
+ return true;
638
+ });
639
+ const errorLine = [...lines].reverse().find((line) => line.startsWith('Error: ')) ??
640
+ lines.find((line) => line.includes('Error: ')) ??
641
+ lines[0] ??
642
+ error;
643
+ let summary = errorLine.includes('Error: ')
644
+ ? errorLine.slice(errorLine.indexOf('Error: ') + 'Error: '.length)
645
+ : errorLine;
646
+ const resultIndex = summary.indexOf('. Result:');
647
+ if (resultIndex > 0)
648
+ summary = summary.slice(0, resultIndex);
649
+ if (nestedError && !summary.includes(nestedError))
650
+ summary = `${summary}: ${nestedError}`;
651
+ const maxLength = 180;
652
+ return summary.length > maxLength ? `${summary.slice(0, maxLength - 1)}…` : summary;
653
+ }
654
+ function assertManifestIsValid(manifest) {
655
+ const result = validateRecipeActionManifestDocument(manifest);
656
+ if (result.status === 'invalid') {
657
+ throw new Error(`Recipe action manifest is invalid: ${result.findings
658
+ .map((finding) => `${finding.code} ${finding.path}: ${finding.message}`)
659
+ .join('; ')}`);
660
+ }
661
+ }
662
+ function assertRecipeMatchesManifest(recipe, manifest) {
663
+ const result = validateRecipeWithManifest(recipe, manifest);
664
+ if (result.status === 'invalid') {
665
+ throw new Error(`Recipe is invalid for the action manifest: ${result.findings
666
+ .map((finding) => `${finding.code} ${finding.path}: ${finding.message}`)
667
+ .join('; ')}`);
668
+ }
669
+ }
670
+ function extractWorkflowGraph(recipe) {
671
+ if (!isRecord(recipe) || !isRecord(recipe.validate) || !isRecord(recipe.validate.workflow)) {
672
+ throw new Error('Recipe must include validate.workflow.');
673
+ }
674
+ const workflow = recipe.validate.workflow;
675
+ if (typeof workflow.entry !== 'string' || !isRecord(workflow.nodes)) {
676
+ throw new Error('Recipe workflow must include entry and nodes.');
677
+ }
678
+ const nodes = Object.create(null);
679
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
680
+ if (isRecord(node))
681
+ nodes[nodeId] = node;
682
+ }
683
+ const mainEntry = workflow.entry;
684
+ const setupNodes = parseLifecycleNodes(workflow.setup, 'setup');
685
+ const startState = isRecord(recipe.startState)
686
+ ? [{ id: 'startState', node: recipe.startState }]
687
+ : [];
688
+ const teardownNodes = parseLifecycleNodes(workflow.teardown, 'teardown');
689
+ const preconditions = parsePreconditions(workflow.pre_conditions);
690
+ let entry = mainEntry;
691
+ const setupChain = [...setupNodes, ...startState];
692
+ if (setupChain.length > 0) {
693
+ entry = setupChain[0].id;
694
+ linkLifecycleNodes(nodes, setupChain, mainEntry);
695
+ }
696
+ let teardownEntry;
697
+ if (teardownNodes.length > 0) {
698
+ teardownEntry = teardownNodes[0].id;
699
+ linkLifecycleNodes(nodes, teardownNodes, 'teardown:end');
700
+ nodes['teardown:end'] = { action: 'end', status: 'pass', phase: 'teardown' };
701
+ }
702
+ return { entry, nodes, preconditions, teardownEntry };
703
+ }
704
+ function parsePreconditions(value) {
705
+ if (value == null)
706
+ return [];
707
+ if (!Array.isArray(value)) {
708
+ throw new Error('validate.workflow.pre_conditions must be an array.');
709
+ }
710
+ return value.map((entry, index) => {
711
+ if (typeof entry === 'string' && entry.trim())
712
+ return { id: entry.trim() };
713
+ if (!isRecord(entry)) {
714
+ throw new Error(`validate.workflow.pre_conditions[${index}] must be a string or object.`);
715
+ }
716
+ if (typeof entry.id !== 'string' || !entry.id.trim()) {
717
+ throw new Error(`validate.workflow.pre_conditions[${index}].id must be a non-empty string.`);
718
+ }
719
+ if (entry.params != null && !isRecord(entry.params)) {
720
+ throw new Error(`validate.workflow.pre_conditions[${index}].params must be an object.`);
721
+ }
722
+ return {
723
+ id: entry.id.trim(),
724
+ description: typeof entry.description === 'string' ? entry.description : undefined,
725
+ params: isRecord(entry.params) ? entry.params : undefined,
726
+ required: typeof entry.required === 'boolean' ? entry.required : undefined,
727
+ };
728
+ });
729
+ }
730
+ function normalizePreconditionResult(result) {
731
+ if (typeof result === 'boolean')
732
+ return { ok: result };
733
+ if (result === undefined)
734
+ return { ok: true };
735
+ return result;
736
+ }
737
+ function parseLifecycleNodes(value, phase) {
738
+ if (value == null)
739
+ return [];
740
+ if (!Array.isArray(value))
741
+ throw new Error(`validate.workflow.${phase} must be an array.`);
742
+ return value.map((entry, index) => {
743
+ if (!isRecord(entry)) {
744
+ throw new Error(`validate.workflow.${phase}[${index}] must be an action node object.`);
745
+ }
746
+ const authoredId = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : `${phase}:${index}`;
747
+ return {
748
+ id: authoredId,
749
+ node: {
750
+ phase,
751
+ ...entry,
752
+ },
753
+ };
754
+ });
755
+ }
756
+ function linkLifecycleNodes(nodes, lifecycleNodes, nextAfterLifecycle) {
757
+ lifecycleNodes.forEach((entry, index) => {
758
+ if (nodes[entry.id])
759
+ throw new Error(`Lifecycle node id ${entry.id} conflicts with graph node.`);
760
+ if (entry.node.next != null) {
761
+ throw new Error(`Lifecycle node ${entry.id} must not declare next; setup and teardown arrays are ordered lists.`);
762
+ }
763
+ const next = lifecycleNodes[index + 1]?.id ?? nextAfterLifecycle;
764
+ nodes[entry.id] = {
765
+ ...entry.node,
766
+ next,
767
+ };
768
+ });
769
+ }
770
+ function resolveNextNode(node, result) {
771
+ if (result.next)
772
+ return result.next;
773
+ if (result.case && isRecord(node.cases)) {
774
+ const target = node.cases[result.case];
775
+ if (typeof target === 'string')
776
+ return target;
777
+ }
778
+ if (typeof node.next === 'string')
779
+ return node.next;
780
+ if (typeof node.default === 'string')
781
+ return node.default;
782
+ return undefined;
783
+ }
784
+ async function collectFlows(recipe, options) {
785
+ const flows = new Map();
786
+ if (!isRecord(recipe))
787
+ return flows;
788
+ for (const catalogPath of collectUsePaths(recipe.uses)) {
789
+ const catalog = await readJsonFile(resolveUsePath(catalogPath, options));
790
+ addCatalogFlows(flows, catalog, catalogPath);
791
+ }
792
+ if (isRecord(recipe.flows)) {
793
+ addCatalogFlows(flows, { flows: recipe.flows }, 'recipe.flows');
794
+ }
795
+ return flows;
796
+ }
797
+ function collectUsePaths(value) {
798
+ if (value == null)
799
+ return [];
800
+ if (!Array.isArray(value))
801
+ throw new Error('uses must be an array of flow catalog paths.');
802
+ return value.map((entry, index) => {
803
+ if (typeof entry !== 'string' || !entry.trim()) {
804
+ throw new Error(`uses[${index}] must be a non-empty flow catalog path.`);
805
+ }
806
+ return entry;
807
+ });
808
+ }
809
+ function resolveUsePath(catalogPath, options) {
810
+ const normalized = normalizeRelativePath(catalogPath);
811
+ const fromRecipe = path.join(options.recipeDir, normalized);
812
+ if (path.isAbsolute(catalogPath)) {
813
+ throw new Error(`uses entry ${catalogPath} must be relative.`);
814
+ }
815
+ const relativeToProject = path.relative(options.projectRoot, fromRecipe);
816
+ if (!relativeToProject.startsWith('..') && !path.isAbsolute(relativeToProject)) {
817
+ return fromRecipe;
818
+ }
819
+ return path.join(options.projectRoot, normalized);
820
+ }
821
+ function addCatalogFlows(flows, catalog, source) {
822
+ if (!isRecord(catalog) || !isRecord(catalog.flows)) {
823
+ throw new Error(`Flow catalog ${source} must contain a flows object.`);
824
+ }
825
+ for (const [ref, flow] of Object.entries(catalog.flows)) {
826
+ const normalized = normalizeFlow(ref, flow, source);
827
+ if (flows.has(ref))
828
+ throw new Error(`Flow ${ref} is declared more than once.`);
829
+ flows.set(ref, normalized);
830
+ }
831
+ }
832
+ function normalizeFlow(ref, flow, source) {
833
+ if (!isRecord(flow))
834
+ throw new Error(`Flow ${ref} in ${source} must be an object.`);
835
+ const workflow = isRecord(flow.workflow) ? flow.workflow : flow;
836
+ if (typeof workflow.entry !== 'string' || !workflow.entry.trim() || !isRecord(workflow.nodes)) {
837
+ throw new Error(`Flow ${ref} in ${source} must include workflow.entry and workflow.nodes.`);
838
+ }
839
+ const nodes = Object.create(null);
840
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
841
+ if (isRecord(node))
842
+ nodes[nodeId] = node;
843
+ }
844
+ return {
845
+ entry: workflow.entry,
846
+ nodes,
847
+ paramsSchema: flow.paramsSchema,
848
+ postcondition: flow.postcondition,
849
+ };
850
+ }
851
+ async function executeInlineFlowCall({ callNodeId, node, context, flowCatalog, adapters, traceWriter, callStack, maxCallDepth, }) {
852
+ const ref = typeof node.ref === 'string' && node.ref.trim() ? node.ref.trim() : '';
853
+ if (!ref)
854
+ throw new Error('call.ref must be a non-empty flow id.');
855
+ if (callStack.includes(ref)) {
856
+ throw new Error(`Flow call cycle detected: ${[...callStack, ref].join(' -> ')}.`);
857
+ }
858
+ if (callStack.length >= maxCallDepth) {
859
+ throw new Error(`Flow call depth exceeded maximum ${maxCallDepth}.`);
860
+ }
861
+ const flow = flowCatalog.get(ref);
862
+ if (!flow)
863
+ throw new Error(`Flow ${ref} is not available from recipe uses or inline flows.`);
864
+ const params = isRecord(node.params) ? node.params : {};
865
+ validateParamsSchema(params, flow.paramsSchema, ref);
866
+ const nextCallStack = [...callStack, ref];
867
+ const output = await executeInlineFlow({
868
+ callNodeId,
869
+ flowRef: ref,
870
+ flow,
871
+ params,
872
+ context,
873
+ flowCatalog,
874
+ adapters,
875
+ traceWriter,
876
+ callStack: nextCallStack,
877
+ maxCallDepth,
878
+ });
879
+ if (flow.postcondition != null && !evaluatePredicate(output, flow.postcondition)) {
880
+ throw new Error(`Flow ${ref} failed its postcondition.`);
881
+ }
882
+ return { output };
883
+ }
884
+ async function executeInlineFlow({ callNodeId, flowRef, flow, params, context, flowCatalog, adapters, traceWriter, callStack, maxCallDepth, }) {
885
+ let currentNodeId = flow.entry;
886
+ let transitionCount = 0;
887
+ const maxTransitions = Object.keys(flow.nodes).length * 3;
888
+ const flowOutputs = {};
889
+ const flowOutputMap = new Map();
890
+ flowOutputMap.set('params', params);
891
+ flowOutputMap.set(`${callNodeId}/params`, params);
892
+ while (currentNodeId) {
893
+ transitionCount += 1;
894
+ if (transitionCount > maxTransitions) {
895
+ throw new Error(`Flow ${flowRef} exceeded its maximum transition count.`);
896
+ }
897
+ const localNodeId = currentNodeId;
898
+ const rawFlowNode = flow.nodes[localNodeId];
899
+ if (!rawFlowNode)
900
+ throw new Error(`Flow ${flowRef} node ${localNodeId} does not exist.`);
901
+ const flowNode = resolveParams(rawFlowNode, params);
902
+ const action = String(flowNode.action);
903
+ const adapter = action === 'call' ? undefined : adapters.get(action);
904
+ if (action !== 'call' && !adapter)
905
+ throw new Error(`No adapter registered for flow action ${action}.`);
906
+ const startedAt = new Date();
907
+ const namespacedNodeId = `${callNodeId}/${localNodeId}`;
908
+ try {
909
+ const mergedOutputs = new Map([...context.outputs, ...flowOutputMap]);
910
+ const gate = evaluateNodeGate(flowNode, mergedOutputs);
911
+ if (!gate.run) {
912
+ const next = resolveNextNode(flowNode, {});
913
+ traceWriter.record({
914
+ nodeId: namespacedNodeId,
915
+ action,
916
+ ...traceNodeMetadata(flowNode),
917
+ startedAt: startedAt.toISOString(),
918
+ endedAt: new Date().toISOString(),
919
+ durationMs: Date.now() - startedAt.getTime(),
920
+ ok: true,
921
+ next,
922
+ output: { skipped: true, reason: gate.reason },
923
+ });
924
+ currentNodeId = next;
925
+ continue;
926
+ }
927
+ const childContext = {
928
+ ...context,
929
+ nodeId: namespacedNodeId,
930
+ outputs: mergedOutputs,
931
+ getOutput(nodeId) {
932
+ if (flowOutputMap.has(nodeId))
933
+ return flowOutputMap.get(nodeId);
934
+ const namespacedLookup = `${callNodeId}/${nodeId}`;
935
+ if (flowOutputMap.has(namespacedLookup))
936
+ return flowOutputMap.get(namespacedLookup);
937
+ return context.getOutput(nodeId);
938
+ },
939
+ };
940
+ const result = action === 'call'
941
+ ? await executeInlineFlowCall({
942
+ callNodeId: namespacedNodeId,
943
+ node: flowNode,
944
+ context: childContext,
945
+ flowCatalog,
946
+ adapters,
947
+ traceWriter,
948
+ callStack,
949
+ maxCallDepth,
950
+ })
951
+ : await adapter.execute(flowNode, childContext);
952
+ if (result.output !== undefined) {
953
+ flowOutputs[localNodeId] = result.output;
954
+ flowOutputMap.set(localNodeId, result.output);
955
+ flowOutputMap.set(namespacedNodeId, result.output);
956
+ }
957
+ traceWriter.record({
958
+ nodeId: namespacedNodeId,
959
+ action,
960
+ ...traceNodeMetadata(flowNode, result),
961
+ startedAt: startedAt.toISOString(),
962
+ endedAt: new Date().toISOString(),
963
+ durationMs: Date.now() - startedAt.getTime(),
964
+ ok: true,
965
+ next: resolveNextNode(flowNode, result),
966
+ status: result.status,
967
+ output: result.output,
968
+ });
969
+ if (result.status) {
970
+ if (result.status === 'fail')
971
+ throw new Error(`Flow ${flowRef} failed at ${localNodeId}.`);
972
+ return { ref: flowRef, status: result.status, outputs: flowOutputs };
973
+ }
974
+ currentNodeId = resolveNextNode(flowNode, result);
975
+ }
976
+ catch (error) {
977
+ const message = error instanceof Error ? error.message : String(error);
978
+ traceWriter.record({
979
+ nodeId: namespacedNodeId,
980
+ action,
981
+ ...traceNodeMetadata(flowNode),
982
+ startedAt: startedAt.toISOString(),
983
+ endedAt: new Date().toISOString(),
984
+ durationMs: Date.now() - startedAt.getTime(),
985
+ ok: false,
986
+ error: message,
987
+ });
988
+ throw error;
989
+ }
990
+ }
991
+ return { ref: flowRef, status: 'unknown', outputs: flowOutputs };
992
+ }
993
+ function validateParamsSchema(params, schema, flowRef) {
994
+ if (schema == null)
995
+ return;
996
+ if (!isRecord(schema))
997
+ throw new Error(`Flow ${flowRef} paramsSchema must be an object.`);
998
+ if (schema.type != null && schema.type !== 'object') {
999
+ throw new Error(`Flow ${flowRef} paramsSchema.type must be object.`);
1000
+ }
1001
+ if (Array.isArray(schema.required)) {
1002
+ for (const key of schema.required) {
1003
+ if (typeof key !== 'string')
1004
+ continue;
1005
+ if (!Object.prototype.hasOwnProperty.call(params, key)) {
1006
+ throw new Error(`Flow ${flowRef} params missing required field ${key}.`);
1007
+ }
1008
+ }
1009
+ }
1010
+ if (!isRecord(schema.properties))
1011
+ return;
1012
+ for (const [key, propertySchema] of Object.entries(schema.properties)) {
1013
+ if (!Object.prototype.hasOwnProperty.call(params, key))
1014
+ continue;
1015
+ validateParamValue(params[key], propertySchema, `Flow ${flowRef} params.${key}`);
1016
+ }
1017
+ }
1018
+ function validateParamValue(value, schema, label) {
1019
+ if (!isRecord(schema))
1020
+ return;
1021
+ if (Array.isArray(schema.enum) && !schema.enum.some((entry) => isDeepStrictEqual(entry, value))) {
1022
+ throw new Error(`${label} must be one of ${JSON.stringify(schema.enum)}.`);
1023
+ }
1024
+ if (schema.type == null)
1025
+ return;
1026
+ const actualType = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
1027
+ if (actualType !== schema.type) {
1028
+ throw new Error(`${label} must be ${String(schema.type)}, received ${actualType}.`);
1029
+ }
1030
+ }
1031
+ function resolveParams(node, params) {
1032
+ return resolveParamValue(node, params);
1033
+ }
1034
+ function resolveParamValue(value, params) {
1035
+ if (typeof value === 'string') {
1036
+ const fullMatch = /^\{\{params\.([A-Za-z0-9_.-]+)\}\}$/.exec(value);
1037
+ if (fullMatch)
1038
+ return getPathValue(params, fullMatch[1]);
1039
+ return value.replace(/\{\{params\.([A-Za-z0-9_.-]+)\}\}/g, (_match, key) => String(getPathValue(params, key) ?? ''));
1040
+ }
1041
+ if (Array.isArray(value))
1042
+ return value.map((entry) => resolveParamValue(entry, params));
1043
+ if (!isRecord(value))
1044
+ return value;
1045
+ const resolved = {};
1046
+ for (const [key, entry] of Object.entries(value)) {
1047
+ resolved[key] = resolveParamValue(entry, params);
1048
+ }
1049
+ return resolved;
1050
+ }
1051
+ function evaluateNodeGate(node, outputs) {
1052
+ const root = Object.fromEntries(outputs);
1053
+ if (node.when != null && !evaluatePredicate(root, node.when)) {
1054
+ return { run: false, reason: 'when predicate was false' };
1055
+ }
1056
+ if (node.unless != null && evaluatePredicate(root, node.unless)) {
1057
+ return { run: false, reason: 'unless predicate was true' };
1058
+ }
1059
+ return { run: true };
1060
+ }
1061
+ function evaluatePredicate(root, predicate) {
1062
+ if (!isRecord(predicate))
1063
+ throw new Error('Predicate must be an assertion object.');
1064
+ if (Array.isArray(predicate.all))
1065
+ return predicate.all.every((entry) => evaluatePredicate(root, entry));
1066
+ if (Array.isArray(predicate.any))
1067
+ return predicate.any.some((entry) => evaluatePredicate(root, entry));
1068
+ if (Array.isArray(predicate.none))
1069
+ return !predicate.none.some((entry) => evaluatePredicate(root, entry));
1070
+ const pathValue = typeof predicate.path === 'string' ? predicate.path : '$';
1071
+ const actual = pathValue === '$' ? root : getPathValue(root, pathValue.replace(/^\$\./, ''));
1072
+ const operator = typeof predicate.operator === 'string' ? predicate.operator : 'truthy';
1073
+ const expected = predicate.value;
1074
+ switch (operator) {
1075
+ case 'exists':
1076
+ return actual !== undefined;
1077
+ case 'not_null':
1078
+ return actual != null;
1079
+ case 'truthy':
1080
+ return Boolean(actual);
1081
+ case 'falsy':
1082
+ return !actual;
1083
+ case 'eq':
1084
+ return actual === expected;
1085
+ case 'ne':
1086
+ case 'neq':
1087
+ return actual !== expected;
1088
+ case 'deep_eq':
1089
+ return isDeepStrictEqual(actual, expected);
1090
+ case 'contains':
1091
+ return containsValue(actual, expected);
1092
+ case 'not_contains':
1093
+ return !containsValue(actual, expected);
1094
+ case 'matches':
1095
+ return typeof expected === 'string' && new RegExp(expected).test(String(actual ?? ''));
1096
+ case 'one_of':
1097
+ return Array.isArray(expected) && expected.some((entry) => isDeepStrictEqual(entry, actual));
1098
+ case 'gt':
1099
+ case 'gte':
1100
+ case 'lt':
1101
+ case 'lte':
1102
+ return compareNumbers(actual, expected, operator);
1103
+ case 'length_eq':
1104
+ case 'length_gt':
1105
+ case 'length_gte':
1106
+ case 'length_lt':
1107
+ case 'length_lte':
1108
+ return compareNumbers(lengthOf(actual), expected, operator.replace('length_', ''));
1109
+ default:
1110
+ throw new Error(`Unsupported predicate operator ${operator}.`);
1111
+ }
1112
+ }
1113
+ function getPathValue(root, dottedPath) {
1114
+ return dottedPath
1115
+ .split('.')
1116
+ .filter(Boolean)
1117
+ .reduce((current, segment) => {
1118
+ if (!isRecord(current) && !Array.isArray(current))
1119
+ return undefined;
1120
+ if (Array.isArray(current))
1121
+ return current[Number(segment)];
1122
+ return current[segment];
1123
+ }, root);
1124
+ }
1125
+ function containsValue(actual, expected) {
1126
+ if (typeof actual === 'string')
1127
+ return actual.includes(String(expected));
1128
+ if (Array.isArray(actual))
1129
+ return actual.some((entry) => isDeepStrictEqual(entry, expected));
1130
+ return false;
1131
+ }
1132
+ function lengthOf(actual) {
1133
+ if (typeof actual === 'string' || Array.isArray(actual))
1134
+ return actual.length;
1135
+ if (isRecord(actual))
1136
+ return Object.keys(actual).length;
1137
+ return Number.NaN;
1138
+ }
1139
+ function compareNumbers(actual, expected, operator) {
1140
+ if (typeof actual !== 'number' || typeof expected !== 'number')
1141
+ return false;
1142
+ if (operator === 'gt')
1143
+ return actual > expected;
1144
+ if (operator === 'gte')
1145
+ return actual >= expected;
1146
+ if (operator === 'lt')
1147
+ return actual < expected;
1148
+ return actual <= expected;
1149
+ }
1150
+ function recordSyntheticFailure(traceWriter, nodeId, error, action = 'unknown') {
1151
+ const now = new Date().toISOString();
1152
+ traceWriter.record({
1153
+ nodeId,
1154
+ action,
1155
+ startedAt: now,
1156
+ endedAt: now,
1157
+ durationMs: 0,
1158
+ ok: false,
1159
+ error: error.message,
1160
+ });
1161
+ }
1162
+ function traceNodeMetadata(node, result) {
1163
+ const metadata = {};
1164
+ if (typeof node.phase === 'string')
1165
+ metadata.phase = node.phase;
1166
+ if (node.record !== undefined)
1167
+ metadata.record = node.record;
1168
+ if (result?.artifacts?.length)
1169
+ metadata.artifacts = result.artifacts;
1170
+ return metadata;
1171
+ }
1172
+ //# sourceMappingURL=runner.js.map