@sensigo/realm 0.38.0 → 0.40.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/dist/engine/abandon-run.d.ts.map +1 -1
- package/dist/engine/abandon-run.js +5 -7
- package/dist/engine/abandon-run.js.map +1 -1
- package/dist/engine/apply-resume.d.ts.map +1 -1
- package/dist/engine/apply-resume.js +17 -1
- package/dist/engine/apply-resume.js.map +1 -1
- package/dist/engine/eligibility.d.ts +85 -2
- package/dist/engine/eligibility.d.ts.map +1 -1
- package/dist/engine/eligibility.js +271 -14
- package/dist/engine/eligibility.js.map +1 -1
- package/dist/engine/execution-loop.d.ts.map +1 -1
- package/dist/engine/execution-loop.js +172 -37
- package/dist/engine/execution-loop.js.map +1 -1
- package/dist/engine/run-health.d.ts +1 -1
- package/dist/engine/run-health.d.ts.map +1 -1
- package/dist/engine/run-health.js +89 -0
- package/dist/engine/run-health.js.map +1 -1
- package/dist/engine/settlement.d.ts +61 -6
- package/dist/engine/settlement.d.ts.map +1 -1
- package/dist/engine/settlement.js +190 -25
- package/dist/engine/settlement.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/store/failed-attempt-store.d.ts +9 -1
- package/dist/store/failed-attempt-store.d.ts.map +1 -1
- package/dist/store/failed-attempt-store.js +12 -1
- package/dist/store/failed-attempt-store.js.map +1 -1
- package/dist/store/json-file-store.d.ts +28 -4
- package/dist/store/json-file-store.d.ts.map +1 -1
- package/dist/store/json-file-store.js +133 -5
- package/dist/store/json-file-store.js.map +1 -1
- package/dist/store/per-run-artifact-store.d.ts +42 -1
- package/dist/store/per-run-artifact-store.d.ts.map +1 -1
- package/dist/store/seal-integrity.d.ts +9 -0
- package/dist/store/seal-integrity.d.ts.map +1 -0
- package/dist/store/seal-integrity.js +249 -0
- package/dist/store/seal-integrity.js.map +1 -0
- package/dist/store/store-interface.d.ts +47 -2
- package/dist/store/store-interface.d.ts.map +1 -1
- package/dist/store/trace-buffer-store.d.ts +32 -6
- package/dist/store/trace-buffer-store.d.ts.map +1 -1
- package/dist/store/trace-buffer-store.js +39 -6
- package/dist/store/trace-buffer-store.js.map +1 -1
- package/dist/types/response-envelope.d.ts +0 -1
- package/dist/types/response-envelope.d.ts.map +1 -1
- package/dist/types/run-record.d.ts +201 -6
- package/dist/types/run-record.d.ts.map +1 -1
- package/dist/types/run-record.js +25 -1
- package/dist/types/run-record.js.map +1 -1
- package/dist/types/workflow-definition.d.ts +11 -1
- package/dist/types/workflow-definition.d.ts.map +1 -1
- package/dist/types/workflow-definition.js +1 -0
- package/dist/types/workflow-definition.js.map +1 -1
- package/dist/types/workflow-error.d.ts +1 -1
- package/dist/types/workflow-error.d.ts.map +1 -1
- package/dist/types/workflow-error.js.map +1 -1
- package/dist/workflow/diagnostics.d.ts +39 -3
- package/dist/workflow/diagnostics.d.ts.map +1 -1
- package/dist/workflow/diagnostics.js +37 -10
- package/dist/workflow/diagnostics.js.map +1 -1
- package/dist/workflow/source-positions.d.ts +39 -0
- package/dist/workflow/source-positions.d.ts.map +1 -0
- package/dist/workflow/source-positions.js +107 -0
- package/dist/workflow/source-positions.js.map +1 -0
- package/dist/workflow/yaml-loader.d.ts.map +1 -1
- package/dist/workflow/yaml-loader.js +436 -117
- package/dist/workflow/yaml-loader.js.map +1 -1
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import { Ajv } from 'ajv';
|
|
|
7
7
|
import { KNOWN_STEP_KEYS, KNOWN_WORKFLOW_KEYS, KNOWN_RETRY_KEYS, KNOWN_GATE_KEYS, } from '../types/workflow-definition.js';
|
|
8
8
|
import { WorkflowError } from '../types/workflow-error.js';
|
|
9
9
|
import { findUnknownKeys, renderLoaderWarning, resolveSeverity, closestKey, } from './diagnostics.js';
|
|
10
|
+
import { createSourcePositionCollector } from './source-positions.js';
|
|
10
11
|
import { resolveTemplates } from './template-resolver.js';
|
|
11
12
|
import { normalizeTriggerFilter, validateTriggerStructure } from './trigger-schema.js';
|
|
12
13
|
import { splitComparison, isPathShaped } from '../engine/comparison-expr.js';
|
|
@@ -18,20 +19,27 @@ import { assessStructuredOutputEligibility, renderIneligibleMessage, } from './s
|
|
|
18
19
|
* used at runtime). Rejects compound `and`/`or`, multiple operators, and non-path LHS. For `when`,
|
|
19
20
|
* also enforces the direct-`depends_on` reference check (Change 2). Pushes actionable errors.
|
|
20
21
|
*/
|
|
21
|
-
function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors
|
|
22
|
+
function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors,
|
|
23
|
+
/**
|
|
24
|
+
* Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
|
|
25
|
+
* the compiler names every call site if this ever gains another one — an omitted resolver
|
|
26
|
+
* would silently drop positions, which is exactly the kind of quiet gap this repo keeps
|
|
27
|
+
* finding the hard way.
|
|
28
|
+
*/
|
|
29
|
+
withLine) {
|
|
22
30
|
const split = splitComparison(leaf);
|
|
23
31
|
if (split.kind === 'invalid') {
|
|
24
32
|
if (split.reason === 'compound_and' || split.reason === 'compound_or') {
|
|
25
33
|
const kw = split.reason === 'compound_and' ? 'and' : 'or';
|
|
26
34
|
const listForm = (split.parts ?? [leaf]).map((p) => ` - "${p}"`).join('\n');
|
|
27
|
-
errors.push(`Step '${stepName}': '${surface}' uses unsupported '${kw}' — write it as a list:\n` +
|
|
28
|
-
` ${surface}:\n${listForm}`);
|
|
35
|
+
errors.push(withLine(stepName, `Step '${stepName}': '${surface}' uses unsupported '${kw}' — write it as a list:\n` +
|
|
36
|
+
` ${surface}:\n${listForm}`));
|
|
29
37
|
}
|
|
30
38
|
else if (split.reason === 'multiple_operators') {
|
|
31
|
-
errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' has multiple comparison operators — each leaf must be a single comparison.`);
|
|
39
|
+
errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' has multiple comparison operators — each leaf must be a single comparison.`));
|
|
32
40
|
}
|
|
33
41
|
else {
|
|
34
|
-
errors.push(`Step '${stepName}': '${surface}' leaf must not be empty.`);
|
|
42
|
+
errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf must not be empty.`));
|
|
35
43
|
}
|
|
36
44
|
return;
|
|
37
45
|
}
|
|
@@ -42,7 +50,7 @@ function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors) {
|
|
|
42
50
|
// $settlement one-hop reason (issue #220 §4c pin kk: the precondition witness for a
|
|
43
51
|
// $settlement leaf must use the comparison spelling).
|
|
44
52
|
if (surface === 'preconditions') {
|
|
45
|
-
errors.push(`Step '${stepName}': precondition '${leaf}' must be a comparison (e.g. "step.field >= 1").`);
|
|
53
|
+
errors.push(withLine(stepName, `Step '${stepName}': precondition '${leaf}' must be a comparison (e.g. "step.field >= 1").`));
|
|
46
54
|
return;
|
|
47
55
|
}
|
|
48
56
|
// issue #220 §4c (PR-3): `$settlement.<dep>.<field>` handling lives HERE, in
|
|
@@ -52,28 +60,28 @@ function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors) {
|
|
|
52
60
|
// `surface === 'when'` — an arm there would never fire for abort_unless/preconditions). This
|
|
53
61
|
// fires on ALL THREE surfaces since it runs BEFORE the generic isPathShaped check below.
|
|
54
62
|
if (split.path.split('.')[0] === '$settlement') {
|
|
55
|
-
validateSettlementReference(split.path, surface, leaf, stepName, dependsOn, errors);
|
|
63
|
+
validateSettlementReference(split.path, surface, leaf, stepName, dependsOn, errors, withLine);
|
|
56
64
|
return;
|
|
57
65
|
}
|
|
58
66
|
if (!isPathShaped(split.path)) {
|
|
59
|
-
errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' is not a valid path or comparison.`);
|
|
67
|
+
errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' is not a valid path or comparison.`));
|
|
60
68
|
return;
|
|
61
69
|
}
|
|
62
70
|
if (surface === 'when')
|
|
63
|
-
validateWhenReference(split.path, stepName, dependsOn, errors);
|
|
71
|
+
validateWhenReference(split.path, stepName, dependsOn, errors, withLine);
|
|
64
72
|
return;
|
|
65
73
|
}
|
|
66
74
|
// comparison
|
|
67
75
|
if (split.lhsPath.split('.')[0] === '$settlement') {
|
|
68
|
-
validateSettlementReference(split.lhsPath, surface, leaf, stepName, dependsOn, errors);
|
|
76
|
+
validateSettlementReference(split.lhsPath, surface, leaf, stepName, dependsOn, errors, withLine);
|
|
69
77
|
return;
|
|
70
78
|
}
|
|
71
79
|
if (!isPathShaped(split.lhsPath)) {
|
|
72
|
-
errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' must have a path on the left-hand side (got '${split.lhsPath}').`);
|
|
80
|
+
errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' must have a path on the left-hand side (got '${split.lhsPath}').`));
|
|
73
81
|
return;
|
|
74
82
|
}
|
|
75
83
|
if (surface === 'when')
|
|
76
|
-
validateWhenReference(split.lhsPath, stepName, dependsOn, errors);
|
|
84
|
+
validateWhenReference(split.lhsPath, stepName, dependsOn, errors, withLine);
|
|
77
85
|
}
|
|
78
86
|
/**
|
|
79
87
|
* issue #220 §4c (PR-3): validates a `$settlement.<dep>.<field>` reference reached from ANY of
|
|
@@ -83,7 +91,14 @@ function validateConditionLeaf(surface, leaf, stepName, dependsOn, errors) {
|
|
|
83
91
|
* `surface === 'when'` gate. The caller must NOT fall through to the generic `isPathShaped` check
|
|
84
92
|
* afterward (which rejects `$` outright) — this function's callers always `return` immediately.
|
|
85
93
|
*/
|
|
86
|
-
function validateSettlementReference(path, surface, leaf, stepName, dependsOn, errors
|
|
94
|
+
function validateSettlementReference(path, surface, leaf, stepName, dependsOn, errors,
|
|
95
|
+
/**
|
|
96
|
+
* Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
|
|
97
|
+
* the compiler names every call site if this ever gains another one — an omitted resolver
|
|
98
|
+
* would silently drop positions, which is exactly the kind of quiet gap this repo keeps
|
|
99
|
+
* finding the hard way.
|
|
100
|
+
*/
|
|
101
|
+
withLine) {
|
|
87
102
|
// Path-shape: a NARROWING for this ONE prefix only (never a general `$` allowance) — the
|
|
88
103
|
// remainder after `$settlement` must itself be path-shaped. Rejects `$foo`, a bare `$`, and
|
|
89
104
|
// garbage remainders like `$settlement.a b`.
|
|
@@ -96,21 +111,21 @@ function validateSettlementReference(path, surface, leaf, stepName, dependsOn, e
|
|
|
96
111
|
// no backtracking. Accepts every valid `$settlement.<dep>.<field>` path identically; stricter
|
|
97
112
|
// only on pathological consecutive dots (`$settlement.dep..field`), which is more correct.
|
|
98
113
|
if (!/^\$settlement(\.[A-Za-z_][A-Za-z0-9_-]*)*$/.test(path)) {
|
|
99
|
-
errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' has an invalid '$settlement' reference ` +
|
|
100
|
-
`'${path}' — expected '$settlement.<dep>.<field>'.`);
|
|
114
|
+
errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' has an invalid '$settlement' reference ` +
|
|
115
|
+
`'${path}' — expected '$settlement.<dep>.<field>'.`));
|
|
101
116
|
return;
|
|
102
117
|
}
|
|
103
118
|
// One-hop (§4c-S4): the SECOND segment must be a DIRECT dependency of this step.
|
|
104
119
|
const dep = path.split('.')[1];
|
|
105
120
|
if (dep === undefined) {
|
|
106
|
-
errors.push(`Step '${stepName}': '${surface}' leaf '${leaf}' references '$settlement' with no ` +
|
|
107
|
-
`dependency segment — expected '$settlement.<dep>.<field>' where '<dep>' is a direct dependency.`);
|
|
121
|
+
errors.push(withLine(stepName, `Step '${stepName}': '${surface}' leaf '${leaf}' references '$settlement' with no ` +
|
|
122
|
+
`dependency segment — expected '$settlement.<dep>.<field>' where '<dep>' is a direct dependency.`));
|
|
108
123
|
return;
|
|
109
124
|
}
|
|
110
125
|
if (!dependsOn.includes(dep)) {
|
|
111
|
-
errors.push(`Step '${stepName}': '${surface}' references '$settlement.${dep}' — '${dep}' is not in ` +
|
|
126
|
+
errors.push(withLine(stepName, `Step '${stepName}': '${surface}' references '$settlement.${dep}' — '${dep}' is not in ` +
|
|
112
127
|
`its depends_on [${dependsOn.join(', ')}]. '$settlement' paths must reference a direct ` +
|
|
113
|
-
`dependency (one-hop rule).`);
|
|
128
|
+
`dependency (one-hop rule).`));
|
|
114
129
|
}
|
|
115
130
|
}
|
|
116
131
|
/**
|
|
@@ -118,16 +133,23 @@ function validateSettlementReference(path, surface, leaf, stepName, dependsOn, e
|
|
|
118
133
|
* step in this step's DIRECT `depends_on` (one-hop membership — no graph traversal). Field names are
|
|
119
134
|
* not checked (agent-step outputs aren't statically declared).
|
|
120
135
|
*/
|
|
121
|
-
function validateWhenReference(path, stepName, dependsOn, errors
|
|
136
|
+
function validateWhenReference(path, stepName, dependsOn, errors,
|
|
137
|
+
/**
|
|
138
|
+
* Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
|
|
139
|
+
* the compiler names every call site if this ever gains another one — an omitted resolver
|
|
140
|
+
* would silently drop positions, which is exactly the kind of quiet gap this repo keeps
|
|
141
|
+
* finding the hard way.
|
|
142
|
+
*/
|
|
143
|
+
withLine) {
|
|
122
144
|
const first = path.split('.')[0];
|
|
123
145
|
if (first === 'run') {
|
|
124
146
|
if (!(path === 'run.params' || path.startsWith('run.params.'))) {
|
|
125
|
-
errors.push(`Step '${stepName}': 'when' references '${path}' — only 'run.params.*' is available from 'run'.`);
|
|
147
|
+
errors.push(withLine(stepName, `Step '${stepName}': 'when' references '${path}' — only 'run.params.*' is available from 'run'.`));
|
|
126
148
|
}
|
|
127
149
|
return;
|
|
128
150
|
}
|
|
129
151
|
if (!dependsOn.includes(first)) {
|
|
130
|
-
errors.push(`Step '${stepName}': 'when' references step '${first}' which is not in its depends_on [${dependsOn.join(', ')}]. Add it to depends_on or use 'run.params.*'.`);
|
|
152
|
+
errors.push(withLine(stepName, `Step '${stepName}': 'when' references step '${first}' which is not in its depends_on [${dependsOn.join(', ')}]. Add it to depends_on or use 'run.params.*'.`));
|
|
131
153
|
}
|
|
132
154
|
}
|
|
133
155
|
/** Bumped on every breaking change to WorkflowDefinition's serialized format. */
|
|
@@ -478,9 +500,14 @@ function detectDependencyCycles(edges) {
|
|
|
478
500
|
*/
|
|
479
501
|
function parseWorkflowString(content, registry, opts) {
|
|
480
502
|
// Step 1: Parse YAML
|
|
503
|
+
//
|
|
504
|
+
// issue #392: the position collector rides THIS parse via js-yaml's own listener — there is no
|
|
505
|
+
// second parse and no parser change. If the parse throws, `finish()` is never reached and every
|
|
506
|
+
// position is simply absent, which is the correct answer for a file that did not parse.
|
|
507
|
+
const positions = createSourcePositionCollector();
|
|
481
508
|
let raw;
|
|
482
509
|
try {
|
|
483
|
-
raw = load(content);
|
|
510
|
+
raw = load(content, { listener: positions.listener });
|
|
484
511
|
}
|
|
485
512
|
catch (err) {
|
|
486
513
|
throw new WorkflowError(`YAML parse error: ${err instanceof Error ? err.message : String(err)}`, {
|
|
@@ -492,6 +519,36 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
492
519
|
}
|
|
493
520
|
const errors = [];
|
|
494
521
|
const warnings = [];
|
|
522
|
+
// Finalised after the parse succeeded; resolves a semantic path to its place in the source.
|
|
523
|
+
const sourceMap = positions.finish();
|
|
524
|
+
/**
|
|
525
|
+
* Appends ` (line N)` when the step's own key can be placed, and nothing when it cannot
|
|
526
|
+
* (issue #392). Used at PUSH time, never at join time — once messages are joined into one
|
|
527
|
+
* string the step each came from is no longer recoverable.
|
|
528
|
+
*/
|
|
529
|
+
const withStepLine = (stepName, message) => {
|
|
530
|
+
const line = sourceMap.posOf(['steps', stepName])?.line;
|
|
531
|
+
return line === undefined ? message : `${message} (line ${line})`;
|
|
532
|
+
};
|
|
533
|
+
/**
|
|
534
|
+
* Like `withStepLine`, but names the OFFENDING KEY's own line (issue #417).
|
|
535
|
+
*
|
|
536
|
+
* For a key-scoped refusal the step's line is the wrong place to send someone: a long step has
|
|
537
|
+
* the key twenty lines below its own name, and the author reading `(line 40)` looks at the
|
|
538
|
+
* declaration rather than at the field being refused. The position map records every pairable
|
|
539
|
+
* mapping key, so the key's own line is available wherever the step's is.
|
|
540
|
+
*
|
|
541
|
+
* Falls back to the step's line, and then to no position at all — and the two real shapes land
|
|
542
|
+
* on DIFFERENT rungs, which is why both are pinned. A step body assembled through a merge key
|
|
543
|
+
* (`<<: *anchor`) leaves the KEY unpairable while the step's own name is still placeable, so it
|
|
544
|
+
* falls back to the step's line. A `use_template` step, whose keys are synthesized, exists at no
|
|
545
|
+
* line in the file at all and carries no position. Neither guesses — a wrong line number sends
|
|
546
|
+
* an author confidently to the wrong place, which is worse than sending them nowhere.
|
|
547
|
+
*/
|
|
548
|
+
const withKeyLine = (stepName, key, message) => {
|
|
549
|
+
const line = sourceMap.posOf(['steps', stepName, key])?.line ?? sourceMap.posOf(['steps', stepName])?.line;
|
|
550
|
+
return line === undefined ? message : `${message} (line ${line})`;
|
|
551
|
+
};
|
|
495
552
|
// Step 2: Top-level validation
|
|
496
553
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
497
554
|
throw new WorkflowError('Invalid workflow: Workflow must be a non-null object', {
|
|
@@ -515,6 +572,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
515
572
|
scope: 'workflow',
|
|
516
573
|
code: 'UNKNOWN_WORKFLOW_KEY',
|
|
517
574
|
id: workflowId,
|
|
575
|
+
positionOf: (key) => sourceMap.posOf([key]),
|
|
518
576
|
}));
|
|
519
577
|
}
|
|
520
578
|
// Project extensions: hard error for string-based loading (fires before any other
|
|
@@ -567,7 +625,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
567
625
|
// Step 3: Per-step validation
|
|
568
626
|
for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
|
|
569
627
|
if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw)) {
|
|
570
|
-
errors.push(`Step '${stepName}' must be an object`);
|
|
628
|
+
errors.push(withStepLine(stepName, `Step '${stepName}' must be an object`));
|
|
571
629
|
continue;
|
|
572
630
|
}
|
|
573
631
|
const step = stepRaw;
|
|
@@ -588,6 +646,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
588
646
|
scope: 'step',
|
|
589
647
|
code: 'UNKNOWN_STEP_KEY',
|
|
590
648
|
step: stepName,
|
|
649
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, key]),
|
|
591
650
|
}));
|
|
592
651
|
// issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
|
|
593
652
|
// namespace it names is not minted until a later PR — else the inter-PR gap could register a
|
|
@@ -605,11 +664,11 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
605
664
|
const REQUIRED_STEP = ['description', 'execution'];
|
|
606
665
|
for (const field of REQUIRED_STEP) {
|
|
607
666
|
if (!(field in step)) {
|
|
608
|
-
errors.push(`Step '${stepName}': missing required field '${field}'`);
|
|
667
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': missing required field '${field}'`));
|
|
609
668
|
}
|
|
610
669
|
}
|
|
611
670
|
if ('execution' in step && !VALID_EXECUTIONS.has(step['execution'])) {
|
|
612
|
-
errors.push(`Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`);
|
|
671
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`));
|
|
613
672
|
}
|
|
614
673
|
// Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
|
|
615
674
|
if (step['execution'] === 'finalizer') {
|
|
@@ -630,37 +689,43 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
630
689
|
];
|
|
631
690
|
for (const field of prohibited) {
|
|
632
691
|
if (step[field] !== undefined) {
|
|
633
|
-
errors.push(`Step '${stepName}': '${field}' is not valid on execution: finalizer steps`);
|
|
692
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: finalizer steps`));
|
|
634
693
|
}
|
|
635
694
|
}
|
|
636
695
|
// A finalizer must not gate — reject any human-gate trust level.
|
|
637
696
|
if (step['trust'] !== undefined && step['trust'] !== 'auto') {
|
|
638
|
-
errors.push(`Step '${stepName}': 'trust: ${String(step['trust'])}' is not valid on execution: finalizer steps (a finalizer must not gate)`);
|
|
697
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'trust: ${String(step['trust'])}' is not valid on execution: finalizer steps (a finalizer must not gate)`));
|
|
639
698
|
}
|
|
640
699
|
// v1 is handler-only.
|
|
641
700
|
if (step['handler'] === undefined) {
|
|
642
|
-
errors.push(`Step '${stepName}': execution: finalizer requires 'handler' (handler-only in v1)`);
|
|
701
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'handler' (handler-only in v1)`));
|
|
643
702
|
}
|
|
644
703
|
// on_outcome is required, non-empty, every value in the FinalizerTrigger enum.
|
|
645
704
|
const rawOutcome = step['on_outcome'];
|
|
646
705
|
if (rawOutcome === undefined) {
|
|
647
|
-
errors.push(`Step '${stepName}': execution: finalizer requires 'on_outcome'`);
|
|
706
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'on_outcome'`));
|
|
648
707
|
}
|
|
649
708
|
else {
|
|
650
709
|
const outcomes = Array.isArray(rawOutcome) ? rawOutcome : [rawOutcome];
|
|
651
710
|
if (outcomes.length === 0) {
|
|
652
|
-
errors.push(`Step '${stepName}': 'on_outcome' must not be empty`);
|
|
711
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'on_outcome' must not be empty`));
|
|
653
712
|
}
|
|
654
713
|
for (const o of outcomes) {
|
|
655
714
|
if (typeof o !== 'string' || !VALID_FINALIZER_TRIGGERS.has(o)) {
|
|
656
|
-
errors.push(`Step '${stepName}': invalid on_outcome value '${String(o)}'; must be one of ${[...VALID_FINALIZER_TRIGGERS].join(', ')}`);
|
|
715
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid on_outcome value '${String(o)}'; must be one of ${[...VALID_FINALIZER_TRIGGERS].join(', ')}`));
|
|
657
716
|
}
|
|
658
717
|
}
|
|
659
718
|
}
|
|
660
719
|
}
|
|
661
720
|
// on_outcome is only valid on execution: finalizer steps.
|
|
662
721
|
if (step['on_outcome'] !== undefined && step['execution'] !== 'finalizer') {
|
|
663
|
-
errors.push(
|
|
722
|
+
errors.push(
|
|
723
|
+
// Consumer: settlement.ts:145 (`finalizerTriggers`) — it is read only when selecting
|
|
724
|
+
// which finalizers a run's outcome should fire.
|
|
725
|
+
withKeyLine(stepName, 'on_outcome', `Step '${stepName}': 'on_outcome' is only valid on execution: finalizer steps — it ` +
|
|
726
|
+
'selects which finalizers run for a given outcome, and only finalizers are selected ' +
|
|
727
|
+
'that way, so here it would decide nothing. Move it to the finalizer that should ' +
|
|
728
|
+
'react to the outcome, or remove it.'));
|
|
664
729
|
}
|
|
665
730
|
// Guard step constraints.
|
|
666
731
|
if (step['execution'] === 'guard') {
|
|
@@ -680,29 +745,112 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
680
745
|
];
|
|
681
746
|
for (const field of prohibited) {
|
|
682
747
|
if (step[field] !== undefined) {
|
|
683
|
-
errors.push(`Step '${stepName}': '${field}' is not valid on execution: guard steps`);
|
|
748
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: guard steps`));
|
|
684
749
|
}
|
|
685
750
|
}
|
|
686
751
|
if (step['abort_unless'] === undefined) {
|
|
687
|
-
errors.push(`Step '${stepName}': execution: guard requires 'abort_unless'`);
|
|
752
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
|
|
753
|
+
}
|
|
754
|
+
// issue #369: `preconditions` gets its OWN error rather than joining `prohibited` above,
|
|
755
|
+
// because the generic message ("'x' is not valid on execution: guard steps") would not say
|
|
756
|
+
// the thing that matters — this field was ACCEPTED and INERT before this check existed, so
|
|
757
|
+
// an author who wrote one has a workflow that looks guarded and never was. The generic
|
|
758
|
+
// list's own message style is issue #366's territory; the other twelve are left alone.
|
|
759
|
+
//
|
|
760
|
+
// The claim "never evaluates it there" rests on `checkPreconditions` having exactly one
|
|
761
|
+
// engine call site (execution-loop.ts:1380, inside `executeStep`), which `executeGuardStep`
|
|
762
|
+
// never reaches. A test pins that count so a second call site reds this message.
|
|
763
|
+
if (step['preconditions'] !== undefined) {
|
|
764
|
+
errors.push(withKeyLine(stepName, 'preconditions', `Step '${stepName}': 'preconditions' is not valid on execution: guard steps — the ` +
|
|
765
|
+
`engine never evaluates it there (a guard's execution evaluates only 'abort_unless'), ` +
|
|
766
|
+
`so the run would LOOK guarded while the declared check never ran. Move the condition ` +
|
|
767
|
+
`into 'abort_unless'. Whether guards gain a live condition surface is an open design ` +
|
|
768
|
+
`question (issue #366) — if admitted later, existing workflows are unaffected.`));
|
|
688
769
|
}
|
|
689
770
|
}
|
|
690
771
|
// abort_unless and abort_message are only valid on execution: guard steps.
|
|
691
772
|
if (step['abort_unless'] !== undefined && step['execution'] !== 'guard') {
|
|
692
|
-
errors.push(
|
|
773
|
+
errors.push(
|
|
774
|
+
// Consumer: execution-loop.ts:4828 — the condition list a guard evaluates before the
|
|
775
|
+
// run is allowed to continue.
|
|
776
|
+
withKeyLine(stepName, 'abort_unless', `Step '${stepName}': 'abort_unless' is only valid on execution: guard steps — it is ` +
|
|
777
|
+
'the condition list a guard evaluates before letting the run continue, and only ' +
|
|
778
|
+
'guard steps are evaluated that way, so here it would gate nothing. Put the check ' +
|
|
779
|
+
'on a guard step, or remove it.'));
|
|
693
780
|
}
|
|
694
781
|
if (step['abort_message'] !== undefined && step['execution'] !== 'guard') {
|
|
695
|
-
errors.push(
|
|
782
|
+
errors.push(
|
|
783
|
+
// Consumer: execution-loop.ts:4943 — the text reported when a guard aborts the run.
|
|
784
|
+
// The clause is about READERSHIP, not about who aborts: `handler_abort` and
|
|
785
|
+
// `gate_expiry_abort` are seal arms too (types/run-record.ts:603-617), so "only a guard
|
|
786
|
+
// aborts" would be false. What is true is that every reader of this key is a guard path.
|
|
787
|
+
withKeyLine(stepName, 'abort_message', `Step '${stepName}': 'abort_message' is only valid on execution: guard steps — it is ` +
|
|
788
|
+
'the text reported when a guard aborts the run, and nothing but a guard reads it, ' +
|
|
789
|
+
'so here it would never be read. Move it to the guard that performs the abort, or ' +
|
|
790
|
+
'remove it.'));
|
|
696
791
|
}
|
|
697
792
|
// agent_profile is only valid on agent steps.
|
|
698
793
|
if ('agent_profile' in step && step['execution'] !== 'agent') {
|
|
699
|
-
errors.push(
|
|
794
|
+
errors.push(
|
|
795
|
+
// Consumer: run-agent.ts:584 — resolved into the model prompt for the step.
|
|
796
|
+
withKeyLine(stepName, 'agent_profile', `Step '${stepName}': 'agent_profile' is only valid on execution: agent steps — its ` +
|
|
797
|
+
'content is resolved into the model prompt, and only an agent step makes a model ' +
|
|
798
|
+
'request, so here it would reach no model. Move it to the agent step whose prompt ' +
|
|
799
|
+
'it should shape, or remove it.'));
|
|
800
|
+
}
|
|
801
|
+
// llm_timeout_seconds (issue #401) is only valid on agent steps — no other execution kind
|
|
802
|
+
// makes a model request, so the key would be silently inert anywhere else. One `!== 'agent'`
|
|
803
|
+
// check covers auto/guard/finalizer.
|
|
804
|
+
if (step['llm_timeout_seconds'] !== undefined && step['execution'] !== 'agent') {
|
|
805
|
+
errors.push(
|
|
806
|
+
// Consumer: run-agent.ts:501-507 — the per-step clock resolution, which is the
|
|
807
|
+
// per-attempt bound on the step's model request. The range names the resolution rather
|
|
808
|
+
// than each read: :501 and :507 read the KEY, :503 reads the CLI flag it overrides.
|
|
809
|
+
withKeyLine(stepName, 'llm_timeout_seconds', `Step '${stepName}': 'llm_timeout_seconds' is only valid on execution: agent steps — ` +
|
|
810
|
+
'it bounds one model request, and no other kind makes one, so here it would bound ' +
|
|
811
|
+
'nothing. Move it to the agent step whose request it should bound, or remove it. ' +
|
|
812
|
+
"An auto step's dispatch is bounded by 'timeout_seconds', and a " +
|
|
813
|
+
"finalizer's handler by its own 'timeout_seconds'."));
|
|
814
|
+
}
|
|
815
|
+
// ...and when present it must be a positive integer (the same convention as
|
|
816
|
+
// retry.total_timeout_seconds and gate.timeout_seconds).
|
|
817
|
+
if (step['llm_timeout_seconds'] !== undefined &&
|
|
818
|
+
(!Number.isInteger(step['llm_timeout_seconds']) ||
|
|
819
|
+
step['llm_timeout_seconds'] <= 0)) {
|
|
820
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'llm_timeout_seconds' must be a positive integer`));
|
|
821
|
+
}
|
|
822
|
+
// timeout_seconds is NOT valid on an agent step (issue #402). Nothing enforces it there:
|
|
823
|
+
// `shouldEnforceTimeout` is `execution === 'auto'`, and agent dispatch is never wrapped in
|
|
824
|
+
// `withTimeout` at all. The key is now inert as well as unenforced — issue #412 deleted the
|
|
825
|
+
// `expected_timeout` display that used to render it into the NextAction, which is what made
|
|
826
|
+
// it actively misleading rather than merely useless. The error stays: an author who writes a
|
|
827
|
+
// bound should be told it does nothing, not left to find out. The message names both bounds
|
|
828
|
+
// that DO exist, scoped to realm's own drive (an externally driven step gets neither), on
|
|
829
|
+
// the RETRY_INERT_NON_AUTO precedent below.
|
|
830
|
+
//
|
|
831
|
+
// `=== 'agent'` EXACTLY, never `!== 'auto'`: finalizers consume this key twice — the drain
|
|
832
|
+
// lease (execution-loop.ts:5226) and the handler's own bound (:5030) — and guards already
|
|
833
|
+
// reject it in the prohibited-fields list above.
|
|
834
|
+
if (step['timeout_seconds'] !== undefined && step['execution'] === 'agent') {
|
|
835
|
+
errors.push(withKeyLine(stepName, 'timeout_seconds', `Step '${stepName}': 'timeout_seconds' is not valid on execution: agent steps — ` +
|
|
836
|
+
'the engine never enforces it there (agent dispatch is never wrapped in a timeout), ' +
|
|
837
|
+
'so the step would LOOK time-bounded while nothing enforced the bound. ' +
|
|
838
|
+
"In realm's own drive the model request is bounded by 'llm_timeout_seconds' " +
|
|
839
|
+
"(or --llm-timeout) and tool calls by 'tool_timeout'."));
|
|
700
840
|
}
|
|
701
841
|
// idempotent (issue #101 Phase 2) is only valid on execution: auto steps — the reliably
|
|
702
842
|
// time-boundable, deadline-carrying class. It is inert (no concrete deadline is ever written)
|
|
703
843
|
// on agent/guard/finalizer, so it is rejected there rather than silently ignored.
|
|
704
844
|
if (step['idempotent'] !== undefined && step['execution'] !== 'auto') {
|
|
705
|
-
errors.push(
|
|
845
|
+
errors.push(
|
|
846
|
+
// Consumers: execution-loop.ts:2526 (the `willRetry` conjunct gating `retry.on_timeout`;
|
|
847
|
+
// the :2115 advisory mirrors the rule for loader-bypassing definitions and, by its own
|
|
848
|
+
// header, never gates) and reclaim.ts:73 (reclaim eligibility) — both act on auto
|
|
849
|
+
// dispatch.
|
|
850
|
+
withKeyLine(stepName, 'idempotent', `Step '${stepName}': 'idempotent' is only valid on execution: auto steps — it gates ` +
|
|
851
|
+
"'retry.on_timeout' and reclaim eligibility, and both act on auto dispatch, so here " +
|
|
852
|
+
'it would gate nothing. Remove it, or move the work to an auto step if you need ' +
|
|
853
|
+
'either.'));
|
|
706
854
|
}
|
|
707
855
|
// WARN (do not reject): an idempotent auto step in a finalizer-bearing workflow gets
|
|
708
856
|
// `deadline: null` (issue #101), so the RECLAIM function is inert — `realm run reclaim --all`
|
|
@@ -734,7 +882,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
734
882
|
}
|
|
735
883
|
// output_schema is only valid on execution: agent steps.
|
|
736
884
|
if (step['output_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
737
|
-
errors.push(`Step '${stepName}': 'output_schema' is only valid on execution: agent steps`);
|
|
885
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'output_schema' is only valid on execution: agent steps`));
|
|
738
886
|
}
|
|
739
887
|
// issue #236 (L0 prevention layer): structured_output is only valid on execution: agent
|
|
740
888
|
// steps (mirrors output_schema's rule above), and its only legal value is the literal
|
|
@@ -744,10 +892,10 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
744
892
|
// only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
|
|
745
893
|
if (step['structured_output'] !== undefined) {
|
|
746
894
|
if (step['execution'] !== 'agent') {
|
|
747
|
-
errors.push(`Step '${stepName}': 'structured_output' is only valid on execution: agent steps`);
|
|
895
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' is only valid on execution: agent steps`));
|
|
748
896
|
}
|
|
749
897
|
else if (step['structured_output'] !== 'strict') {
|
|
750
|
-
errors.push(`Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`);
|
|
898
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`));
|
|
751
899
|
}
|
|
752
900
|
else {
|
|
753
901
|
const verdict = assessStructuredOutputEligibility({
|
|
@@ -760,8 +908,8 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
760
908
|
...(step['tools'] !== undefined ? { tools: step['tools'] } : {}),
|
|
761
909
|
});
|
|
762
910
|
if (verdict.verdict === 'ineligible') {
|
|
763
|
-
errors.push(`Step '${stepName}': 'structured_output: strict' is not eligible for this step's ` +
|
|
764
|
-
`schema — ${renderIneligibleMessage(verdict.reasons)}`);
|
|
911
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output: strict' is not eligible for this step's ` +
|
|
912
|
+
`schema — ${renderIneligibleMessage(verdict.reasons)}`));
|
|
765
913
|
}
|
|
766
914
|
}
|
|
767
915
|
}
|
|
@@ -776,11 +924,11 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
776
924
|
// as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
|
|
777
925
|
if (step['validation_exhaustion'] !== undefined) {
|
|
778
926
|
if (step['execution'] !== 'agent') {
|
|
779
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion' is only valid on execution: agent steps`);
|
|
927
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' is only valid on execution: agent steps`));
|
|
780
928
|
}
|
|
781
929
|
else if (typeof step['validation_exhaustion'] !== 'object' ||
|
|
782
930
|
step['validation_exhaustion'] === null) {
|
|
783
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion' must be an object`);
|
|
931
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' must be an object`));
|
|
784
932
|
}
|
|
785
933
|
else {
|
|
786
934
|
const exhaustionBlock = step['validation_exhaustion'];
|
|
@@ -793,28 +941,29 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
793
941
|
code: 'UNKNOWN_VALIDATION_EXHAUSTION_KEY',
|
|
794
942
|
step: stepName,
|
|
795
943
|
noun: 'validation_exhaustion',
|
|
944
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, 'validation_exhaustion', key]),
|
|
796
945
|
}));
|
|
797
946
|
if ('threshold' in exhaustionBlock &&
|
|
798
947
|
(!Number.isInteger(exhaustionBlock['threshold']) ||
|
|
799
948
|
exhaustionBlock['threshold'] < 1)) {
|
|
800
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion.threshold' must be a positive integer ` +
|
|
949
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.threshold' must be a positive integer ` +
|
|
801
950
|
`(1 is legal — it disables in-drive schema-repair, since the first rejection ` +
|
|
802
|
-
`already meets it)`);
|
|
951
|
+
`already meets it)`));
|
|
803
952
|
}
|
|
804
953
|
const modeValue = exhaustionBlock['mode'];
|
|
805
954
|
if (modeValue !== undefined && modeValue !== 'fail' && modeValue !== 'default') {
|
|
806
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion.mode' must be 'fail' or 'default' ` +
|
|
807
|
-
`(got: ${JSON.stringify(modeValue)})`);
|
|
955
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode' must be 'fail' or 'default' ` +
|
|
956
|
+
`(got: ${JSON.stringify(modeValue)})`));
|
|
808
957
|
}
|
|
809
958
|
const hasDefaultOutput = 'default_output' in exhaustionBlock;
|
|
810
959
|
if (modeValue === 'default') {
|
|
811
960
|
if (!hasDefaultOutput) {
|
|
812
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion.mode: default' requires ` +
|
|
813
|
-
`'default_output' (nothing to substitute on exhaustion)`);
|
|
961
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode: default' requires ` +
|
|
962
|
+
`'default_output' (nothing to substitute on exhaustion)`));
|
|
814
963
|
}
|
|
815
964
|
else if (step['output_schema'] === undefined) {
|
|
816
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion.default_output' requires the step to ` +
|
|
817
|
-
`declare 'output_schema' (an undeclared schema makes the default unvalidatable)`);
|
|
965
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' requires the step to ` +
|
|
966
|
+
`declare 'output_schema' (an undeclared schema makes the default unvalidatable)`));
|
|
818
967
|
}
|
|
819
968
|
else {
|
|
820
969
|
// B10 — load-time AJV proof: REUSE the runtime validator so the load-time verdict can
|
|
@@ -836,8 +985,8 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
836
985
|
: err instanceof Error
|
|
837
986
|
? err.message
|
|
838
987
|
: String(err);
|
|
839
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion.default_output' does not validate ` +
|
|
840
|
-
`against the step's own 'output_schema': ${detail}`);
|
|
988
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' does not validate ` +
|
|
989
|
+
`against the step's own 'output_schema': ${detail}`));
|
|
841
990
|
}
|
|
842
991
|
}
|
|
843
992
|
}
|
|
@@ -874,17 +1023,17 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
874
1023
|
}
|
|
875
1024
|
// trace_schema is only valid on execution: agent steps.
|
|
876
1025
|
if (step['trace_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
877
|
-
errors.push(`Step '${stepName}': 'trace_schema' is only valid on execution: agent steps`);
|
|
1026
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_schema' is only valid on execution: agent steps`));
|
|
878
1027
|
}
|
|
879
1028
|
// trace_validation_mode is only valid on execution: agent steps.
|
|
880
1029
|
if (step['trace_validation_mode'] !== undefined && step['execution'] !== 'agent') {
|
|
881
|
-
errors.push(`Step '${stepName}': 'trace_validation_mode' is only valid on execution: agent steps`);
|
|
1030
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_validation_mode' is only valid on execution: agent steps`));
|
|
882
1031
|
}
|
|
883
1032
|
// trace_validation_mode must be 'warn' or 'enforce' when provided.
|
|
884
1033
|
if (step['trace_validation_mode'] !== undefined &&
|
|
885
1034
|
step['trace_validation_mode'] !== 'warn' &&
|
|
886
1035
|
step['trace_validation_mode'] !== 'enforce') {
|
|
887
|
-
errors.push(`Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`);
|
|
1036
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`));
|
|
888
1037
|
}
|
|
889
1038
|
// issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
|
|
890
1039
|
// the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
|
|
@@ -894,7 +1043,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
894
1043
|
// trust value).
|
|
895
1044
|
if (step['gate'] !== undefined) {
|
|
896
1045
|
if (typeof step['gate'] !== 'object' || step['gate'] === null) {
|
|
897
|
-
errors.push(`Step '${stepName}': 'gate' must be an object`);
|
|
1046
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate' must be an object`));
|
|
898
1047
|
}
|
|
899
1048
|
else {
|
|
900
1049
|
const gate = step['gate'];
|
|
@@ -905,25 +1054,26 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
905
1054
|
code: 'UNKNOWN_GATE_KEY',
|
|
906
1055
|
step: stepName,
|
|
907
1056
|
noun: 'gate',
|
|
1057
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, 'gate', key]),
|
|
908
1058
|
}));
|
|
909
1059
|
// E2: timeout_seconds/reminder_seconds/reminder_max must each be a positive integer
|
|
910
1060
|
// (yaml-loader :1164-1174 precedent — the SAME convention as retry.total_timeout_seconds).
|
|
911
1061
|
if ('timeout_seconds' in gate &&
|
|
912
1062
|
(!Number.isInteger(gate['timeout_seconds']) || gate['timeout_seconds'] <= 0)) {
|
|
913
|
-
errors.push(`Step '${stepName}': 'gate.timeout_seconds' must be a positive integer`);
|
|
1063
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.timeout_seconds' must be a positive integer`));
|
|
914
1064
|
}
|
|
915
1065
|
if ('reminder_seconds' in gate &&
|
|
916
1066
|
(!Number.isInteger(gate['reminder_seconds']) || gate['reminder_seconds'] <= 0)) {
|
|
917
|
-
errors.push(`Step '${stepName}': 'gate.reminder_seconds' must be a positive integer`);
|
|
1067
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_seconds' must be a positive integer`));
|
|
918
1068
|
}
|
|
919
1069
|
if ('reminder_max' in gate &&
|
|
920
1070
|
(!Number.isInteger(gate['reminder_max']) || gate['reminder_max'] <= 0)) {
|
|
921
|
-
errors.push(`Step '${stepName}': 'gate.reminder_max' must be a positive integer`);
|
|
1071
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_max' must be a positive integer`));
|
|
922
1072
|
}
|
|
923
1073
|
// on_expiry must be 'settle_default' or 'abort' when provided.
|
|
924
1074
|
const onExpiry = gate['on_expiry'];
|
|
925
1075
|
if (onExpiry !== undefined && onExpiry !== 'settle_default' && onExpiry !== 'abort') {
|
|
926
|
-
errors.push(`Step '${stepName}': 'gate.on_expiry' must be 'settle_default' or 'abort' (got: ${JSON.stringify(onExpiry)})`);
|
|
1076
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry' must be 'settle_default' or 'abort' (got: ${JSON.stringify(onExpiry)})`));
|
|
927
1077
|
}
|
|
928
1078
|
// default_choice: REQUIRED iff on_expiry === 'settle_default' (E2-style hard error,
|
|
929
1079
|
// mirroring validation_exhaustion.mode:'default' requiring default_output); validated
|
|
@@ -934,8 +1084,8 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
934
1084
|
const hasDefaultChoice = 'default_choice' in gate;
|
|
935
1085
|
if (onExpiry === 'settle_default') {
|
|
936
1086
|
if (!hasDefaultChoice) {
|
|
937
|
-
errors.push(`Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
|
|
938
|
-
`(nothing to resolve the gate with on expiry)`);
|
|
1087
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
|
|
1088
|
+
`(nothing to resolve the gate with on expiry)`));
|
|
939
1089
|
}
|
|
940
1090
|
else {
|
|
941
1091
|
const choicesRaw = gate['choices'] ??
|
|
@@ -944,8 +1094,8 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
944
1094
|
? choicesRaw
|
|
945
1095
|
: ['approve', 'reject'];
|
|
946
1096
|
if (!effectiveChoices.includes(gate['default_choice'])) {
|
|
947
|
-
errors.push(`Step '${stepName}': 'gate.default_choice' (${JSON.stringify(gate['default_choice'])}) ` +
|
|
948
|
-
`is not one of the step's effective choices: ${effectiveChoices.join(', ')}`);
|
|
1097
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.default_choice' (${JSON.stringify(gate['default_choice'])}) ` +
|
|
1098
|
+
`is not one of the step's effective choices: ${effectiveChoices.join(', ')}`));
|
|
949
1099
|
}
|
|
950
1100
|
}
|
|
951
1101
|
}
|
|
@@ -995,13 +1145,13 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
995
1145
|
if (typeof services !== 'object' ||
|
|
996
1146
|
services === null ||
|
|
997
1147
|
!(step['uses_service'] in services)) {
|
|
998
|
-
errors.push(`Step '${stepName}': uses_service '${step['uses_service']}' is not defined in 'services'`);
|
|
1148
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': uses_service '${step['uses_service']}' is not defined in 'services'`));
|
|
999
1149
|
}
|
|
1000
1150
|
}
|
|
1001
1151
|
// Validate retry: backoff must be a recognised value when present.
|
|
1002
1152
|
if (step['retry'] !== undefined) {
|
|
1003
1153
|
if (typeof step['retry'] !== 'object' || step['retry'] === null) {
|
|
1004
|
-
errors.push(`Step '${stepName}': 'retry' must be an object`);
|
|
1154
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry' must be an object`));
|
|
1005
1155
|
}
|
|
1006
1156
|
else {
|
|
1007
1157
|
const retry = step['retry'];
|
|
@@ -1013,29 +1163,30 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1013
1163
|
code: 'UNKNOWN_RETRY_KEY',
|
|
1014
1164
|
step: stepName,
|
|
1015
1165
|
noun: 'retry',
|
|
1166
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, 'retry', key]),
|
|
1016
1167
|
}));
|
|
1017
1168
|
if ('backoff' in retry &&
|
|
1018
1169
|
retry['backoff'] !== 'fixed' &&
|
|
1019
1170
|
retry['backoff'] !== 'linear' &&
|
|
1020
1171
|
retry['backoff'] !== 'exponential') {
|
|
1021
|
-
errors.push(`Step '${stepName}': 'retry.backoff' must be 'fixed', 'linear', or 'exponential'`);
|
|
1172
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.backoff' must be 'fixed', 'linear', or 'exponential'`));
|
|
1022
1173
|
}
|
|
1023
1174
|
if ('max_attempts' in retry &&
|
|
1024
1175
|
(!Number.isInteger(retry['max_attempts']) || retry['max_attempts'] < 1)) {
|
|
1025
|
-
errors.push(`Step '${stepName}': 'retry.max_attempts' must be a positive integer`);
|
|
1176
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_attempts' must be a positive integer`));
|
|
1026
1177
|
}
|
|
1027
1178
|
if ('base_delay_ms' in retry &&
|
|
1028
1179
|
(typeof retry['base_delay_ms'] !== 'number' || retry['base_delay_ms'] < 0)) {
|
|
1029
|
-
errors.push(`Step '${stepName}': 'retry.base_delay_ms' must be a non-negative number`);
|
|
1180
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.base_delay_ms' must be a non-negative number`));
|
|
1030
1181
|
}
|
|
1031
1182
|
if ('max_delay_ms' in retry &&
|
|
1032
1183
|
(typeof retry['max_delay_ms'] !== 'number' || retry['max_delay_ms'] < 0)) {
|
|
1033
|
-
errors.push(`Step '${stepName}': 'retry.max_delay_ms' must be a non-negative number`);
|
|
1184
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_delay_ms' must be a non-negative number`));
|
|
1034
1185
|
}
|
|
1035
1186
|
// --- issue #140: on_timeout / total_timeout_seconds --------------------------------
|
|
1036
1187
|
// E3: on_timeout must be a boolean (kills the 'on_timeout: "true"' silent-inert case).
|
|
1037
1188
|
if ('on_timeout' in retry && typeof retry['on_timeout'] !== 'boolean') {
|
|
1038
|
-
errors.push(`Step '${stepName}': 'retry.on_timeout' must be a boolean`);
|
|
1189
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout' must be a boolean`));
|
|
1039
1190
|
}
|
|
1040
1191
|
// E2: total_timeout_seconds must be a positive integer — same convention as
|
|
1041
1192
|
// timeout_seconds (0 is rejected here at load; a hand-built definition bypassing the
|
|
@@ -1043,16 +1194,16 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1043
1194
|
if ('total_timeout_seconds' in retry &&
|
|
1044
1195
|
(!Number.isInteger(retry['total_timeout_seconds']) ||
|
|
1045
1196
|
retry['total_timeout_seconds'] <= 0)) {
|
|
1046
|
-
errors.push(`Step '${stepName}': 'retry.total_timeout_seconds' must be a positive integer`);
|
|
1197
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.total_timeout_seconds' must be a positive integer`));
|
|
1047
1198
|
}
|
|
1048
1199
|
// E1: on_timeout: true requires idempotent: true — declared, never inferred. Strict
|
|
1049
1200
|
// `=== true` on both loci, provably matching the engine's own conjunct.
|
|
1050
1201
|
if (retry['on_timeout'] === true && step['idempotent'] !== true) {
|
|
1051
|
-
errors.push(`Step '${stepName}': 'retry.on_timeout: true' requires 'idempotent: true' declared ` +
|
|
1202
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout: true' requires 'idempotent: true' declared ` +
|
|
1052
1203
|
`on the step — a timeout-retry can run concurrently with the still-in-flight ` +
|
|
1053
1204
|
`original attempt, so the step must explicitly attest that any partial prior ` +
|
|
1054
1205
|
`application is harmless to re-apply. Declare 'idempotent: true' or remove ` +
|
|
1055
|
-
`'on_timeout'.`);
|
|
1206
|
+
`'on_timeout'.`));
|
|
1056
1207
|
}
|
|
1057
1208
|
// W5 (CAP-ONLY advisory — the on_timeout half of this is already an E1 hard error, so
|
|
1058
1209
|
// it never reaches here as a warning): the total-time cap only bounds `execution: 'auto'`
|
|
@@ -1142,15 +1293,22 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1142
1293
|
}
|
|
1143
1294
|
}
|
|
1144
1295
|
if ('service_method' in step && !VALID_SERVICE_METHODS.has(step['service_method'])) {
|
|
1145
|
-
errors.push(`Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`);
|
|
1296
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`));
|
|
1146
1297
|
}
|
|
1147
1298
|
// Validate input_map: only valid on execution: auto steps (both uses_service and handler).
|
|
1148
1299
|
if (step['input_map'] !== undefined) {
|
|
1149
1300
|
if (step['execution'] !== 'auto') {
|
|
1150
|
-
errors.push(`Step '${stepName}': 'input_map' is only valid on execution: auto steps`);
|
|
1301
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'input_map' is only valid on execution: auto steps`));
|
|
1151
1302
|
}
|
|
1152
1303
|
else {
|
|
1153
|
-
|
|
1304
|
+
// issue #392: input_map's errors are minted deep inside a recursive walk that knows only
|
|
1305
|
+
// its path string, not the step's position. Collected here and suffixed on the way out,
|
|
1306
|
+
// so ONE step's error list never mixes positioned and bare messages — a reader seeing
|
|
1307
|
+
// "(line 12)" on three of five errors would reasonably wonder what is different about
|
|
1308
|
+
// the other two, and nothing is.
|
|
1309
|
+
const inputMapErrors = [];
|
|
1310
|
+
validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
|
|
1311
|
+
errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
|
|
1154
1312
|
}
|
|
1155
1313
|
}
|
|
1156
1314
|
// Step config may hold any JSON value (scalars, arrays, nested objects). It is passed through
|
|
@@ -1164,14 +1322,14 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1164
1322
|
const adapterName = service?.['adapter'];
|
|
1165
1323
|
const adapter = adapterName !== undefined ? registry?.getAdapter(adapterName) : undefined;
|
|
1166
1324
|
if (adapter !== undefined && adapter.config_schema === undefined) {
|
|
1167
|
-
errors.push(`Step '${stepName}': 'config' declared but adapter '${adapterName}' does not declare 'config_schema'`);
|
|
1325
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'config' declared but adapter '${adapterName}' does not declare 'config_schema'`));
|
|
1168
1326
|
}
|
|
1169
1327
|
else if (adapter?.config_schema !== undefined) {
|
|
1170
1328
|
const ajv = new Ajv();
|
|
1171
1329
|
const valid = ajv.validate(adapter.config_schema, step['config']);
|
|
1172
1330
|
if (!valid) {
|
|
1173
1331
|
const errMessages = ajv.errors?.map((e) => e.message ?? '').join('; ') ?? 'unknown error';
|
|
1174
|
-
errors.push(`Step '${stepName}': config validation failed against adapter config_schema: ${errMessages}`);
|
|
1332
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': config validation failed against adapter config_schema: ${errMessages}`));
|
|
1175
1333
|
}
|
|
1176
1334
|
}
|
|
1177
1335
|
}
|
|
@@ -1182,8 +1340,8 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1182
1340
|
if (handler !== undefined && handler.uses_resources !== undefined) {
|
|
1183
1341
|
for (const resourceStepId of handler.uses_resources) {
|
|
1184
1342
|
if (!(resourceStepId in stepsRaw)) {
|
|
1185
|
-
errors.push(`Step '${stepName}': handler '${handlerName}' declares uses_resources '${resourceStepId}' ` +
|
|
1186
|
-
`but no step with that ID exists in this workflow`);
|
|
1343
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': handler '${handlerName}' declares uses_resources '${resourceStepId}' ` +
|
|
1344
|
+
`but no step with that ID exists in this workflow`));
|
|
1187
1345
|
}
|
|
1188
1346
|
}
|
|
1189
1347
|
}
|
|
@@ -1191,31 +1349,31 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1191
1349
|
// Validate trigger_rule.
|
|
1192
1350
|
if ('trigger_rule' in step) {
|
|
1193
1351
|
if (!VALID_TRIGGER_RULES.has(step['trigger_rule'])) {
|
|
1194
|
-
errors.push(`Step '${stepName}': invalid trigger_rule '${String(step['trigger_rule'])}'; must be one of ${[...VALID_TRIGGER_RULES].join(', ')}`);
|
|
1352
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid trigger_rule '${String(step['trigger_rule'])}'; must be one of ${[...VALID_TRIGGER_RULES].join(', ')}`));
|
|
1195
1353
|
}
|
|
1196
1354
|
}
|
|
1197
1355
|
// Validate depends_on: must be an array of existing step names.
|
|
1198
1356
|
if ('depends_on' in step && step['depends_on'] !== undefined) {
|
|
1199
1357
|
if (!Array.isArray(step['depends_on'])) {
|
|
1200
|
-
errors.push(`Step '${stepName}': 'depends_on' must be an array`);
|
|
1358
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'depends_on' must be an array`));
|
|
1201
1359
|
}
|
|
1202
1360
|
else {
|
|
1203
1361
|
for (const dep of step['depends_on']) {
|
|
1204
1362
|
if (typeof dep !== 'string') {
|
|
1205
|
-
errors.push(`Step '${stepName}': depends_on entries must be strings`);
|
|
1363
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': depends_on entries must be strings`));
|
|
1206
1364
|
}
|
|
1207
1365
|
else if (dep === stepName) {
|
|
1208
|
-
errors.push(`Step '${stepName}': a step cannot depend on itself`);
|
|
1366
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': a step cannot depend on itself`));
|
|
1209
1367
|
}
|
|
1210
1368
|
else if (!(dep in stepsRaw)) {
|
|
1211
|
-
errors.push(`Step '${stepName}': depends_on references unknown step '${dep}'`);
|
|
1369
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references unknown step '${dep}'`));
|
|
1212
1370
|
}
|
|
1213
1371
|
else if (stepsRaw[dep]['execution'] === 'finalizer') {
|
|
1214
1372
|
// A domain step depending on a held-out finalizer would deadlock: the finalizer
|
|
1215
1373
|
// never enters the eligible set, so this step never becomes eligible and the run
|
|
1216
1374
|
// never seals.
|
|
1217
|
-
errors.push(`Step '${stepName}': depends_on references finalizer step '${dep}' — finalizers ` +
|
|
1218
|
-
`run at the terminal transition and are held out of the DAG; a step cannot depend on one.`);
|
|
1375
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references finalizer step '${dep}' — finalizers ` +
|
|
1376
|
+
`run at the terminal transition and are held out of the DAG; a step cannot depend on one.`));
|
|
1219
1377
|
}
|
|
1220
1378
|
}
|
|
1221
1379
|
}
|
|
@@ -1225,29 +1383,29 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1225
1383
|
const rawWhen = step['when'];
|
|
1226
1384
|
if (typeof rawWhen === 'string') {
|
|
1227
1385
|
if (rawWhen.trim() === '') {
|
|
1228
|
-
errors.push(`Step '${stepName}': 'when' must be a non-empty string`);
|
|
1386
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a non-empty string`));
|
|
1229
1387
|
}
|
|
1230
1388
|
else {
|
|
1231
|
-
validateConditionLeaf('when', rawWhen, stepName, dependsOn, errors);
|
|
1389
|
+
validateConditionLeaf('when', rawWhen, stepName, dependsOn, errors, withStepLine);
|
|
1232
1390
|
}
|
|
1233
1391
|
}
|
|
1234
1392
|
else if (Array.isArray(rawWhen)) {
|
|
1235
1393
|
if (rawWhen.length === 0) {
|
|
1236
|
-
errors.push(`Step '${stepName}': 'when' array must not be empty`);
|
|
1394
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array must not be empty`));
|
|
1237
1395
|
}
|
|
1238
1396
|
else {
|
|
1239
1397
|
for (const leaf of rawWhen) {
|
|
1240
1398
|
if (typeof leaf !== 'string' || leaf.trim() === '') {
|
|
1241
|
-
errors.push(`Step '${stepName}': 'when' array entries must be non-empty strings`);
|
|
1399
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array entries must be non-empty strings`));
|
|
1242
1400
|
}
|
|
1243
1401
|
else {
|
|
1244
|
-
validateConditionLeaf('when', leaf, stepName, dependsOn, errors);
|
|
1402
|
+
validateConditionLeaf('when', leaf, stepName, dependsOn, errors, withStepLine);
|
|
1245
1403
|
}
|
|
1246
1404
|
}
|
|
1247
1405
|
}
|
|
1248
1406
|
}
|
|
1249
1407
|
else {
|
|
1250
|
-
errors.push(`Step '${stepName}': 'when' must be a string or an array of strings`);
|
|
1408
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a string or an array of strings`));
|
|
1251
1409
|
}
|
|
1252
1410
|
}
|
|
1253
1411
|
// Validate abort_unless leaf shape (guard steps only; the LEGACY depends_on/run.params
|
|
@@ -1257,29 +1415,29 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1257
1415
|
const rawAbort = step['abort_unless'];
|
|
1258
1416
|
if (typeof rawAbort === 'string') {
|
|
1259
1417
|
if (rawAbort.trim() === '') {
|
|
1260
|
-
errors.push(`Step '${stepName}': 'abort_unless' must be a non-empty string`);
|
|
1418
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a non-empty string`));
|
|
1261
1419
|
}
|
|
1262
1420
|
else {
|
|
1263
|
-
validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, errors);
|
|
1421
|
+
validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, errors, withStepLine);
|
|
1264
1422
|
}
|
|
1265
1423
|
}
|
|
1266
1424
|
else if (Array.isArray(rawAbort)) {
|
|
1267
1425
|
if (rawAbort.length === 0) {
|
|
1268
|
-
errors.push(`Step '${stepName}': 'abort_unless' array must not be empty`);
|
|
1426
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array must not be empty`));
|
|
1269
1427
|
}
|
|
1270
1428
|
else {
|
|
1271
1429
|
for (const leaf of rawAbort) {
|
|
1272
1430
|
if (typeof leaf !== 'string' || leaf.trim() === '') {
|
|
1273
|
-
errors.push(`Step '${stepName}': 'abort_unless' array entries must be non-empty strings`);
|
|
1431
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array entries must be non-empty strings`));
|
|
1274
1432
|
}
|
|
1275
1433
|
else {
|
|
1276
|
-
validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, errors);
|
|
1434
|
+
validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, errors, withStepLine);
|
|
1277
1435
|
}
|
|
1278
1436
|
}
|
|
1279
1437
|
}
|
|
1280
1438
|
}
|
|
1281
1439
|
else {
|
|
1282
|
-
errors.push(`Step '${stepName}': 'abort_unless' must be a string or an array of strings`);
|
|
1440
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a string or an array of strings`));
|
|
1283
1441
|
}
|
|
1284
1442
|
}
|
|
1285
1443
|
// Validate preconditions leaf shape (each must be a single comparison). Reference check is
|
|
@@ -1288,15 +1446,132 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1288
1446
|
if (step['preconditions'] !== undefined) {
|
|
1289
1447
|
const rawPre = step['preconditions'];
|
|
1290
1448
|
if (!Array.isArray(rawPre)) {
|
|
1291
|
-
errors.push(`Step '${stepName}': 'preconditions' must be an array of strings`);
|
|
1449
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' must be an array of strings`));
|
|
1292
1450
|
}
|
|
1293
1451
|
else {
|
|
1294
1452
|
for (const leaf of rawPre) {
|
|
1295
1453
|
if (typeof leaf !== 'string' || leaf.trim() === '') {
|
|
1296
|
-
errors.push(`Step '${stepName}': 'preconditions' entries must be non-empty strings`);
|
|
1454
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' entries must be non-empty strings`));
|
|
1297
1455
|
}
|
|
1298
1456
|
else {
|
|
1299
|
-
validateConditionLeaf('preconditions', leaf, stepName, dependsOn, errors);
|
|
1457
|
+
validateConditionLeaf('preconditions', leaf, stepName, dependsOn, errors, withStepLine);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
// issue #362 — REJECT A PROVABLY-DEAD FAILURE CONDITION.
|
|
1463
|
+
//
|
|
1464
|
+
// `$settlement.<dep>.failed == true` under a trigger rule that structurally excludes a failed
|
|
1465
|
+
// `<dep>` can never be true. The trigger gate runs BEFORE the condition gate, and both
|
|
1466
|
+
// `all_success` and `none_failed` carry an explicit "no dep in failed_steps" conjunct — so if
|
|
1467
|
+
// the rule is satisfied, `<dep>` did not fail, and the condition is false by construction.
|
|
1468
|
+
//
|
|
1469
|
+
// The author's compensation step therefore never runs. It is not silent at runtime — the run
|
|
1470
|
+
// record says `trigger_rule_unsatisfiable` — but it names the RULE and the blocking dep, never
|
|
1471
|
+
// the condition the author wrote, so the diagnosis points away from the mistake. This is an
|
|
1472
|
+
// authoring-time error precisely because the fix is one word and no legitimate use of the
|
|
1473
|
+
// shape exists.
|
|
1474
|
+
dead_condition: {
|
|
1475
|
+
const rule = step['execution'] === 'guard' ? 'all_success' : (step['trigger_rule'] ?? 'all_success');
|
|
1476
|
+
// A guard may not declare `trigger_rule` at all (it is a prohibited field), so a guard that
|
|
1477
|
+
// declares one must NOT be able to suppress this check by doing so.
|
|
1478
|
+
const ruleDeclared = step['execution'] !== 'guard' && step['trigger_rule'] !== undefined;
|
|
1479
|
+
if (step['execution'] !== 'guard' && !VALID_TRIGGER_RULES.has(rule)) {
|
|
1480
|
+
break dead_condition; // an invalid rule already has its own error — adding noise helps nobody
|
|
1481
|
+
}
|
|
1482
|
+
if (rule !== 'all_success' && rule !== 'none_failed')
|
|
1483
|
+
break dead_condition;
|
|
1484
|
+
const asLeaves = (v) => Array.isArray(v)
|
|
1485
|
+
? v.filter((x) => typeof x === 'string')
|
|
1486
|
+
: typeof v === 'string'
|
|
1487
|
+
? [v]
|
|
1488
|
+
: [];
|
|
1489
|
+
const surfaces = [
|
|
1490
|
+
{ surface: 'when', leaves: asLeaves(step['when']) },
|
|
1491
|
+
// `abort_unless` is only validated (and only meaningful) on guards; on anything else the
|
|
1492
|
+
// loader leaves it alone and the engine never reads it.
|
|
1493
|
+
{
|
|
1494
|
+
surface: 'abort_unless',
|
|
1495
|
+
leaves: step['execution'] === 'guard' ? asLeaves(step['abort_unless']) : [],
|
|
1496
|
+
},
|
|
1497
|
+
// `preconditions` is collected for EVERY step kind, but it is INERT on a guard: the sole
|
|
1498
|
+
// `checkPreconditions` call site is `executeStep` (execution-loop.ts:1380), and a guard
|
|
1499
|
+
// goes through `executeGuardStep`, which evaluates only `abort_unless`. That is why the
|
|
1500
|
+
// guard arm's consequence below is forked — collapsing it back into one shared string
|
|
1501
|
+
// would make the error claim a wedge that cannot happen.
|
|
1502
|
+
//
|
|
1503
|
+
// Post-#369 a guard declaring `preconditions` is REFUSED outright by the guard block
|
|
1504
|
+
// above, so this arm now only ever fires ALONGSIDE that refusal: errors accumulate rather
|
|
1505
|
+
// than short-circuit, and the guard block runs first, so both messages reach the author
|
|
1506
|
+
// with the prohibition printed above this one. The arm is kept, not deleted — it is what
|
|
1507
|
+
// stops the dead-condition message from claiming a wedge that a guard cannot have, and a
|
|
1508
|
+
// definition reaching this code by any path other than a fresh YAML load (a
|
|
1509
|
+
// store-registered definition, an inline object) is never re-parsed and never sees the
|
|
1510
|
+
// prohibition at all.
|
|
1511
|
+
{ surface: 'preconditions', leaves: asLeaves(step['preconditions']) },
|
|
1512
|
+
];
|
|
1513
|
+
for (const { surface, leaves } of surfaces) {
|
|
1514
|
+
for (const leaf of leaves) {
|
|
1515
|
+
const split = splitComparison(leaf);
|
|
1516
|
+
// Two spellings are equally dead: the explicit `== true`, and the BARE PATH, which the
|
|
1517
|
+
// engine coerces with `Boolean()`. `preconditions` refuses bare paths anyway.
|
|
1518
|
+
const lhsPath = split.kind === 'comparison' && split.op === '==' && split.rhsRaw.trim() === 'true'
|
|
1519
|
+
? split.lhsPath
|
|
1520
|
+
: split.kind === 'path'
|
|
1521
|
+
? split.path
|
|
1522
|
+
: undefined;
|
|
1523
|
+
if (lhsPath === undefined)
|
|
1524
|
+
continue;
|
|
1525
|
+
// EXACTLY three segments. `$settlement.x.failed.deep` is also dead, but for a different
|
|
1526
|
+
// reason, so the trigger-rule remedy would be wrong advice there.
|
|
1527
|
+
const segments = lhsPath.trim().split('.');
|
|
1528
|
+
if (segments.length !== 3 || segments[0] !== '$settlement' || segments[2] !== 'failed') {
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
const dep = segments[1];
|
|
1532
|
+
// Without the dep actually being a dependency, the one-hop error fires on its own AND
|
|
1533
|
+
// the trigger gate returns true unconditionally for a step with no deps — so the leaf
|
|
1534
|
+
// is not dead-by-trigger here and the remedy would be false advice.
|
|
1535
|
+
if (!dependsOn.includes(dep))
|
|
1536
|
+
continue;
|
|
1537
|
+
const ruleText = ruleDeclared ? `'${rule}'` : `the default '${rule}'`;
|
|
1538
|
+
const guardPrecondition = step['execution'] === 'guard' && surface === 'preconditions';
|
|
1539
|
+
const consequence = surface === 'when'
|
|
1540
|
+
? `if '${dep}' fails the step is skipped as trigger_rule_unsatisfiable before the condition is evaluated; if '${dep}' succeeds the condition evaluates to false (when_false) — either way the step never runs`
|
|
1541
|
+
: surface === 'preconditions'
|
|
1542
|
+
? guardPrecondition
|
|
1543
|
+
? // NOT the wedge: an unevaluated condition cannot block anything.
|
|
1544
|
+
`the run behaves identically whether this condition is present or absent`
|
|
1545
|
+
: `the step never settles — the run WEDGES in a blocked envelope`
|
|
1546
|
+
: `the guard aborts the run on every execution`;
|
|
1547
|
+
if (step['execution'] === 'guard') {
|
|
1548
|
+
// Guards cannot declare a trigger rule, so the trigger-rule remedy is wrong advice
|
|
1549
|
+
// here. This is a v1 SCOPE narrowing, not an architectural statement — issue #366
|
|
1550
|
+
// carries the design question.
|
|
1551
|
+
//
|
|
1552
|
+
// The middle clause forks with the consequence: "by the time this is evaluated" is
|
|
1553
|
+
// itself false for `preconditions`, which a guard never evaluates at all.
|
|
1554
|
+
const cause = guardPrecondition
|
|
1555
|
+
? `and on an execution: guard step it is never evaluated at all: a guard evaluates ` +
|
|
1556
|
+
`only 'abort_unless', so this condition is inert (${consequence})`
|
|
1557
|
+
: `a guard runs under ${ruleText} and 'trigger_rule' is not a valid field on ` +
|
|
1558
|
+
`execution: guard steps, so '${dep}' has always succeeded by the time this is ` +
|
|
1559
|
+
`evaluated (${consequence})`;
|
|
1560
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — ${cause}. ` +
|
|
1561
|
+
`Guards run only when their dependencies succeeded; for work that must happen AFTER a ` +
|
|
1562
|
+
`failure, use an 'execution: finalizer' step (see issue #366 for widening guards).`));
|
|
1563
|
+
}
|
|
1564
|
+
else {
|
|
1565
|
+
const remedies = ['all_done', 'one_failed'];
|
|
1566
|
+
if (new Set(dependsOn).size === 1)
|
|
1567
|
+
remedies.push('all_failed');
|
|
1568
|
+
const tail = new Set(dependsOn).size > 1
|
|
1569
|
+
? ` ('all_failed' fires only if EVERY dependency fails; 'one_success' only if at least one other dependency succeeds.)`
|
|
1570
|
+
: '';
|
|
1571
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — under ` +
|
|
1572
|
+
`${ruleText} trigger rule, '${dep}' can never be in failed_steps when this step is ` +
|
|
1573
|
+
`evaluated (${consequence}). To run this step when '${dep}' fails, set trigger_rule to ` +
|
|
1574
|
+
`one of: ${remedies.join(', ')}.${tail}`));
|
|
1300
1575
|
}
|
|
1301
1576
|
}
|
|
1302
1577
|
}
|
|
@@ -1304,20 +1579,54 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1304
1579
|
// Validate tools: only valid on execution: agent steps without handler.
|
|
1305
1580
|
if (step['tools'] !== undefined &&
|
|
1306
1581
|
(step['execution'] !== 'agent' || step['handler'] !== undefined)) {
|
|
1307
|
-
errors.push(`Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`);
|
|
1582
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`));
|
|
1583
|
+
}
|
|
1584
|
+
// issue #413: `tool_timeout` requires `tools`. It bounds ONE tool call inside the agentic
|
|
1585
|
+
// loop (run-agent.ts), and a step with no tools never enters that loop — so the key sits
|
|
1586
|
+
// there bounding nothing while its author believes tool calls are capped.
|
|
1587
|
+
//
|
|
1588
|
+
// An EMPTY list counts as missing, and that is not pedantry: run-agent gates the tools path
|
|
1589
|
+
// on `tools.length > 0`, so `tools: []` is exactly as toolless at runtime as no key at all.
|
|
1590
|
+
// This one helper is also the shape check's complement further down, which is what makes
|
|
1591
|
+
// "exactly one error" true by construction rather than by coincidence.
|
|
1592
|
+
//
|
|
1593
|
+
// NOT extended to non-array `tools` spellings — that is #391, still open. Under the
|
|
1594
|
+
// `!toolsMissing` complement below, a non-array `tools` still lets the shape check fire, so
|
|
1595
|
+
// nothing is silently exempted here.
|
|
1596
|
+
const toolsMissing = step['tools'] === undefined ||
|
|
1597
|
+
(Array.isArray(step['tools']) && step['tools'].length === 0);
|
|
1598
|
+
if (step['tool_timeout'] !== undefined && toolsMissing) {
|
|
1599
|
+
errors.push(withKeyLine(stepName, 'tool_timeout', `Step '${stepName}': 'tool_timeout' requires 'tools' (a declared, non-empty list) — ` +
|
|
1600
|
+
'without tool calls there is ' +
|
|
1601
|
+
'nothing for it to bound, so the step would carry a bound with nothing to bind. ' +
|
|
1602
|
+
"In realm's own drive each tool call is capped at tool_timeout seconds (default " +
|
|
1603
|
+
'30); declare at least one tool or remove the key.'));
|
|
1308
1604
|
}
|
|
1309
1605
|
// Validate tools: requires input_schema.
|
|
1310
1606
|
if (step['tools'] !== undefined && step['input_schema'] === undefined) {
|
|
1311
|
-
errors.push(`Step '${stepName}': 'tools' requires 'input_schema' to be defined — the agentic loop needs a schema for final output extraction`);
|
|
1607
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' requires 'input_schema' to be defined — the agentic loop needs a schema for final output extraction`));
|
|
1312
1608
|
}
|
|
1313
1609
|
// Validate tools: entries must be in server_id:tool_name format.
|
|
1314
1610
|
if (step['tools'] !== undefined && Array.isArray(step['tools'])) {
|
|
1315
1611
|
for (const entry of step['tools']) {
|
|
1316
1612
|
if (!/^[^:]+:[^:]+$/.test(entry)) {
|
|
1317
|
-
errors.push(`Step '${stepName}': tools entry '${entry}' must be in 'server_id:tool_name' format`);
|
|
1613
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' must be in 'server_id:tool_name' format`));
|
|
1318
1614
|
}
|
|
1319
1615
|
}
|
|
1320
1616
|
}
|
|
1617
|
+
// issue #338: the check below only runs when an `mcp_servers` block EXISTS, so the absent-block
|
|
1618
|
+
// variant loaded clean — and every disclosure this loader has for tools lives inside that same
|
|
1619
|
+
// fork, so the corner produced no error, no warning, and a run where the declared tools were
|
|
1620
|
+
// simply never offered. ONE error per step, not one per entry: the entries are not individually
|
|
1621
|
+
// wrong, the workflow is.
|
|
1622
|
+
if (step['tools'] !== undefined &&
|
|
1623
|
+
Array.isArray(step['tools']) &&
|
|
1624
|
+
step['tools'].length > 0 &&
|
|
1625
|
+
!Array.isArray(doc['mcp_servers'])) {
|
|
1626
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': declares tools but the workflow defines no mcp_servers — no drive ` +
|
|
1627
|
+
`can ever offer these tools, so the declaration can never be satisfied. Define an ` +
|
|
1628
|
+
`mcp_servers block, or remove 'tools'.`));
|
|
1629
|
+
}
|
|
1321
1630
|
// Validate tools: server_id must reference a defined mcp_server.
|
|
1322
1631
|
if (step['tools'] !== undefined &&
|
|
1323
1632
|
Array.isArray(step['tools']) &&
|
|
@@ -1326,33 +1635,43 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1326
1635
|
for (const entry of step['tools']) {
|
|
1327
1636
|
const serverId = entry.split(':')[0] ?? '';
|
|
1328
1637
|
if (!serverIds.has(serverId)) {
|
|
1329
|
-
errors.push(`Step '${stepName}': tools entry '${entry}' references unknown MCP server '${serverId}'`);
|
|
1638
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' references unknown MCP server '${serverId}'`));
|
|
1330
1639
|
}
|
|
1331
1640
|
}
|
|
1332
1641
|
}
|
|
1333
1642
|
// Validate max_tool_calls: must be a positive integer.
|
|
1334
1643
|
if (step['max_tool_calls'] !== undefined &&
|
|
1335
1644
|
(!Number.isInteger(step['max_tool_calls']) || step['max_tool_calls'] <= 0)) {
|
|
1336
|
-
errors.push(`Step '${stepName}': 'max_tool_calls' must be a positive integer`);
|
|
1645
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'max_tool_calls' must be a positive integer`));
|
|
1337
1646
|
}
|
|
1338
1647
|
// Validate max_fan_out: must be a positive integer.
|
|
1339
1648
|
if (step['max_fan_out'] !== undefined &&
|
|
1340
1649
|
(!Number.isInteger(step['max_fan_out']) || step['max_fan_out'] <= 0)) {
|
|
1341
|
-
errors.push(`Step '${stepName}': 'max_fan_out' must be a positive integer`);
|
|
1342
|
-
}
|
|
1343
|
-
// Validate tool_timeout: must be a positive integer.
|
|
1650
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'max_fan_out' must be a positive integer`));
|
|
1651
|
+
}
|
|
1652
|
+
// Validate tool_timeout: must be a positive integer. Skipped where the key is not valid at
|
|
1653
|
+
// all (issue #413's requires-tools check above already reported that) — the same convention
|
|
1654
|
+
// as `timeout_seconds` below: an author told BOTH that the key does not belong here and that
|
|
1655
|
+
// its value has the wrong shape is being pointed at the shape, which is not the problem.
|
|
1656
|
+
// The `!toolsMissing` complement is the SAME helper the prohibition keys on, so the two are
|
|
1657
|
+
// exhaustive and disjoint by construction: `tools: []` with a negative value reports once.
|
|
1344
1658
|
if (step['tool_timeout'] !== undefined &&
|
|
1659
|
+
!toolsMissing &&
|
|
1345
1660
|
(!Number.isInteger(step['tool_timeout']) || step['tool_timeout'] <= 0)) {
|
|
1346
|
-
errors.push(`Step '${stepName}': 'tool_timeout' must be a positive integer`);
|
|
1661
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'tool_timeout' must be a positive integer`));
|
|
1347
1662
|
}
|
|
1348
1663
|
// Validate timeout_seconds: must be a positive integer (issue A3). Skipped on
|
|
1349
1664
|
// execution: guard — the guard-prohibited-fields check above already flatly rejects
|
|
1350
1665
|
// 'timeout_seconds' there ('is not valid on execution: guard steps'); re-checking its
|
|
1351
1666
|
// shape here would double-report the same root cause under a second, confusing message.
|
|
1667
|
+
// Same suppression for the agent prohibition (issue #402), for the same reason: an author
|
|
1668
|
+
// told BOTH that the key is invalid here and that its value has the wrong shape is being
|
|
1669
|
+
// pointed at the shape, which is not the problem.
|
|
1352
1670
|
if (step['timeout_seconds'] !== undefined &&
|
|
1353
1671
|
step['execution'] !== 'guard' &&
|
|
1672
|
+
step['execution'] !== 'agent' &&
|
|
1354
1673
|
(!Number.isInteger(step['timeout_seconds']) || step['timeout_seconds'] <= 0)) {
|
|
1355
|
-
errors.push(`Step '${stepName}': 'timeout_seconds' must be a positive integer`);
|
|
1674
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'timeout_seconds' must be a positive integer`));
|
|
1356
1675
|
}
|
|
1357
1676
|
}
|
|
1358
1677
|
// Require at least one non-finalizer step: a workflow of only finalizers is meaningless
|