@db-lyon/flowkit 0.17.2 → 0.18.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 (59) hide show
  1. package/README.md +53 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config/index.d.ts +4 -2
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +2 -1
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/config/loader.d.ts +11 -0
  8. package/dist/config/loader.d.ts.map +1 -1
  9. package/dist/config/loader.js +4 -0
  10. package/dist/config/loader.js.map +1 -1
  11. package/dist/config/schema.d.ts +1486 -79
  12. package/dist/config/schema.d.ts.map +1 -1
  13. package/dist/config/schema.js +125 -8
  14. package/dist/config/schema.js.map +1 -1
  15. package/dist/config/strict.d.ts +40 -0
  16. package/dist/config/strict.d.ts.map +1 -0
  17. package/dist/config/strict.js +146 -0
  18. package/dist/config/strict.js.map +1 -0
  19. package/dist/flow/index.d.ts +2 -2
  20. package/dist/flow/index.d.ts.map +1 -1
  21. package/dist/flow/index.js +1 -1
  22. package/dist/flow/index.js.map +1 -1
  23. package/dist/flow/runner.d.ts +279 -6
  24. package/dist/flow/runner.d.ts.map +1 -1
  25. package/dist/flow/runner.js +845 -40
  26. package/dist/flow/runner.js.map +1 -1
  27. package/dist/index.d.ts +13 -5
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +6 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/task/base-task.d.ts +55 -1
  32. package/dist/task/base-task.d.ts.map +1 -1
  33. package/dist/task/base-task.js +39 -0
  34. package/dist/task/base-task.js.map +1 -1
  35. package/dist/task/composite.d.ts +66 -0
  36. package/dist/task/composite.d.ts.map +1 -0
  37. package/dist/task/composite.js +21 -0
  38. package/dist/task/composite.js.map +1 -0
  39. package/dist/task/index.d.ts +7 -1
  40. package/dist/task/index.d.ts.map +1 -1
  41. package/dist/task/index.js +3 -0
  42. package/dist/task/index.js.map +1 -1
  43. package/dist/task/options-schema.d.ts +51 -0
  44. package/dist/task/options-schema.d.ts.map +1 -0
  45. package/dist/task/options-schema.js +102 -0
  46. package/dist/task/options-schema.js.map +1 -0
  47. package/dist/task/registry.d.ts +39 -1
  48. package/dist/task/registry.d.ts.map +1 -1
  49. package/dist/task/registry.js +44 -2
  50. package/dist/task/registry.js.map +1 -1
  51. package/dist/task/warnings.d.ts +25 -0
  52. package/dist/task/warnings.d.ts.map +1 -0
  53. package/dist/task/warnings.js +30 -0
  54. package/dist/task/warnings.js.map +1 -0
  55. package/docs/api-reference.md +273 -0
  56. package/docs/configuration.md +262 -1
  57. package/docs/custom-tasks.md +155 -0
  58. package/docs/releases.md +47 -0
  59. package/package.json +1 -1
@@ -1,8 +1,19 @@
1
1
  import { noopLogger } from '../logger.js';
2
2
  import { DEFAULT_EXECUTION_PHASE } from '../task/base-task.js';
3
+ import { assertTaskOptions, mergeOptionSpecs, taskClassMetadata, TaskOptionsError, } from '../task/options-schema.js';
4
+ import { deprecationWarning, mergeWarnings } from '../task/warnings.js';
3
5
  import { AgentTask } from '../task/agent-task.js';
4
6
  import { resolveReferences } from '../references.js';
5
7
  import { resolveTaskDefinition, resolveTaskCall } from '../task/task-resolution.js';
8
+ /** Raised (as a step or flow error) when an `action: error` check fires. */
9
+ export class CheckFailedError extends Error {
10
+ outcome;
11
+ constructor(outcome) {
12
+ super(outcome.message);
13
+ this.name = 'CheckFailedError';
14
+ this.outcome = outcome;
15
+ }
16
+ }
6
17
  // ---------------------------------------------------------------------------
7
18
  // FlowRunner
8
19
  // ---------------------------------------------------------------------------
@@ -17,6 +28,8 @@ export class FlowRunner {
17
28
  references;
18
29
  agents;
19
30
  nestedAgentTaskFactory;
31
+ optionsScope;
32
+ strictStepReferences;
20
33
  runDepth = 0;
21
34
  /**
22
35
  * Reference scope outside any step: the host namespaces, no step results.
@@ -34,6 +47,8 @@ export class FlowRunner {
34
47
  this.references = config.references;
35
48
  this.baseReferences = { steps: [], namespaces: this.references };
36
49
  this.agents = config.agents ?? {};
50
+ this.optionsScope = config.optionsScope ?? 'flat';
51
+ this.strictStepReferences = config.strictStepReferences ?? false;
37
52
  this.nestedAgentTaskFactory =
38
53
  config.nestedAgentTaskFactory ?? ((ctx, options) => new AgentTask(ctx, options));
39
54
  // Compile each agent into a task definition so it is runnable as a flow
@@ -76,14 +91,97 @@ export class FlowRunner {
76
91
  * sub-agent dispatch, so a task invoked several agents deep resolves its
77
92
  * configured defaults exactly as it would as a top-level step.
78
93
  */
79
- contextFor(references, executionPhase = DEFAULT_EXECUTION_PHASE) {
94
+ contextFor(references, executionPhase = DEFAULT_EXECUTION_PHASE, sink) {
80
95
  return {
81
96
  ...this.ctx,
82
97
  taskReferenceContext: references,
83
98
  executionPhase,
84
99
  runAgent: (agentName, input, depth, ledger) => this.runAgentTool(agentName, input, depth, ledger, references),
100
+ ...(sink
101
+ ? {
102
+ step: (target, options, spec) => this.runChildStep(sink, target, options ?? {}, spec),
103
+ }
104
+ : {}),
85
105
  };
86
106
  }
107
+ /**
108
+ * Run one composite child through the same machinery as a flow step: hooks,
109
+ * retries, option schema, deprecation and rollback capture. The child is
110
+ * recorded on the parent's sink and becomes `TaskResult.children`.
111
+ *
112
+ * A child's options are the composite's runtime data: they layer over the
113
+ * child task's configured defaults (interpolated in the parent's reference
114
+ * scope) verbatim. Runtime `params` and enclosing-flow overrides do not
115
+ * reach children, and a child flow gets `options` as its `params`.
116
+ */
117
+ async runChildStep(sink, target, options, spec) {
118
+ const stepNumber = sink.children.length + 1;
119
+ const isFlow = typeof target === 'object' && 'flow' in target;
120
+ const name = typeof target === 'string' ? target : 'flow' in target ? target.flow : target.task;
121
+ const path = sink.path ? `${sink.path}/${stepNumber}` : String(stepNumber);
122
+ const planStep = {
123
+ stepNumber,
124
+ type: isFlow ? 'flow' : 'task',
125
+ name,
126
+ skipped: false,
127
+ options,
128
+ ...(spec?.retries !== undefined ? { retries: spec.retries } : {}),
129
+ ...(spec?.retryDelay !== undefined ? { retryDelay: spec.retryDelay } : {}),
130
+ ...(spec?.retryOn !== undefined ? { retryOn: spec.retryOn } : {}),
131
+ path,
132
+ depth: path.split('/').length - 1,
133
+ };
134
+ // Claim the slot before running, so a composite that runs children
135
+ // concurrently still numbers them in the order it asked.
136
+ const record = { stepNumber, type: planStep.type, name, skipped: false, duration: 0, path };
137
+ sink.children.push(record);
138
+ await this.hooks.beforeStep?.(planStep);
139
+ const start = Date.now();
140
+ if (isFlow) {
141
+ if (!this.flows[name]) {
142
+ record.result = { success: false, error: new Error(`Flow "${name}" not found in configuration`) };
143
+ }
144
+ else {
145
+ // A child flow is never the top of a run, even under a bare runTask.
146
+ this.runDepth++;
147
+ try {
148
+ const nested = await this.runWith({ flowName: name, params: options, rollbackOwnedByAncestor: sink.rollbackOwned }, {});
149
+ record.result = {
150
+ success: nested.success,
151
+ data: { stepCount: nested.steps.length },
152
+ error: nested.success ? undefined : nested.error,
153
+ ...(nested.warnings ? { warnings: nested.warnings } : {}),
154
+ };
155
+ record.nestedSteps = nested.steps;
156
+ if (nested.checks)
157
+ record.checks = nested.checks;
158
+ }
159
+ catch (err) {
160
+ record.result = { success: false, error: err instanceof Error ? err : new Error(String(err)) };
161
+ }
162
+ finally {
163
+ this.runDepth--;
164
+ }
165
+ }
166
+ }
167
+ else {
168
+ const { result, attempts } = await this.withRetry(planStep, async () => {
169
+ let resolved;
170
+ try {
171
+ resolved = resolveTaskCall(name, this.tasks, options, sink.references);
172
+ }
173
+ catch (err) {
174
+ return { success: false, error: err instanceof Error ? err : new Error(String(err)) };
175
+ }
176
+ return this.executeTask(resolved.classPath, resolved.options, sink.references, DEFAULT_EXECUTION_PHASE, name, { path, rollbackOwned: sink.rollbackOwned });
177
+ });
178
+ record.result = result;
179
+ record.attempts = attempts;
180
+ }
181
+ record.duration = Date.now() - start;
182
+ await this.hooks.afterStep?.(planStep, record);
183
+ return record.result;
184
+ }
87
185
  /** Map an agent definition onto AgentTask options (everything but the prompt). */
88
186
  compileAgent(def) {
89
187
  const b = def.budget ?? {};
@@ -180,7 +278,156 @@ export class FlowRunner {
180
278
  catch (err) {
181
279
  return { success: false, error: err instanceof Error ? err : new Error(String(err)) };
182
280
  }
183
- return this.executeTask(resolved.classPath, resolved.options, refs);
281
+ return this.executeTask(resolved.classPath, resolved.options, refs, DEFAULT_EXECUTION_PHASE, taskName);
282
+ }
283
+ /**
284
+ * Describe a configured (or registered) task: its class, merged default
285
+ * options, option schema, outputs and deprecation. See `TaskRegistry.describe`.
286
+ */
287
+ async describeTask(taskName) {
288
+ return this.registry.describe(taskName, this.tasks);
289
+ }
290
+ /** Describe a configured flow: its declared metadata and its resolved main steps. */
291
+ describeFlow(flowName) {
292
+ const flow = this.flows[flowName];
293
+ if (!flow)
294
+ throw new Error(`Flow "${flowName}" not found in configuration`);
295
+ const out = { name: flowName, steps: this.resolveExecutionPlan(flow, new Set()) };
296
+ if (flow.description != null)
297
+ out.description = flow.description;
298
+ if (flow.deprecated)
299
+ out.deprecated = flow.deprecated;
300
+ if (flow.replaced_by !== undefined)
301
+ out.replaced_by = flow.replaced_by;
302
+ if (flow.rollback_on_failure !== undefined)
303
+ out.rollback_on_failure = flow.rollback_on_failure;
304
+ if (flow.options_scope !== undefined)
305
+ out.options_scope = flow.options_scope;
306
+ if (flow.checks)
307
+ out.checks = flow.checks;
308
+ return out;
309
+ }
310
+ /**
311
+ * The children a composite task would run for `options`, from its class's
312
+ * static `expand`, without running anything. `options` layer over the task's
313
+ * configured defaults as `runTask` would layer them. Returns `null` when the
314
+ * class declares no `expand` or its `expand` cannot say.
315
+ */
316
+ async expandTask(taskName, options = {}) {
317
+ const { classPath, options: defaults } = resolveTaskDefinition(taskName, this.tasks);
318
+ const expand = await this.expandFunctionOf(classPath);
319
+ if (!expand)
320
+ return null;
321
+ const merged = lenientReferences({ ...defaults, ...options }, this.baseReferences);
322
+ return (await expand(merged, this.expandContext(taskName))) ?? null;
323
+ }
324
+ expandContext(taskName) {
325
+ return {
326
+ taskName,
327
+ taskDefinitions: this.tasks,
328
+ flows: this.flows,
329
+ ...(this.references ? { references: this.references } : {}),
330
+ };
331
+ }
332
+ /** A task class's static `expand`, or undefined (including when the class cannot load). */
333
+ async expandFunctionOf(classPath) {
334
+ try {
335
+ const ctor = (await this.registry.resolve(classPath));
336
+ return typeof ctor.expand === 'function' ? ctor.expand.bind(ctor) : undefined;
337
+ }
338
+ catch {
339
+ return undefined;
340
+ }
341
+ }
342
+ /**
343
+ * Plan mode: follow each composite task row with the children its `expand`
344
+ * reports, one level deeper, recursively. Options are resolved as far as
345
+ * they can be before a run: definition defaults, the step's options and its
346
+ * runtime params, with host references interpolated and step references left
347
+ * as written.
348
+ */
349
+ async expandCompositeRows(plan, params, scope, ancestors = new Set()) {
350
+ const out = [];
351
+ for (const row of plan) {
352
+ out.push(row);
353
+ if (row.type !== 'task' || row.skipped || ancestors.has(row.name))
354
+ continue;
355
+ const { classPath, options: defaults } = resolveTaskDefinition(row.name, this.tasks);
356
+ const expand = await this.expandFunctionOf(classPath);
357
+ if (!expand)
358
+ continue;
359
+ const path = row.path ?? String(row.stepNumber);
360
+ let runtime = params;
361
+ if (scope === 'step' && params) {
362
+ runtime = {
363
+ ...params[row.name],
364
+ ...params[path],
365
+ };
366
+ }
367
+ const raw = { ...defaults, ...(row.options ?? {}), ...(ancestors.size === 0 ? runtime : {}) };
368
+ let entries = null;
369
+ try {
370
+ entries = await expand(lenientReferences(raw, this.baseReferences), this.expandContext(row.name));
371
+ }
372
+ catch (err) {
373
+ this.logger.warn({ task: row.name, err }, `expand() failed for ${row.name}; shown as opaque`);
374
+ }
375
+ if (!entries) {
376
+ row.composite = 'opaque';
377
+ continue;
378
+ }
379
+ row.composite = 'expanded';
380
+ row.path = path;
381
+ row.depth = row.depth ?? 0;
382
+ const children = entries.map((entry, i) => ({
383
+ stepNumber: i + 1,
384
+ type: 'flow' in entry ? 'flow' : 'task',
385
+ name: 'flow' in entry ? entry.flow : entry.task,
386
+ skipped: false,
387
+ ...(entry.options ? { options: entry.options } : {}),
388
+ path: `${path}/${i + 1}`,
389
+ depth: row.depth + 1,
390
+ }));
391
+ out.push(...(await this.expandCompositeRows(children, params, scope, new Set(ancestors).add(row.name))));
392
+ }
393
+ return out;
394
+ }
395
+ /**
396
+ * The deprecation notice for a task or flow, or undefined. A task's class is
397
+ * resolved for its static metadata; one that cannot load reports nothing
398
+ * here and fails where it would have anyway, at run time.
399
+ */
400
+ async deprecationOf(type, name) {
401
+ if (type === 'flow') {
402
+ const f = this.flows[name];
403
+ return f ? deprecationWarning('flow', name, f.deprecated, f.replaced_by) : undefined;
404
+ }
405
+ const def = this.tasks[name];
406
+ let meta = {};
407
+ try {
408
+ meta = taskClassMetadata(await this.registry.resolve(resolveTaskDefinition(name, this.tasks).classPath));
409
+ }
410
+ catch {
411
+ // Unloadable class: no static metadata to report.
412
+ }
413
+ return deprecationWarning('task', name, def?.deprecated ?? meta.deprecated, def?.replaced_by ?? meta.replacedBy);
414
+ }
415
+ /** Plan mode: mark deprecated rows and collect their warnings. */
416
+ async annotatePlan(plan) {
417
+ const warnings = [];
418
+ for (const step of plan) {
419
+ if (step.skipped)
420
+ continue;
421
+ const w = await this.deprecationOf(step.type, step.name);
422
+ if (!w)
423
+ continue;
424
+ const def = step.type === 'flow' ? this.flows[step.name] : this.tasks[step.name];
425
+ step.deprecated = def?.deprecated || true;
426
+ if (w.replacedBy)
427
+ step.replaced_by = w.replacedBy;
428
+ warnings.push(w);
429
+ }
430
+ return warnings;
184
431
  }
185
432
  /**
186
433
  * Instantiate and run a task with fully-resolved options (the shared leaf).
@@ -191,27 +438,190 @@ export class FlowRunner {
191
438
  * as a step, directly, as a tool, or during rollback all report failure
192
439
  * identically — and a step's `retries` cover construction, not just execution.
193
440
  * Task-to-task calls derive their equivalent context in `BaseTask.resolve`.
441
+ *
442
+ * `taskName` is the configured name the call came through. When given, the
443
+ * task's declared options (class `optionsSchema` refined by the definition's
444
+ * `options_schema`) supply defaults and are checked here, so every runner
445
+ * path validates the same way and a bad option fails before the task exists.
194
446
  */
195
- async executeTask(classPath, options, references, executionPhase = DEFAULT_EXECUTION_PHASE) {
196
- const taskCtx = this.contextFor(references, executionPhase);
447
+ async executeTask(classPath, options, references, executionPhase = DEFAULT_EXECUTION_PHASE, taskName, site = { path: '', rollbackOwned: false }) {
448
+ const sink = { ...site, children: [], references };
449
+ const taskCtx = this.contextFor(references, executionPhase, sink);
450
+ const finish = (result) => {
451
+ if (sink.children.length === 0)
452
+ return result;
453
+ result.children = sink.children;
454
+ // A child's notices (a deprecated child task, say) surface on the parent,
455
+ // so a flow reports them like any step's.
456
+ const merged = mergeWarnings(result.warnings, ...sink.children.map((c) => c.result?.warnings));
457
+ if (merged.length > 0)
458
+ result.warnings = merged;
459
+ return result;
460
+ };
197
461
  try {
198
- const task = await this.registry.create(classPath, taskCtx, options);
199
- return task.run();
462
+ let finalOptions = options;
463
+ let deprecation;
464
+ if (taskName !== undefined) {
465
+ const meta = taskClassMetadata(await this.registry.resolve(classPath));
466
+ const def = this.tasks[taskName];
467
+ deprecation = deprecationWarning('task', taskName, def?.deprecated ?? meta.deprecated, def?.replaced_by ?? meta.replacedBy);
468
+ if (deprecation)
469
+ this.logger.warn({ task: taskName }, deprecation.message);
470
+ const specs = mergeOptionSpecs(meta.optionsSchema, def?.options_schema);
471
+ try {
472
+ finalOptions = assertTaskOptions(taskName, specs, options);
473
+ }
474
+ catch (err) {
475
+ return withWarnings({ success: false, error: err }, deprecation);
476
+ }
477
+ }
478
+ const task = await this.registry.create(classPath, taskCtx, finalOptions);
479
+ return finish(withWarnings(await task.run(), deprecation));
200
480
  }
201
481
  catch (err) {
202
482
  return { success: false, error: err instanceof Error ? err : new Error(String(err)) };
203
483
  }
204
484
  }
205
- async runWith(options, parentOptions) {
485
+ async runWith(options, parentOptions, frame) {
206
486
  this.runDepth++;
207
487
  const isTopLevel = this.runDepth === 1;
208
488
  try {
209
- return await this.executeFlow(options, isTopLevel, parentOptions);
489
+ return await this.executeFlow(options, isTopLevel, parentOptions, frame);
210
490
  }
211
491
  finally {
212
492
  this.runDepth--;
213
493
  }
214
494
  }
495
+ /** Path of a main step inside a frame: `3` at the root, `2/3` inside step 2's flow. */
496
+ stepPath(frame, stepNumber) {
497
+ return frame.pathPrefix ? `${frame.pathPrefix}/${stepNumber}` : String(stepNumber);
498
+ }
499
+ /**
500
+ * The runtime option layer one step receives. Flat: all of `params`.
501
+ * Step-scoped: the options under the step's task name, then under its path.
502
+ */
503
+ runtimeOptionsFor(step, frame, params) {
504
+ if (frame.scope === 'flat' || !params)
505
+ return params;
506
+ const byName = params[step.name];
507
+ // Hook steps carry synthetic step numbers and are addressed by name only.
508
+ const byPath = step.phase === undefined
509
+ ? params[this.stepPath(frame, step.stepNumber)]
510
+ : undefined;
511
+ if (!byName && !byPath)
512
+ return undefined;
513
+ return { ...byName, ...byPath };
514
+ }
515
+ /**
516
+ * Every selector a step-scoped `params` key may use for a run of `flowName`:
517
+ * task names anywhere in the tree (main and hook steps, nested flows
518
+ * included) and the paths of main task steps.
519
+ */
520
+ collectSelectors(flowName, pathPrefix, ancestors, out) {
521
+ const flow = this.flows[flowName];
522
+ if (!flow || ancestors.has(flowName))
523
+ return;
524
+ const nextAncestors = new Set(ancestors).add(flowName);
525
+ const visit = (step, path) => {
526
+ if (step.name === 'None')
527
+ return;
528
+ if (step.type === 'task') {
529
+ out.names.add(step.name);
530
+ if (path !== undefined)
531
+ out.paths.add(path);
532
+ }
533
+ else {
534
+ this.collectSelectors(step.name, path ?? `${pathPrefix}/${step.phase}`, nextAncestors, out);
535
+ }
536
+ };
537
+ for (const step of this.resolveExecutionPlan(flow, new Set())) {
538
+ visit(step, pathPrefix ? `${pathPrefix}/${step.stepNumber}` : String(step.stepNumber));
539
+ }
540
+ for (const phase of ['on_start', 'on_success', 'on_failure', 'finally']) {
541
+ for (const step of this.planHookSteps(flow[phase], phase, new Set(), 0))
542
+ visit(step, undefined);
543
+ }
544
+ }
545
+ /**
546
+ * Find `${steps.<id>...}` references that cannot be bound to exactly one
547
+ * earlier main step: ambiguous names, unknown ids and forward references.
548
+ * Scans the flow's main and hook steps (`options` of task steps, `when`, and
549
+ * check `when`s) and, recursively, every flow they nest. It does not scan
550
+ * task definition defaults, which are resolved in whatever step runs them.
551
+ */
552
+ checkStepReferences(flowName) {
553
+ const issues = [];
554
+ this.collectReferenceIssues(flowName, new Set(), issues);
555
+ return issues;
556
+ }
557
+ collectReferenceIssues(flowName, seen, issues) {
558
+ const flow = this.flows[flowName];
559
+ if (!flow || seen.has(flowName))
560
+ return;
561
+ seen.add(flowName);
562
+ const main = this.resolveExecutionPlan(flow, new Set());
563
+ const byName = new Map();
564
+ for (const st of main) {
565
+ if (st.name === 'None')
566
+ continue;
567
+ byName.set(st.name, [...(byName.get(st.name) ?? []), st.stepNumber]);
568
+ }
569
+ const numbers = new Set(main.filter((st) => st.name !== 'None').map((st) => st.stepNumber));
570
+ const scan = (st) => {
571
+ const texts = [st.when, ...(st.checks ?? []).map((c) => c.when)];
572
+ // A flow step's options are overrides for tasks inside the child flow and
573
+ // resolve against the child's steps, not this flow's.
574
+ if (st.type === 'task')
575
+ texts.push(st.options);
576
+ for (const ref of stepReferencesIn(texts)) {
577
+ const issue = bindStepReference(ref, st, byName, numbers);
578
+ if (issue) {
579
+ issues.push({
580
+ flowName,
581
+ stepNumber: st.stepNumber,
582
+ ...(st.phase ? { phase: st.phase } : {}),
583
+ reference: `\${steps.${ref}}`,
584
+ ...issue,
585
+ });
586
+ }
587
+ }
588
+ };
589
+ for (const st of main) {
590
+ if (st.name === 'None')
591
+ continue;
592
+ scan(st);
593
+ if (st.type === 'flow')
594
+ this.collectReferenceIssues(st.name, seen, issues);
595
+ }
596
+ for (const phase of ['on_start', 'on_success', 'on_failure', 'finally']) {
597
+ for (const st of this.planHookSteps(flow[phase], phase, new Set(), 0)) {
598
+ if (st.name === 'None')
599
+ continue;
600
+ scan(st);
601
+ if (st.type === 'flow')
602
+ this.collectReferenceIssues(st.name, seen, issues);
603
+ }
604
+ }
605
+ }
606
+ /** Reject step-scoped `params` that address nothing or are not option objects. */
607
+ checkScopedParams(flowName, params) {
608
+ if (!params)
609
+ return;
610
+ const selectors = { names: new Set(), paths: new Set() };
611
+ this.collectSelectors(flowName, '', new Set(), selectors);
612
+ const problems = [];
613
+ for (const [key, value] of Object.entries(params)) {
614
+ if (!selectors.names.has(key) && !selectors.paths.has(key)) {
615
+ problems.push(`"${key}" matches no task name or step path in flow "${flowName}"`);
616
+ }
617
+ else if (!value || typeof value !== 'object' || Array.isArray(value)) {
618
+ problems.push(`"${key}" must map to an object of options`);
619
+ }
620
+ }
621
+ if (problems.length > 0) {
622
+ throw new Error(`Invalid step-scoped params: ${problems.join('; ')}`);
623
+ }
624
+ }
215
625
  resolveExecutionPlan(flow, skipSet) {
216
626
  const sortedKeys = Object.keys(flow.steps)
217
627
  .map(Number)
@@ -222,6 +632,9 @@ export class FlowRunner {
222
632
  if (step.task === 'None') {
223
633
  return { stepNumber, type: 'task', name: 'None', skipped: true };
224
634
  }
635
+ if (step.flow === 'None') {
636
+ return { stepNumber, type: 'flow', name: 'None', skipped: true };
637
+ }
225
638
  const name = (step.task ?? step.flow);
226
639
  const type = step.task ? 'task' : 'flow';
227
640
  return {
@@ -235,6 +648,7 @@ export class FlowRunner {
235
648
  retryOn: step.retryOn,
236
649
  when: step.when,
237
650
  ignore_failure: step.ignore_failure,
651
+ ...(step.checks ? { checks: step.checks } : {}),
238
652
  };
239
653
  }
240
654
  planHookSteps(hookSteps, phase, skipSet, baseStepNumber) {
@@ -283,7 +697,7 @@ export class FlowRunner {
283
697
  * constructor seed: a `finally` hook gating on `context.executionPhase`
284
698
  * would otherwise be told `'task'` and run when it meant to skip.
285
699
  */
286
- async evaluateWhen(when, completedSteps, params, executionPhase, errorCtx) {
700
+ async evaluateWhen(when, completedSteps, params, executionPhase, errorCtx, gate) {
287
701
  if (when === undefined)
288
702
  return true;
289
703
  if (typeof when === 'boolean')
@@ -302,6 +716,10 @@ export class FlowRunner {
302
716
  params,
303
717
  context: executionPhase === this.ctx.executionPhase ? this.ctx : { ...this.ctx, executionPhase },
304
718
  error,
719
+ ...(this.references ? { references: this.references } : {}),
720
+ ...(gate?.step ? { step: gate.step } : {}),
721
+ ...(gate?.check ? { check: gate.check } : {}),
722
+ ...(gate?.flowName ? { flowName: gate.flowName } : {}),
305
723
  });
306
724
  }
307
725
  // Built-in fallback: resolve ${...} references, then test truthiness.
@@ -312,10 +730,132 @@ export class FlowRunner {
312
730
  });
313
731
  return truthy(resolved);
314
732
  }
733
+ /**
734
+ * Evaluate declared checks in order. Never throws: an evaluator failure is
735
+ * reported on the outcome.
736
+ */
737
+ async evaluateChecks(checks, scope, flowName, completedSteps, params, executionPhase, step, path, errorCtx) {
738
+ const out = [];
739
+ for (const check of checks ?? []) {
740
+ const outcome = {
741
+ scope,
742
+ flowName,
743
+ ...(step ? { stepNumber: step.stepNumber, name: step.name } : {}),
744
+ ...(path !== undefined ? { path } : {}),
745
+ when: check.when,
746
+ action: check.action,
747
+ message: check.message ?? defaultCheckMessage(check, scope === 'flow' ? flowName : step?.name),
748
+ triggered: false,
749
+ };
750
+ try {
751
+ outcome.triggered = await this.evaluateWhen(check.when, completedSteps, params, executionPhase, errorCtx, { step, check, flowName });
752
+ }
753
+ catch (err) {
754
+ outcome.error = err instanceof Error ? err : new Error(String(err));
755
+ }
756
+ out.push(outcome);
757
+ }
758
+ return out;
759
+ }
760
+ /**
761
+ * Fold evaluated checks into a verdict. Fired checks go to `fired`, fired
762
+ * `warn` checks to `warnings`. The first fired `error` check wins; an
763
+ * evaluator failure is returned separately so the caller can treat it like a
764
+ * `when:` that throws.
765
+ */
766
+ applyChecks(outcomes, fired, warnings) {
767
+ let error;
768
+ let evalError;
769
+ let skip = false;
770
+ for (const o of outcomes) {
771
+ if (o.error) {
772
+ evalError ??= new Error(`Check on "${o.name ?? o.flowName}" could not be evaluated: ${o.error.message}`);
773
+ continue;
774
+ }
775
+ if (!o.triggered)
776
+ continue;
777
+ fired.push(o);
778
+ if (o.action === 'error')
779
+ error ??= new CheckFailedError(o);
780
+ else if (o.action === 'skip')
781
+ skip = true;
782
+ else {
783
+ warnings.push(checkWarning(o));
784
+ this.logger.warn({ flow: o.flowName, step: o.stepNumber }, o.message);
785
+ }
786
+ }
787
+ return { error, skip, evalError };
788
+ }
789
+ /**
790
+ * Evaluate every declared check of a flow and its steps without running
791
+ * anything. Checks see no step results (nothing has run) and the runtime
792
+ * `params`; one that needs a step result reports an evaluation error and the
793
+ * row's status is `unknown`. Nested flows are expanded, with paths as in an
794
+ * expanded plan; hook steps are listed under their phase.
795
+ */
796
+ async preflight(flowName, params, options = {}) {
797
+ const flow = this.flows[flowName];
798
+ if (!flow)
799
+ throw new Error(`Flow "${flowName}" not found in configuration`);
800
+ const skipSet = new Set(options.skip ?? []);
801
+ const flowChecks = await this.evaluateChecks(flow.checks, 'flow', flowName, [], params, DEFAULT_EXECUTION_PHASE);
802
+ const steps = [];
803
+ await this.preflightFlow(flow, flowName, params, skipSet, '', 0, new Set([flowName]), steps);
804
+ const all = [...flowChecks, ...steps.flatMap((s) => s.checks)];
805
+ const warnings = mergeWarnings(all.filter((c) => c.triggered && c.action === 'warn').map(checkWarning), await this.annotatePlan(steps.filter((s) => s.status !== 'skip')));
806
+ const result = {
807
+ flowName,
808
+ ok: !all.some((c) => c.triggered && c.action === 'error'),
809
+ checks: flowChecks,
810
+ steps,
811
+ };
812
+ if (warnings.length > 0)
813
+ result.warnings = warnings;
814
+ return result;
815
+ }
816
+ async preflightFlow(flow, flowName, params, skipSet, pathPrefix, depth, ancestors, out) {
817
+ const at = (p) => (pathPrefix ? `${pathPrefix}/${p}` : p);
818
+ const rows = [
819
+ ...this.resolveExecutionPlan(flow, skipSet).map((step) => ({ step, path: at(String(step.stepNumber)) })),
820
+ ...['on_start', 'on_success', 'on_failure', 'finally'].flatMap((phase) => this.planHookSteps(flow[phase], phase, skipSet, 0).map((step, i) => ({
821
+ step,
822
+ path: at(`${phase}/${i + 1}`),
823
+ }))),
824
+ ];
825
+ for (const { step, path } of rows) {
826
+ const row = {
827
+ stepNumber: step.stepNumber,
828
+ type: step.type,
829
+ name: step.name,
830
+ path,
831
+ depth,
832
+ ...(step.phase ? { phase: step.phase } : {}),
833
+ status: 'run',
834
+ checks: [],
835
+ };
836
+ out.push(row);
837
+ if (step.skipped) {
838
+ row.status = 'skip';
839
+ row.skipReason = 'static';
840
+ continue;
841
+ }
842
+ row.checks = await this.evaluateChecks(step.checks, 'step', flowName, [], params, step.phase ?? DEFAULT_EXECUTION_PHASE, step, path);
843
+ const child = step.type === 'flow' && !ancestors.has(step.name) ? this.flows[step.name] : undefined;
844
+ if (child) {
845
+ row.checks.push(...(await this.evaluateChecks(child.checks, 'flow', step.name, [], params, DEFAULT_EXECUTION_PHASE)));
846
+ }
847
+ row.status = checkStatus(row.checks);
848
+ if (row.status === 'skip')
849
+ row.skipReason = 'check';
850
+ if (child && row.status !== 'skip') {
851
+ await this.preflightFlow(child, step.name, params, skipSet, path, depth + 1, new Set(ancestors).add(step.name), out);
852
+ }
853
+ }
854
+ }
315
855
  // ---------------------------------------------------------------------------
316
856
  // Internal
317
857
  // ---------------------------------------------------------------------------
318
- async executeFlow(options, isTopLevel, parentOptions) {
858
+ async executeFlow(options, isTopLevel, parentOptions, inheritedFrame) {
319
859
  const startTime = Date.now();
320
860
  const skipSet = new Set(options.skip ?? []);
321
861
  const completedSteps = [];
@@ -331,11 +871,41 @@ export class FlowRunner {
331
871
  // This level must not invoke them as well.
332
872
  const ancestorOwnsRollback = options.rollbackOwnedByAncestor === true;
333
873
  const executionPlan = this.resolveExecutionPlan(flow, skipSet);
874
+ const flowDeprecation = deprecationWarning('flow', options.flowName, flow.deprecated, flow.replaced_by);
875
+ // The flow a run starts on fixes how its params are addressed; nested
876
+ // flows inherit that rather than reading their own `options_scope`.
877
+ const frame = inheritedFrame ?? {
878
+ pathPrefix: '',
879
+ scope: options.optionsScope ?? flow.options_scope ?? this.optionsScope,
880
+ };
881
+ if (!inheritedFrame && frame.scope === 'step') {
882
+ try {
883
+ this.checkScopedParams(options.flowName, options.params);
884
+ }
885
+ catch (err) {
886
+ return { success: false, steps: [], duration: Date.now() - startTime, error: err };
887
+ }
888
+ }
889
+ if (!inheritedFrame && this.strictStepReferences) {
890
+ const issues = this.checkStepReferences(options.flowName);
891
+ if (issues.length > 0) {
892
+ const detail = issues.map((i) => `${i.flowName} step ${i.stepNumber}: ${i.message}`).join('; ');
893
+ return {
894
+ success: false,
895
+ steps: [],
896
+ duration: Date.now() - startTime,
897
+ error: new Error(`Unbindable step references: ${detail}`),
898
+ };
899
+ }
900
+ }
334
901
  // Plan mode — dump all phases for visibility, nothing runs.
335
902
  if (options.plan) {
336
- const mainPlan = options.expandNestedFlows
903
+ let mainPlan = options.expandNestedFlows
337
904
  ? executionPlan.flatMap((s) => this.expandPlanStep(s, parentOptions, String(s.stepNumber), 0, new Set([options.flowName]), skipSet))
338
905
  : executionPlan;
906
+ if (options.expandComposites) {
907
+ mainPlan = await this.expandCompositeRows(mainPlan, options.params, frame.scope);
908
+ }
339
909
  const fullPlan = [
340
910
  ...this.planHookSteps(flow.on_start, 'on_start', skipSet, -3000),
341
911
  ...mainPlan,
@@ -343,7 +913,8 @@ export class FlowRunner {
343
913
  ...this.planHookSteps(flow.on_failure, 'on_failure', skipSet, 20_000),
344
914
  ...this.planHookSteps(flow.finally, 'finally', skipSet, 30_000),
345
915
  ];
346
- return {
916
+ const planWarnings = mergeWarnings(flowDeprecation ? [flowDeprecation] : [], await this.annotatePlan(fullPlan));
917
+ const planResult = {
347
918
  success: true,
348
919
  steps: fullPlan.map((s) => ({
349
920
  stepNumber: s.stepNumber,
@@ -352,20 +923,60 @@ export class FlowRunner {
352
923
  skipped: s.skipped,
353
924
  duration: 0,
354
925
  ...(s.path !== undefined ? { path: s.path, depth: s.depth } : {}),
926
+ ...(s.composite ? { composite: s.composite } : {}),
927
+ ...(s.deprecated ? { deprecated: s.deprecated } : {}),
928
+ ...(s.replaced_by !== undefined ? { replaced_by: s.replaced_by } : {}),
355
929
  })),
356
930
  duration: 0,
357
931
  };
932
+ if (planWarnings.length > 0)
933
+ planResult.warnings = planWarnings;
934
+ return planResult;
935
+ }
936
+ // Flow-level checks gate everything, hooks included, so they run before
937
+ // the run is announced to `beforeRun`.
938
+ const firedChecks = [];
939
+ const checkWarnings = [];
940
+ if (flow.checks?.length) {
941
+ const outcomes = await this.evaluateChecks(flow.checks, 'flow', options.flowName, [], options.params, DEFAULT_EXECUTION_PHASE);
942
+ const verdict = this.applyChecks(outcomes, firedChecks, checkWarnings);
943
+ const failure = verdict.error ?? verdict.evalError;
944
+ if (failure || verdict.skip) {
945
+ const done = {
946
+ success: !failure,
947
+ steps: !failure
948
+ ? executionPlan.map((s) => ({
949
+ stepNumber: s.stepNumber,
950
+ type: s.type,
951
+ name: s.name,
952
+ skipped: true,
953
+ duration: 0,
954
+ skipReason: 'check',
955
+ }))
956
+ : [],
957
+ duration: Date.now() - startTime,
958
+ ...(failure ? { error: failure } : {}),
959
+ ...(firedChecks.length > 0 ? { checks: firedChecks } : {}),
960
+ };
961
+ const w = mergeWarnings(flowDeprecation ? [flowDeprecation] : [], checkWarnings);
962
+ if (w.length > 0)
963
+ done.warnings = w;
964
+ return done;
965
+ }
358
966
  }
359
967
  if (isTopLevel) {
360
968
  await this.hooks.beforeRun?.(options.flowName, executionPlan);
361
969
  }
970
+ if (flowDeprecation)
971
+ this.logger.warn({ flow: options.flowName }, flowDeprecation.message);
972
+ const hookWarnings = [];
362
973
  let flowError;
363
974
  let flowErrorStepName;
364
975
  // ---- on_start ----
365
976
  {
366
977
  const startPlan = this.planHookSteps(flow.on_start, 'on_start', skipSet, -3000);
367
978
  for (const hookStep of startPlan) {
368
- const ok = await this.runHookStep(hookStep, options, completedSteps, parentOptions, undefined, hookErrors);
979
+ const ok = await this.runHookStep(hookStep, options, completedSteps, parentOptions, undefined, hookErrors, hookWarnings, frame);
369
980
  if (!ok) {
370
981
  flowError = hookErrors[hookErrors.length - 1]?.error;
371
982
  flowErrorStepName = hookStep.name;
@@ -421,12 +1032,54 @@ export class FlowRunner {
421
1032
  await this.hooks.afterStep?.(planStep, sr);
422
1033
  continue;
423
1034
  }
1035
+ // Declared checks, after `when` has decided the step would run.
1036
+ const stepFired = [];
1037
+ if (planStep.checks?.length) {
1038
+ const outcomes = await this.evaluateChecks(planStep.checks, 'step', options.flowName, completedSteps, options.params, DEFAULT_EXECUTION_PHASE, planStep, this.stepPath(frame, planStep.stepNumber));
1039
+ const verdict = this.applyChecks(outcomes, stepFired, checkWarnings);
1040
+ firedChecks.push(...stepFired);
1041
+ if (verdict.error || verdict.skip || verdict.evalError) {
1042
+ const sr = {
1043
+ stepNumber: planStep.stepNumber,
1044
+ type: planStep.type,
1045
+ name: planStep.name,
1046
+ skipped: !verdict.error && !verdict.evalError,
1047
+ duration: 0,
1048
+ ...(stepFired.length > 0 ? { checks: [...stepFired] } : {}),
1049
+ };
1050
+ if (sr.skipped) {
1051
+ sr.skipReason = 'check';
1052
+ }
1053
+ else {
1054
+ sr.result = { success: false, error: (verdict.error ?? verdict.evalError) };
1055
+ }
1056
+ completedSteps.push(sr);
1057
+ await this.hooks.afterStep?.(planStep, sr);
1058
+ if (sr.skipped)
1059
+ continue;
1060
+ // A check that could not be evaluated fails like a `when:` that
1061
+ // throws, and honours ignore_failure. A fired `error` check is a
1062
+ // gate and aborts regardless.
1063
+ if (!verdict.error && planStep.ignore_failure) {
1064
+ sr.ignoredFailure = true;
1065
+ continue;
1066
+ }
1067
+ flowError = sr.result.error;
1068
+ flowErrorStepName = planStep.name;
1069
+ await this.hooks.onStepError?.(planStep, flowError, completedSteps);
1070
+ break;
1071
+ }
1072
+ }
424
1073
  await this.hooks.beforeStep?.(planStep);
425
1074
  const stepStart = Date.now();
1075
+ let nestedChecks;
426
1076
  try {
427
1077
  let stepResult;
428
1078
  if (planStep.type === 'task') {
429
- const { result: taskResult, attempts } = await this.executeTaskStepWithRetry(planStep, options.params, completedSteps, parentOptions);
1079
+ const { result: taskResult, attempts } = await this.executeTaskStepWithRetry(planStep, this.runtimeOptionsFor(planStep, frame, options.params), completedSteps, parentOptions, undefined, {
1080
+ path: this.stepPath(frame, planStep.stepNumber),
1081
+ rollbackOwned: rollbackEnabled || ancestorOwnsRollback,
1082
+ });
430
1083
  stepResult = {
431
1084
  stepNumber: planStep.stepNumber,
432
1085
  type: 'task',
@@ -443,13 +1096,9 @@ export class FlowRunner {
443
1096
  // is pushed last and performRollback walks the array backwards, so
444
1097
  // the partial write is undone first and the earlier steps unwind
445
1098
  // after it.
446
- if (taskResult.rollback) {
447
- rollbackRecords.push({
448
- taskName: taskResult.rollback.taskName,
449
- payload: taskResult.rollback.payload,
450
- fromFailedStep: !taskResult.success,
451
- });
452
- }
1099
+ // A composite's children ran before it finished, so their records
1100
+ // go first and unwind after the composite's own.
1101
+ harvestRollbacks(taskResult, rollbackRecords);
453
1102
  }
454
1103
  else {
455
1104
  const childParentOptions = this.mergeParentOptions(parentOptions, planStep.options);
@@ -458,7 +1107,10 @@ export class FlowRunner {
458
1107
  flowName: planStep.name,
459
1108
  plan: false,
460
1109
  rollbackOwnedByAncestor: rollbackEnabled || ancestorOwnsRollback,
461
- }, childParentOptions);
1110
+ }, childParentOptions, { ...frame, pathPrefix: this.stepPath(frame, planStep.stepNumber) });
1111
+ nestedChecks = nestedResult.checks;
1112
+ if (nestedChecks)
1113
+ firedChecks.push(...nestedChecks);
462
1114
  stepResult = {
463
1115
  stepNumber: planStep.stepNumber,
464
1116
  type: 'flow',
@@ -467,6 +1119,7 @@ export class FlowRunner {
467
1119
  success: nestedResult.success,
468
1120
  data: { stepCount: nestedResult.steps.length },
469
1121
  error: nestedResult.success ? undefined : nestedResult.error,
1122
+ ...(nestedResult.warnings ? { warnings: nestedResult.warnings } : {}),
470
1123
  },
471
1124
  skipped: false,
472
1125
  duration: Date.now() - stepStart,
@@ -477,15 +1130,13 @@ export class FlowRunner {
477
1130
  // failing main step's does: the part that landed still needs
478
1131
  // undoing, and the child is the only place that knows what it was.
479
1132
  for (const s of nestedResult.steps) {
480
- if (s.result?.rollback) {
481
- rollbackRecords.push({
482
- taskName: s.result.rollback.taskName,
483
- payload: s.result.rollback.payload,
484
- fromFailedStep: !s.result.success,
485
- });
486
- }
1133
+ if (s.type === 'task' && s.result)
1134
+ harvestRollbacks(s.result, rollbackRecords);
487
1135
  }
488
1136
  }
1137
+ const stepChecks = [...stepFired, ...(nestedChecks ?? [])];
1138
+ if (stepChecks.length > 0)
1139
+ stepResult.checks = stepChecks;
489
1140
  completedSteps.push(stepResult);
490
1141
  await this.hooks.afterStep?.(planStep, stepResult);
491
1142
  if (!stepResult.result?.success) {
@@ -529,13 +1180,13 @@ export class FlowRunner {
529
1180
  if (flowError) {
530
1181
  const failPlan = this.planHookSteps(flow.on_failure, 'on_failure', skipSet, 20_000);
531
1182
  for (const hookStep of failPlan) {
532
- await this.runHookStep(hookStep, options, completedSteps, parentOptions, { error: flowError, step: flowErrorStepName }, hookErrors);
1183
+ await this.runHookStep(hookStep, options, completedSteps, parentOptions, { error: flowError, step: flowErrorStepName }, hookErrors, hookWarnings, frame);
533
1184
  }
534
1185
  }
535
1186
  else {
536
1187
  const successPlan = this.planHookSteps(flow.on_success, 'on_success', skipSet, 10_000);
537
1188
  for (const hookStep of successPlan) {
538
- await this.runHookStep(hookStep, options, completedSteps, parentOptions, undefined, hookErrors);
1189
+ await this.runHookStep(hookStep, options, completedSteps, parentOptions, undefined, hookErrors, hookWarnings, frame);
539
1190
  }
540
1191
  }
541
1192
  // ---- rollback ----
@@ -547,7 +1198,7 @@ export class FlowRunner {
547
1198
  {
548
1199
  const finallyPlan = this.planHookSteps(flow.finally, 'finally', skipSet, 30_000);
549
1200
  for (const hookStep of finallyPlan) {
550
- await this.runHookStep(hookStep, options, completedSteps, parentOptions, flowError ? { error: flowError, step: flowErrorStepName } : undefined, hookErrors);
1201
+ await this.runHookStep(hookStep, options, completedSteps, parentOptions, flowError ? { error: flowError, step: flowErrorStepName } : undefined, hookErrors, hookWarnings, frame);
551
1202
  }
552
1203
  }
553
1204
  const result = {
@@ -558,12 +1209,17 @@ export class FlowRunner {
558
1209
  hookErrors: hookErrors.length > 0 ? hookErrors : undefined,
559
1210
  rollback: rollbackResult,
560
1211
  };
1212
+ const warnings = mergeWarnings(flowDeprecation ? [flowDeprecation] : [], checkWarnings, ...completedSteps.map((s) => s.result?.warnings), hookWarnings);
1213
+ if (warnings.length > 0)
1214
+ result.warnings = warnings;
1215
+ if (firedChecks.length > 0)
1216
+ result.checks = firedChecks;
561
1217
  if (isTopLevel) {
562
1218
  await this.hooks.afterRun?.(result);
563
1219
  }
564
1220
  return result;
565
1221
  }
566
- async runHookStep(hookStep, options, completedSteps, parentOptions, errorCtx, hookErrors) {
1222
+ async runHookStep(hookStep, options, completedSteps, parentOptions, errorCtx, hookErrors, hookWarnings, frame) {
567
1223
  if (hookStep.skipped)
568
1224
  return true;
569
1225
  // Hook steps honor `when:` too — a falsy condition skips them silently.
@@ -582,6 +1238,17 @@ export class FlowRunner {
582
1238
  return false;
583
1239
  }
584
1240
  }
1241
+ if (hookStep.checks?.length) {
1242
+ const outcomes = await this.evaluateChecks(hookStep.checks, 'step', options.flowName, completedSteps, options.params, hookStep.phase ?? DEFAULT_EXECUTION_PHASE, hookStep, undefined, errorCtx);
1243
+ const verdict = this.applyChecks(outcomes, [], hookWarnings);
1244
+ const failure = verdict.error ?? verdict.evalError;
1245
+ if (failure) {
1246
+ hookErrors.push({ phase: hookStep.phase, name: hookStep.name, error: failure });
1247
+ return false;
1248
+ }
1249
+ if (verdict.skip)
1250
+ return true;
1251
+ }
585
1252
  try {
586
1253
  if (hookStep.type === 'flow') {
587
1254
  const childParentOptions = this.mergeParentOptions(parentOptions, hookStep.options);
@@ -593,7 +1260,12 @@ export class FlowRunner {
593
1260
  // harvest, so nothing above will unwind it. It owns its own,
594
1261
  // whatever the run that triggered the hook is doing.
595
1262
  rollbackOwnedByAncestor: false,
596
- }, childParentOptions);
1263
+ }, childParentOptions,
1264
+ // Hook flows are addressed by task name only; the phase keeps their
1265
+ // steps off every main-step path.
1266
+ { ...frame, pathPrefix: `${frame.pathPrefix ? `${frame.pathPrefix}/` : ''}${hookStep.phase}` });
1267
+ if (nested.warnings)
1268
+ hookWarnings.push(...nested.warnings);
597
1269
  if (!nested.success) {
598
1270
  hookErrors.push({
599
1271
  phase: hookStep.phase,
@@ -604,7 +1276,9 @@ export class FlowRunner {
604
1276
  }
605
1277
  return true;
606
1278
  }
607
- const { result } = await this.executeTaskStepWithRetry(hookStep, options.params, completedSteps, parentOptions, errorCtx);
1279
+ const { result } = await this.executeTaskStepWithRetry(hookStep, this.runtimeOptionsFor(hookStep, frame, options.params), completedSteps, parentOptions, errorCtx);
1280
+ if (result.warnings)
1281
+ hookWarnings.push(...result.warnings);
608
1282
  if (!result.success) {
609
1283
  hookErrors.push({
610
1284
  phase: hookStep.phase,
@@ -624,15 +1298,22 @@ export class FlowRunner {
624
1298
  return false;
625
1299
  }
626
1300
  }
627
- async executeTaskStepWithRetry(step, flowParams, completedSteps, parentOptions, errorCtx) {
1301
+ async executeTaskStepWithRetry(step, flowParams, completedSteps, parentOptions, errorCtx, site) {
1302
+ return this.withRetry(step, () => this.executeTaskStep(step, flowParams, completedSteps, parentOptions, errorCtx, site));
1303
+ }
1304
+ /** A step's retry policy (`retries`, `retryDelay`, `retryOn`) around one attempt function. */
1305
+ async withRetry(step, attemptOnce) {
628
1306
  const maxAttempts = Math.max(1, 1 + (step.retries ?? 0));
629
1307
  const delayMs = step.retryDelay ?? 0;
630
1308
  const retryOn = step.retryOn;
631
1309
  let lastResult = { success: false, error: new Error('no attempts executed') };
632
1310
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
633
- lastResult = await this.executeTaskStep(step, flowParams, completedSteps, parentOptions, errorCtx);
1311
+ lastResult = await attemptOnce();
634
1312
  if (lastResult.success)
635
1313
  return { result: lastResult, attempts: attempt };
1314
+ // Bad options fail the same way every time; retrying cannot help.
1315
+ if (lastResult.error instanceof TaskOptionsError)
1316
+ return { result: lastResult, attempts: attempt };
636
1317
  const errMsg = lastResult.error?.message ?? '';
637
1318
  const retryMatches = retryOn == null || errMsg.includes(retryOn);
638
1319
  if (attempt < maxAttempts && retryMatches) {
@@ -645,7 +1326,7 @@ export class FlowRunner {
645
1326
  }
646
1327
  return { result: lastResult, attempts: maxAttempts };
647
1328
  }
648
- async executeTaskStep(step, flowParams, completedSteps, parentOptions, errorCtx) {
1329
+ async executeTaskStep(step, flowParams, completedSteps, parentOptions, errorCtx, site) {
649
1330
  const taskDef = resolveTaskDefinition(step.name, this.tasks);
650
1331
  // Precedence (low → high): task default → enclosing-flow override → step inline → runtime params.
651
1332
  // Every layer here is configuration, so the merge is interpolated as a whole.
@@ -678,7 +1359,7 @@ export class FlowRunner {
678
1359
  };
679
1360
  }
680
1361
  this.logger.info({ step: step.stepNumber, task: step.name, type: step.type }, `Executing step ${step.stepNumber}: ${step.name}`);
681
- return this.executeTask(taskDef.classPath, mergedOptions, refCtx, step.phase ?? DEFAULT_EXECUTION_PHASE);
1362
+ return this.executeTask(taskDef.classPath, mergedOptions, refCtx, step.phase ?? DEFAULT_EXECUTION_PHASE, step.name, site);
682
1363
  }
683
1364
  async performRollback(records) {
684
1365
  const result = { attempted: 0, succeeded: 0, errors: [] };
@@ -692,7 +1373,7 @@ export class FlowRunner {
692
1373
  // `${...}` captured in recorded data is neither substituted nor thrown
693
1374
  // on — which the catch below would turn into a silently failed rollback.
694
1375
  const resolved = resolveTaskCall(rec.taskName, this.tasks, rec.payload, this.baseReferences);
695
- const r = await this.executeTask(resolved.classPath, resolved.options, this.baseReferences, 'rollback');
1376
+ const r = await this.executeTask(resolved.classPath, resolved.options, this.baseReferences, 'rollback', rec.taskName);
696
1377
  if (r.success) {
697
1378
  result.succeeded++;
698
1379
  }
@@ -715,6 +1396,130 @@ export class FlowRunner {
715
1396
  return result;
716
1397
  }
717
1398
  }
1399
+ const STEP_REF = /\$\{steps\.([^}]+)\}/g;
1400
+ /** Every `${steps.<ref>}` body in the strings found anywhere inside `values`. */
1401
+ function stepReferencesIn(values) {
1402
+ const out = [];
1403
+ const walk = (v) => {
1404
+ if (typeof v === 'string') {
1405
+ for (const m of v.matchAll(STEP_REF))
1406
+ out.push(m[1]);
1407
+ }
1408
+ else if (Array.isArray(v)) {
1409
+ v.forEach(walk);
1410
+ }
1411
+ else if (v && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype) {
1412
+ Object.values(v).forEach(walk);
1413
+ }
1414
+ };
1415
+ values.forEach(walk);
1416
+ return out;
1417
+ }
1418
+ /**
1419
+ * Bind one reference the way the resolver does (longest id prefix first,
1420
+ * a number is a step number, anything else a step name) and report why it
1421
+ * cannot be bound to exactly one earlier main step.
1422
+ */
1423
+ function bindStepReference(ref, from, byName, numbers) {
1424
+ const segments = ref.split('.');
1425
+ // Hook steps run after every main step that ran.
1426
+ const before = (n) => from.phase !== undefined || n < from.stepNumber;
1427
+ for (let i = segments.length; i >= 1; i--) {
1428
+ const id = segments.slice(0, i).join('.');
1429
+ if (/^\d+$/.test(id)) {
1430
+ const n = Number(id);
1431
+ if (!numbers.has(n))
1432
+ return { kind: 'unknown', message: `\${steps.${ref}} names step ${id}, which does not exist` };
1433
+ if (!before(n)) {
1434
+ return { kind: 'forward', message: `\${steps.${ref}} names step ${id}, which has not run yet` };
1435
+ }
1436
+ return undefined;
1437
+ }
1438
+ const matches = byName.get(id);
1439
+ if (!matches)
1440
+ continue;
1441
+ if (matches.length > 1) {
1442
+ return {
1443
+ kind: 'ambiguous',
1444
+ message: `\${steps.${ref}}: "${id}" is the name of steps ${matches.join(', ')}; reference one by number`,
1445
+ };
1446
+ }
1447
+ if (!before(matches[0])) {
1448
+ return { kind: 'forward', message: `\${steps.${ref}}: step "${id}" has not run yet` };
1449
+ }
1450
+ return undefined;
1451
+ }
1452
+ return { kind: 'unknown', message: `\${steps.${ref}} matches no step number or name in the flow` };
1453
+ }
1454
+ /**
1455
+ * Interpolate what can be interpolated before a run: host namespaces resolve,
1456
+ * and anything that throws (a step reference, nothing having run) is left as
1457
+ * written.
1458
+ */
1459
+ function lenientReferences(value, refs) {
1460
+ const out = {};
1461
+ for (const [k, v] of Object.entries(value)) {
1462
+ try {
1463
+ out[k] = resolveReferences(v, refs);
1464
+ }
1465
+ catch {
1466
+ out[k] = v;
1467
+ }
1468
+ }
1469
+ return out;
1470
+ }
1471
+ /**
1472
+ * Harvest a task result's rollback records, its composite children's first
1473
+ * (recursively, child flows' steps included), then its own.
1474
+ */
1475
+ function harvestRollbacks(result, into) {
1476
+ for (const child of result.children ?? [])
1477
+ harvestStep(child, into);
1478
+ if (result.rollback) {
1479
+ into.push({
1480
+ taskName: result.rollback.taskName,
1481
+ payload: result.rollback.payload,
1482
+ fromFailedStep: !result.success,
1483
+ });
1484
+ }
1485
+ }
1486
+ function harvestStep(step, into) {
1487
+ if (step.result)
1488
+ harvestRollbacks(step.result, into);
1489
+ for (const nested of step.nestedSteps ?? [])
1490
+ harvestStep(nested, into);
1491
+ }
1492
+ /** Status a set of evaluated checks gives a preflight row. Error beats skip beats unknown. */
1493
+ function checkStatus(outcomes) {
1494
+ if (outcomes.some((o) => o.triggered && o.action === 'error'))
1495
+ return 'error';
1496
+ if (outcomes.some((o) => o.triggered && o.action === 'skip'))
1497
+ return 'skip';
1498
+ if (outcomes.some((o) => o.error))
1499
+ return 'unknown';
1500
+ return 'run';
1501
+ }
1502
+ function defaultCheckMessage(check, subject) {
1503
+ const cond = typeof check.when === 'string' ? check.when : String(check.when);
1504
+ return `Check on ${subject ? `"${subject}"` : 'step'} fired (${check.action}): ${cond}`;
1505
+ }
1506
+ /** The warning a fired `warn` check contributes to a run. */
1507
+ function checkWarning(o) {
1508
+ return {
1509
+ code: 'check',
1510
+ message: o.message,
1511
+ name: o.scope === 'flow' ? o.flowName : (o.name ?? o.flowName),
1512
+ kind: o.scope === 'flow' ? 'flow' : undefined,
1513
+ ...(o.stepNumber !== undefined ? { stepNumber: o.stepNumber } : {}),
1514
+ };
1515
+ }
1516
+ /** Append a runner notice to a task's result without dropping any the task added itself. */
1517
+ function withWarnings(result, warning) {
1518
+ if (!warning)
1519
+ return result;
1520
+ result.warnings = [...(result.warnings ?? []), warning];
1521
+ return result;
1522
+ }
718
1523
  /** Truthiness of a resolved `when:` value, with string special-cases. */
719
1524
  function truthy(value) {
720
1525
  if (typeof value === 'boolean')