@db-lyon/flowkit 0.17.3 → 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.
- package/README.md +53 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/config/index.d.ts +4 -2
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +2 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/loader.d.ts +11 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +4 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +1486 -79
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +125 -8
- package/dist/config/schema.js.map +1 -1
- package/dist/config/strict.d.ts +40 -0
- package/dist/config/strict.d.ts.map +1 -0
- package/dist/config/strict.js +146 -0
- package/dist/config/strict.js.map +1 -0
- package/dist/flow/index.d.ts +2 -2
- package/dist/flow/index.d.ts.map +1 -1
- package/dist/flow/index.js +1 -1
- package/dist/flow/index.js.map +1 -1
- package/dist/flow/runner.d.ts +279 -6
- package/dist/flow/runner.d.ts.map +1 -1
- package/dist/flow/runner.js +845 -40
- package/dist/flow/runner.js.map +1 -1
- package/dist/index.d.ts +13 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/task/base-task.d.ts +50 -1
- package/dist/task/base-task.d.ts.map +1 -1
- package/dist/task/base-task.js +23 -0
- package/dist/task/base-task.js.map +1 -1
- package/dist/task/composite.d.ts +66 -0
- package/dist/task/composite.d.ts.map +1 -0
- package/dist/task/composite.js +21 -0
- package/dist/task/composite.js.map +1 -0
- package/dist/task/index.d.ts +7 -1
- package/dist/task/index.d.ts.map +1 -1
- package/dist/task/index.js +3 -0
- package/dist/task/index.js.map +1 -1
- package/dist/task/options-schema.d.ts +51 -0
- package/dist/task/options-schema.d.ts.map +1 -0
- package/dist/task/options-schema.js +102 -0
- package/dist/task/options-schema.js.map +1 -0
- package/dist/task/registry.d.ts +38 -0
- package/dist/task/registry.d.ts.map +1 -1
- package/dist/task/registry.js +42 -0
- package/dist/task/registry.js.map +1 -1
- package/dist/task/warnings.d.ts +25 -0
- package/dist/task/warnings.d.ts.map +1 -0
- package/dist/task/warnings.js +30 -0
- package/dist/task/warnings.js.map +1 -0
- package/docs/api-reference.md +273 -0
- package/docs/configuration.md +262 -1
- package/docs/custom-tasks.md +155 -0
- package/docs/releases.md +47 -0
- package/package.json +1 -1
package/dist/flow/runner.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
481
|
-
rollbackRecords
|
|
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
|
|
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')
|