@sensigo/realm 0.39.0 → 0.41.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/apply-resume.d.ts.map +1 -1
- package/dist/engine/apply-resume.js +5 -1
- package/dist/engine/apply-resume.js.map +1 -1
- package/dist/engine/execution-loop.d.ts.map +1 -1
- package/dist/engine/execution-loop.js +8 -3
- 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/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- 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 +19 -2
- package/dist/store/json-file-store.d.ts.map +1 -1
- package/dist/store/json-file-store.js +57 -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/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 +78 -0
- package/dist/types/run-record.d.ts.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 +44 -0
- package/dist/types/workflow-error.d.ts.map +1 -1
- package/dist/types/workflow-error.js +30 -1
- package/dist/types/workflow-error.js.map +1 -1
- package/dist/workflow/diagnostics.d.ts +51 -13
- package/dist/workflow/diagnostics.d.ts.map +1 -1
- package/dist/workflow/diagnostics.js +50 -32
- package/dist/workflow/diagnostics.js.map +1 -1
- package/dist/workflow/registrar.d.ts +26 -0
- package/dist/workflow/registrar.d.ts.map +1 -1
- package/dist/workflow/registrar.js +35 -5
- package/dist/workflow/registrar.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 +17 -0
- package/dist/workflow/yaml-loader.d.ts.map +1 -1
- package/dist/workflow/yaml-loader.js +1386 -1087
- 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. */
|
|
@@ -225,13 +247,39 @@ export function findTrustRoot(dir) {
|
|
|
225
247
|
current = parent;
|
|
226
248
|
}
|
|
227
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* issue #424 — attaches the live loader warnings to an error on its way out.
|
|
252
|
+
*
|
|
253
|
+
* Two chokepoints call this (one in `parseWorkflowString`, one in `loadWorkflowFromFileCore`),
|
|
254
|
+
* which is why it exists rather than each throw site building its own error with a `warnings`
|
|
255
|
+
* option: there are eleven throw sites across this file plus four more in template-resolver.ts,
|
|
256
|
+
* the warnings array is out of scope at most of them, and a chokepoint covers every future one
|
|
257
|
+
* for free. The non-empty guard makes the classification automatic — a throw that happens before
|
|
258
|
+
* any warning could exist attaches nothing, by construction rather than by a rule someone has to
|
|
259
|
+
* remember.
|
|
260
|
+
*
|
|
261
|
+
* Attach-once: an inner chokepoint's attachment survives the outer one re-catching the same
|
|
262
|
+
* error, so a file-based load reports the warnings from the parse that produced it rather than
|
|
263
|
+
* an emptier outer set.
|
|
264
|
+
*/
|
|
265
|
+
export function attachLoaderWarnings(err, warnings) {
|
|
266
|
+
if (warnings.length === 0)
|
|
267
|
+
return;
|
|
268
|
+
if (err.warnings !== undefined)
|
|
269
|
+
return;
|
|
270
|
+
err.warnings = warnings;
|
|
271
|
+
}
|
|
228
272
|
/**
|
|
229
273
|
* Pure core of loadWorkflowFromFile (issue #169): parses + resolves everything a file-based load
|
|
230
274
|
* needs, but never prints and never chooses between the two public presentations — it always
|
|
231
275
|
* returns the definition alongside every collected LoaderWarning. `loadWorkflowFromFile` (prints
|
|
232
|
-
* via renderLoaderWarning, returns just the definition
|
|
233
|
-
*
|
|
234
|
-
*
|
|
276
|
+
* via renderLoaderWarning, returns just the definition) and `loadWorkflowFromFileWithDiagnostics`
|
|
277
|
+
* (prints nothing, returns both) are both thin wrappers over this.
|
|
278
|
+
*
|
|
279
|
+
* The #169-era "byte-identical default behavior" claim was retired in issue #444 (2026-08-31):
|
|
280
|
+
* renderLoaderWarning now prefixes `⚠ ` for every code, so this printer's advisory lines gained
|
|
281
|
+
* the prefix they lacked. The SHAPE of the contract is unchanged — one printing wrapper, one
|
|
282
|
+
* silent one — and the text after the prefix is untouched.
|
|
235
283
|
* @throws WorkflowError on read failure or structural validation errors.
|
|
236
284
|
*/
|
|
237
285
|
function loadWorkflowFromFileCore(filePath, registry) {
|
|
@@ -251,107 +299,124 @@ function loadWorkflowFromFileCore(filePath, registry) {
|
|
|
251
299
|
const { definition, warnings } = parseWorkflowString(content, registry, {
|
|
252
300
|
allowExtensions: true,
|
|
253
301
|
});
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
|
|
293
|
-
if (!VALID_WRAPPER_FORMATS.has(definition.context_wrapper)) {
|
|
294
|
-
throw new WorkflowError(`Invalid context_wrapper '${String(definition.context_wrapper)}'; must be 'xml', 'brackets', or 'none'`, {
|
|
302
|
+
// issue #424 — CHOKEPOINT 2. Everything past the parse can throw with `warnings` already
|
|
303
|
+
// populated (a missing agent_profile, a bad workflow_context source), and those warnings used
|
|
304
|
+
// to unwind with the stack: `register` on a file with a typo AND a missing profile printed the
|
|
305
|
+
// profile error alone, so the author fixed it, re-ran, and only then learned about the typo.
|
|
306
|
+
// The parse call itself is deliberately OUTSIDE this try — chokepoint 1 inside
|
|
307
|
+
// `parseWorkflowString` owns those throws and has already attached, and attach-once means its
|
|
308
|
+
// richer set survives.
|
|
309
|
+
try {
|
|
310
|
+
// Resolve agent profiles — only possible when we have a file path.
|
|
311
|
+
const workflowDir = dirname(resolve(filePath));
|
|
312
|
+
const profilesDir = definition.profiles_dir !== undefined
|
|
313
|
+
? resolve(workflowDir, definition.profiles_dir)
|
|
314
|
+
: join(workflowDir, 'profiles');
|
|
315
|
+
const resolvedProfiles = {};
|
|
316
|
+
const profileErrors = [];
|
|
317
|
+
for (const [stepName, step] of Object.entries(definition.steps)) {
|
|
318
|
+
if (step.agent_profile === undefined)
|
|
319
|
+
continue;
|
|
320
|
+
const profileName = step.agent_profile;
|
|
321
|
+
if (profileName in resolvedProfiles)
|
|
322
|
+
continue;
|
|
323
|
+
const profilePath = join(profilesDir, `${profileName}.md`);
|
|
324
|
+
let profileContent;
|
|
325
|
+
try {
|
|
326
|
+
profileContent = readFileSync(profilePath, 'utf8');
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
profileErrors.push(`Step '${stepName}': agent_profile '${profileName}' not found. Searched: ${profilePath}`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const contentHash = createHash('sha256').update(profileContent).digest('hex');
|
|
333
|
+
resolvedProfiles[profileName] = { content: profileContent, content_hash: contentHash };
|
|
334
|
+
}
|
|
335
|
+
if (profileErrors.length > 0) {
|
|
336
|
+
throw new WorkflowError(`Invalid workflow: ${profileErrors.join('; ')}`, {
|
|
337
|
+
// issue #425: the pre-join strings, so a render can list them one per line. Two missing
|
|
338
|
+
// profiles are two problems, not one long sentence.
|
|
339
|
+
errors: [...profileErrors],
|
|
295
340
|
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
296
341
|
category: 'VALIDATION',
|
|
297
342
|
agentAction: 'report_to_user',
|
|
298
343
|
retryable: false,
|
|
299
344
|
});
|
|
300
345
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
agentAction: 'report_to_user',
|
|
310
|
-
retryable: false,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
if (!/^[\w.]+$/.test(name)) {
|
|
314
|
-
throw new WorkflowError(`workflow_context entry name '${name}' is invalid; names must match [\\w.]+ (underscores and dots only — no hyphens)`, {
|
|
346
|
+
if (Object.keys(resolvedProfiles).length > 0) {
|
|
347
|
+
definition.resolved_profiles = resolvedProfiles;
|
|
348
|
+
}
|
|
349
|
+
// Validate context_wrapper if present.
|
|
350
|
+
if (definition.context_wrapper !== undefined) {
|
|
351
|
+
const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
|
|
352
|
+
if (!VALID_WRAPPER_FORMATS.has(definition.context_wrapper)) {
|
|
353
|
+
throw new WorkflowError(`Invalid context_wrapper '${String(definition.context_wrapper)}'; must be 'xml', 'brackets', or 'none'`, {
|
|
315
354
|
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
316
355
|
category: 'VALIDATION',
|
|
317
356
|
agentAction: 'report_to_user',
|
|
318
357
|
retryable: false,
|
|
319
358
|
});
|
|
320
359
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
360
|
+
}
|
|
361
|
+
// Validate and resolve workflow_context entry paths.
|
|
362
|
+
if (definition.workflow_context !== undefined) {
|
|
363
|
+
for (const [name, entry] of Object.entries(definition.workflow_context)) {
|
|
364
|
+
if (name.endsWith('.raw')) {
|
|
365
|
+
throw new WorkflowError(`workflow_context entry names must not end with '.raw' (found: '${name}')`, {
|
|
366
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
367
|
+
category: 'VALIDATION',
|
|
368
|
+
agentAction: 'report_to_user',
|
|
369
|
+
retryable: false,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (!/^[\w.]+$/.test(name)) {
|
|
373
|
+
throw new WorkflowError(`workflow_context entry name '${name}' is invalid; names must match [\\w.]+ (underscores and dots only — no hyphens)`, {
|
|
374
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
375
|
+
category: 'VALIDATION',
|
|
376
|
+
agentAction: 'report_to_user',
|
|
377
|
+
retryable: false,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const rawEntry = entry;
|
|
381
|
+
const rawSource = rawEntry['source'];
|
|
382
|
+
if (rawSource === undefined || typeof rawSource['path'] !== 'string') {
|
|
383
|
+
throw new WorkflowError(`workflow_context.${name}.source.path is required`, {
|
|
384
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
385
|
+
category: 'VALIDATION',
|
|
386
|
+
agentAction: 'report_to_user',
|
|
387
|
+
retryable: false,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
// Resolve relative path to absolute.
|
|
391
|
+
entry.source.path = resolve(workflowDir, rawSource['path']);
|
|
330
392
|
}
|
|
331
|
-
// Resolve relative path to absolute.
|
|
332
|
-
entry.source.path = resolve(workflowDir, rawSource['path']);
|
|
333
393
|
}
|
|
394
|
+
// Auto-register schema.json if present and not explicitly declared.
|
|
395
|
+
const schemaPath = join(workflowDir, 'schema.json');
|
|
396
|
+
if (existsSync(schemaPath) && definition.workflow_context?.['schema'] === undefined) {
|
|
397
|
+
definition.workflow_context ??= {};
|
|
398
|
+
definition.workflow_context['schema'] = {
|
|
399
|
+
source: { path: schemaPath },
|
|
400
|
+
description: 'Auto-registered schema.json from workflow directory',
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
// Resolution metadata: stamped for EVERY file-loaded definition (v0.14) — trust_root is
|
|
404
|
+
// the deployment-manifest anchor (`<trust_root>/realm.yaml`), needed by extension-free
|
|
405
|
+
// workflows that consume manifest-constructed adapters by name. Core resolves/stores
|
|
406
|
+
// PATHS only — it never imports modules or reads the manifest; that is the CLI's job.
|
|
407
|
+
definition.source_dir = workflowDir;
|
|
408
|
+
definition.trust_root = findTrustRoot(workflowDir);
|
|
409
|
+
if (definition.extensions !== undefined) {
|
|
410
|
+
definition.extensions =
|
|
411
|
+
typeof definition.extensions === 'string' ? [definition.extensions] : definition.extensions;
|
|
412
|
+
}
|
|
413
|
+
definition.origin = 'human';
|
|
334
414
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
definition.workflow_context['schema'] = {
|
|
340
|
-
source: { path: schemaPath },
|
|
341
|
-
description: 'Auto-registered schema.json from workflow directory',
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
// Resolution metadata: stamped for EVERY file-loaded definition (v0.14) — trust_root is
|
|
345
|
-
// the deployment-manifest anchor (`<trust_root>/realm.yaml`), needed by extension-free
|
|
346
|
-
// workflows that consume manifest-constructed adapters by name. Core resolves/stores
|
|
347
|
-
// PATHS only — it never imports modules or reads the manifest; that is the CLI's job.
|
|
348
|
-
definition.source_dir = workflowDir;
|
|
349
|
-
definition.trust_root = findTrustRoot(workflowDir);
|
|
350
|
-
if (definition.extensions !== undefined) {
|
|
351
|
-
definition.extensions =
|
|
352
|
-
typeof definition.extensions === 'string' ? [definition.extensions] : definition.extensions;
|
|
415
|
+
catch (err) {
|
|
416
|
+
if (err instanceof WorkflowError)
|
|
417
|
+
attachLoaderWarnings(err, warnings);
|
|
418
|
+
throw err;
|
|
353
419
|
}
|
|
354
|
-
definition.origin = 'human';
|
|
355
420
|
return { definition, warnings };
|
|
356
421
|
}
|
|
357
422
|
/**
|
|
@@ -478,9 +543,14 @@ function detectDependencyCycles(edges) {
|
|
|
478
543
|
*/
|
|
479
544
|
function parseWorkflowString(content, registry, opts) {
|
|
480
545
|
// Step 1: Parse YAML
|
|
546
|
+
//
|
|
547
|
+
// issue #392: the position collector rides THIS parse via js-yaml's own listener — there is no
|
|
548
|
+
// second parse and no parser change. If the parse throws, `finish()` is never reached and every
|
|
549
|
+
// position is simply absent, which is the correct answer for a file that did not parse.
|
|
550
|
+
const positions = createSourcePositionCollector();
|
|
481
551
|
let raw;
|
|
482
552
|
try {
|
|
483
|
-
raw = load(content);
|
|
553
|
+
raw = load(content, { listener: positions.listener });
|
|
484
554
|
}
|
|
485
555
|
catch (err) {
|
|
486
556
|
throw new WorkflowError(`YAML parse error: ${err instanceof Error ? err.message : String(err)}`, {
|
|
@@ -492,1099 +562,1328 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
492
562
|
}
|
|
493
563
|
const errors = [];
|
|
494
564
|
const warnings = [];
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
565
|
+
// issue #424 — CHOKEPOINT 1. Every throw from here down unwinds past a populated `warnings`
|
|
566
|
+
// array, and used to drop it: a workflow with a prohibited key AND a `dependson` typo reported
|
|
567
|
+
// the prohibition alone. This also covers the template-resolver's own throws, which cross this
|
|
568
|
+
// frame and are unreachable from any sweep of this file.
|
|
569
|
+
try {
|
|
570
|
+
// Finalised after the parse succeeded; resolves a semantic path to its place in the source.
|
|
571
|
+
const sourceMap = positions.finish();
|
|
572
|
+
/**
|
|
573
|
+
* Appends ` (step at line N)` when the step's own key can be placed, and nothing when it cannot
|
|
574
|
+
* (issue #392). Used at PUSH time, never at join time — once messages are joined into one
|
|
575
|
+
* string the step each came from is no longer recoverable.
|
|
576
|
+
*
|
|
577
|
+
* The suffix names the STEP because that is the only position this helper ever has, and saying
|
|
578
|
+
* so is the point (issue #420). Across the loader the two forms are univocal:
|
|
579
|
+
*
|
|
580
|
+
* `(line N)` — the OFFENDING KEY's own line. Minted by `withKeyLine`'s first rung
|
|
581
|
+
* below, and by the unknown-key warnings (`renderUnknownKeyMessage`),
|
|
582
|
+
* which is key-exact-or-absent by construction: every `findUnknownKeys`
|
|
583
|
+
* call site passes a `positionOf` that resolves the offending key's own
|
|
584
|
+
* path, with no step fallback anywhere.
|
|
585
|
+
* `(step at line N)` — the STEP's line. This helper, and `withKeyLine`'s fallback rung.
|
|
586
|
+
*
|
|
587
|
+
* Before that split both rungs rendered `(line N)`, so an author could not tell a cite that
|
|
588
|
+
* pointed AT the refused field from one that pointed at the declaration above it. The
|
|
589
|
+
* structured channel (`line`/`column`/`endLine`/`endColumn`) is unaffected — it always carried
|
|
590
|
+
* the distinction; only the prose was ambiguous.
|
|
591
|
+
*/
|
|
592
|
+
const withStepLine = (stepName, message) => {
|
|
593
|
+
const line = sourceMap.posOf(['steps', stepName])?.line;
|
|
594
|
+
return line === undefined ? message : `${message} (step at line ${line})`;
|
|
595
|
+
};
|
|
596
|
+
/**
|
|
597
|
+
* Like `withStepLine`, but names the OFFENDING KEY's own line (issue #417).
|
|
598
|
+
*
|
|
599
|
+
* For a key-scoped refusal the step's line is the wrong place to send someone: a long step has
|
|
600
|
+
* the key twenty lines below its own name, and the author reading `(line 40)` looks at the
|
|
601
|
+
* declaration rather than at the field being refused. The position map records every pairable
|
|
602
|
+
* mapping key, so the key's own line is available wherever the step's is.
|
|
603
|
+
*
|
|
604
|
+
* Falls back to the step's line, and then to no position at all — and the two real shapes land
|
|
605
|
+
* on DIFFERENT rungs, which is why both are pinned. A step body assembled through a merge key
|
|
606
|
+
* (`<<: *anchor`) leaves the KEY unpairable while the step's own name is still placeable, so it
|
|
607
|
+
* falls back to the step's line. A `use_template` step, whose keys are synthesized, exists at no
|
|
608
|
+
* line in the file at all and carries no position. Neither guesses — a wrong line number sends
|
|
609
|
+
* an author confidently to the wrong place, which is worse than sending them nowhere.
|
|
610
|
+
*
|
|
611
|
+
* The two rungs render DIFFERENTLY (issue #420): rung 1 is ` (line N)`, the key's own line;
|
|
612
|
+
* rung 2 is ` (step at line N)`, the step's — the same vocabulary `withStepLine` above
|
|
613
|
+
* documents in full. They were previously indistinguishable, which made the fallback silently
|
|
614
|
+
* claim to be a key-exact cite. The two lookups are separate rather than one `??` chain for
|
|
615
|
+
* exactly that reason: a single chain cannot report WHICH rung answered.
|
|
616
|
+
*/
|
|
617
|
+
const withKeyLine = (stepName, key, message) => {
|
|
618
|
+
const keyLine = sourceMap.posOf(['steps', stepName, key])?.line;
|
|
619
|
+
if (keyLine !== undefined)
|
|
620
|
+
return `${message} (line ${keyLine})`;
|
|
621
|
+
const stepLine = sourceMap.posOf(['steps', stepName])?.line;
|
|
622
|
+
if (stepLine !== undefined)
|
|
623
|
+
return `${message} (step at line ${stepLine})`;
|
|
624
|
+
return message;
|
|
625
|
+
};
|
|
626
|
+
// Step 2: Top-level validation
|
|
627
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
628
|
+
throw new WorkflowError('Invalid workflow: Workflow must be a non-null object', {
|
|
527
629
|
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
528
630
|
category: 'VALIDATION',
|
|
529
631
|
agentAction: 'report_to_user',
|
|
530
632
|
retryable: false,
|
|
531
633
|
});
|
|
532
634
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
// Finalizer-bearing workflows write every claim `deadline: null` (issue #101), so a per-step
|
|
563
|
-
// `idempotent` hint is INERT there (never cron-reclaimable). Used only to WARN below.
|
|
564
|
-
const hasFinalizerStep = Object.values(stepsRaw).some((s) => typeof s === 'object' &&
|
|
565
|
-
s !== null &&
|
|
566
|
-
s['execution'] === 'finalizer');
|
|
567
|
-
// Step 3: Per-step validation
|
|
568
|
-
for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
|
|
569
|
-
if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw)) {
|
|
570
|
-
errors.push(`Step '${stepName}' must be an object`);
|
|
571
|
-
continue;
|
|
572
|
-
}
|
|
573
|
-
const step = stepRaw;
|
|
574
|
-
// issue #220 §4c (PR-3): HOISTED out of the `when`-only block below (was block-local there) so
|
|
575
|
-
// ALL THREE condition surfaces (when/abort_unless/preconditions) can thread the real
|
|
576
|
-
// depends_on list into validateConditionLeaf's `$settlement` one-hop check. Previously
|
|
577
|
-
// abort_unless/preconditions passed a literal `[]` (no reference validation existed for them
|
|
578
|
-
// at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
|
|
579
|
-
// UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
|
|
580
|
-
// computation — byte-identical to the previous block-local `dependsOn` for `when`'s own use.
|
|
581
|
-
const dependsOn = Array.isArray(step['depends_on'])
|
|
582
|
-
? step['depends_on'].filter((d) => typeof d === 'string')
|
|
583
|
-
: [];
|
|
584
|
-
// WARN (do not reject) on an unknown step key — runs after template resolution above, so a
|
|
585
|
-
// template-expanded step's keys are checked too. Same non-breaking posture as the
|
|
586
|
-
// workflow-level check (issue #144).
|
|
587
|
-
warnings.push(...findUnknownKeys(step, KNOWN_STEP_KEYS, {
|
|
588
|
-
scope: 'step',
|
|
589
|
-
code: 'UNKNOWN_STEP_KEY',
|
|
590
|
-
step: stepName,
|
|
591
|
-
}));
|
|
592
|
-
// issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
|
|
593
|
-
// namespace it names is not minted until a later PR — else the inter-PR gap could register a
|
|
594
|
-
// `$settlement`-named step that becomes a load-refused fossil the instant the mint ships.
|
|
595
|
-
if (stepName === 'run' || stepName === 'context' || stepName === '$settlement') {
|
|
596
|
-
errors.push(`Step name '${stepName}' is reserved and cannot be used as a step identifier`);
|
|
597
|
-
}
|
|
598
|
-
// Reject integer-like step names: JS object iteration reorders integer-like keys ahead
|
|
599
|
-
// of insertion order, which would silently break the declaration-order guarantees the
|
|
600
|
-
// eligibility loops and finalizer drain rely on (both iterate via Object.entries).
|
|
601
|
-
if (/^\d+$/.test(stepName)) {
|
|
602
|
-
errors.push(`Step name '${stepName}' is invalid: integer-like names reorder under JS object ` +
|
|
603
|
-
`iteration and would break declaration-order execution. Use a non-numeric name.`);
|
|
604
|
-
}
|
|
605
|
-
const REQUIRED_STEP = ['description', 'execution'];
|
|
606
|
-
for (const field of REQUIRED_STEP) {
|
|
607
|
-
if (!(field in step)) {
|
|
608
|
-
errors.push(`Step '${stepName}': missing required field '${field}'`);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
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'`);
|
|
613
|
-
}
|
|
614
|
-
// Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
|
|
615
|
-
if (step['execution'] === 'finalizer') {
|
|
616
|
-
const prohibited = [
|
|
617
|
-
'depends_on',
|
|
618
|
-
'trigger_rule',
|
|
619
|
-
'abort_unless',
|
|
620
|
-
'abort_message',
|
|
621
|
-
'output_schema',
|
|
622
|
-
'agent_profile',
|
|
623
|
-
'tools',
|
|
624
|
-
'uses_service',
|
|
625
|
-
'service_method',
|
|
626
|
-
'operation',
|
|
627
|
-
'input_map',
|
|
628
|
-
'when',
|
|
629
|
-
'retry',
|
|
630
|
-
];
|
|
631
|
-
for (const field of prohibited) {
|
|
632
|
-
if (step[field] !== undefined) {
|
|
633
|
-
errors.push(`Step '${stepName}': '${field}' is not valid on execution: finalizer steps`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
// A finalizer must not gate — reject any human-gate trust level.
|
|
637
|
-
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)`);
|
|
639
|
-
}
|
|
640
|
-
// v1 is handler-only.
|
|
641
|
-
if (step['handler'] === undefined) {
|
|
642
|
-
errors.push(`Step '${stepName}': execution: finalizer requires 'handler' (handler-only in v1)`);
|
|
643
|
-
}
|
|
644
|
-
// on_outcome is required, non-empty, every value in the FinalizerTrigger enum.
|
|
645
|
-
const rawOutcome = step['on_outcome'];
|
|
646
|
-
if (rawOutcome === undefined) {
|
|
647
|
-
errors.push(`Step '${stepName}': execution: finalizer requires 'on_outcome'`);
|
|
648
|
-
}
|
|
649
|
-
else {
|
|
650
|
-
const outcomes = Array.isArray(rawOutcome) ? rawOutcome : [rawOutcome];
|
|
651
|
-
if (outcomes.length === 0) {
|
|
652
|
-
errors.push(`Step '${stepName}': 'on_outcome' must not be empty`);
|
|
653
|
-
}
|
|
654
|
-
for (const o of outcomes) {
|
|
655
|
-
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(', ')}`);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
635
|
+
const doc = raw;
|
|
636
|
+
// WARN (do not reject) on a key that isn't authorable — checked against KNOWN_WORKFLOW_KEYS
|
|
637
|
+
// ONLY (not RUNTIME_ONLY_WORKFLOW_KEYS), and BEFORE any loader-stamped field is added below.
|
|
638
|
+
// Deliberately excluding runtime-only keys from "known" here means hand-authoring one (e.g.
|
|
639
|
+
// `schema_version:` or `model:` in YAML) warns too — those fields are stamped by the loader
|
|
640
|
+
// and any authored value is silently overwritten/ignored, which is exactly the kind of mistake
|
|
641
|
+
// this check exists to surface (issue #144). Non-breaking by design: siblings #170
|
|
642
|
+
// (hard-reject) and #169 (structured warnings channel) are deliberately out of scope here.
|
|
643
|
+
{
|
|
644
|
+
const workflowId = typeof doc['id'] === 'string' ? doc['id'] : '<unknown>';
|
|
645
|
+
warnings.push(...findUnknownKeys(doc, KNOWN_WORKFLOW_KEYS, {
|
|
646
|
+
scope: 'workflow',
|
|
647
|
+
code: 'UNKNOWN_WORKFLOW_KEY',
|
|
648
|
+
id: workflowId,
|
|
649
|
+
positionOf: (key) => sourceMap.posOf([key]),
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
// Project extensions: hard error for string-based loading (fires before any other
|
|
653
|
+
// processing); shape validation (string | string[], relative-only) for file-based loading.
|
|
654
|
+
if ('extensions' in doc && doc['extensions'] !== undefined) {
|
|
655
|
+
if (!opts.allowExtensions) {
|
|
656
|
+
throw new WorkflowError(`Invalid workflow: 'extensions' requires file-based loading — no directory context is ` +
|
|
657
|
+
`available to resolve extension module paths. Register this workflow from its YAML ` +
|
|
658
|
+
`file (realm workflow register <path>).`, {
|
|
659
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
660
|
+
category: 'VALIDATION',
|
|
661
|
+
agentAction: 'report_to_user',
|
|
662
|
+
retryable: false,
|
|
663
|
+
});
|
|
659
664
|
}
|
|
665
|
+
errors.push(...validateExtensionsDeclaration(doc['extensions']));
|
|
660
666
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
// Guard step constraints.
|
|
666
|
-
if (step['execution'] === 'guard') {
|
|
667
|
-
const prohibited = [
|
|
668
|
-
'uses_service',
|
|
669
|
-
'handler',
|
|
670
|
-
'input_schema',
|
|
671
|
-
'output_schema',
|
|
672
|
-
'trust',
|
|
673
|
-
'agent_profile',
|
|
674
|
-
'trigger_rule',
|
|
675
|
-
'timeout_seconds',
|
|
676
|
-
'service_method',
|
|
677
|
-
'operation',
|
|
678
|
-
'input_map',
|
|
679
|
-
'tools',
|
|
680
|
-
];
|
|
681
|
-
for (const field of prohibited) {
|
|
682
|
-
if (step[field] !== undefined) {
|
|
683
|
-
errors.push(`Step '${stepName}': '${field}' is not valid on execution: guard steps`);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
if (step['abort_unless'] === undefined) {
|
|
687
|
-
errors.push(`Step '${stepName}': execution: guard requires 'abort_unless'`);
|
|
667
|
+
const REQUIRED_TOP_LEVEL = ['id', 'name', 'version', 'steps'];
|
|
668
|
+
for (const field of REQUIRED_TOP_LEVEL) {
|
|
669
|
+
if (!(field in doc)) {
|
|
670
|
+
errors.push(`Missing required field: '${field}'`);
|
|
688
671
|
}
|
|
689
672
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
errors.push(`Step '${stepName}': 'abort_unless' is only valid on execution: guard steps`);
|
|
693
|
-
}
|
|
694
|
-
if (step['abort_message'] !== undefined && step['execution'] !== 'guard') {
|
|
695
|
-
errors.push(`Step '${stepName}': 'abort_message' is only valid on execution: guard steps`);
|
|
673
|
+
if ('version' in doc && typeof doc['version'] !== 'number') {
|
|
674
|
+
errors.push(`'version' must be a number`);
|
|
696
675
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
errors.push(`
|
|
676
|
+
if ('steps' in doc &&
|
|
677
|
+
(typeof doc['steps'] !== 'object' || doc['steps'] === null || Array.isArray(doc['steps']))) {
|
|
678
|
+
errors.push(`'steps' must be a non-null object`);
|
|
700
679
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
680
|
+
if (errors.length > 0) {
|
|
681
|
+
throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
|
|
682
|
+
// issue #425: the pre-join strings — see the profile collector above.
|
|
683
|
+
errors: [...errors],
|
|
684
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
685
|
+
category: 'VALIDATION',
|
|
686
|
+
agentAction: 'report_to_user',
|
|
687
|
+
retryable: false,
|
|
688
|
+
});
|
|
706
689
|
}
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
690
|
+
// Step 1b: Resolve template instantiations before validation.
|
|
691
|
+
const rawTemplates = (doc['templates'] ?? {});
|
|
692
|
+
if (Object.keys(rawTemplates).length > 0 || hasUseTemplateInSteps(doc['steps'])) {
|
|
693
|
+
doc['steps'] = resolveTemplates(doc['steps'], rawTemplates);
|
|
694
|
+
}
|
|
695
|
+
const stepsRaw = doc['steps'];
|
|
696
|
+
// Finalizer-bearing workflows write every claim `deadline: null` (issue #101), so a per-step
|
|
697
|
+
// `idempotent` hint is INERT there (never cron-reclaimable). Used only to WARN below.
|
|
698
|
+
const hasFinalizerStep = Object.values(stepsRaw).some((s) => typeof s === 'object' &&
|
|
699
|
+
s !== null &&
|
|
700
|
+
s['execution'] === 'finalizer');
|
|
701
|
+
// Step 3: Per-step validation
|
|
702
|
+
for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
|
|
703
|
+
if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw)) {
|
|
704
|
+
errors.push(withStepLine(stepName, `Step '${stepName}' must be an object`));
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const step = stepRaw;
|
|
708
|
+
// issue #220 §4c (PR-3): HOISTED out of the `when`-only block below (was block-local there) so
|
|
709
|
+
// ALL THREE condition surfaces (when/abort_unless/preconditions) can thread the real
|
|
710
|
+
// depends_on list into validateConditionLeaf's `$settlement` one-hop check. Previously
|
|
711
|
+
// abort_unless/preconditions passed a literal `[]` (no reference validation existed for them
|
|
712
|
+
// at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
|
|
713
|
+
// UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
|
|
714
|
+
// computation — byte-identical to the previous block-local `dependsOn` for `when`'s own use.
|
|
715
|
+
const dependsOn = Array.isArray(step['depends_on'])
|
|
716
|
+
? step['depends_on'].filter((d) => typeof d === 'string')
|
|
717
|
+
: [];
|
|
718
|
+
// WARN (do not reject) on an unknown step key — runs after template resolution above, so a
|
|
719
|
+
// template-expanded step's keys are checked too. Same non-breaking posture as the
|
|
720
|
+
// workflow-level check (issue #144).
|
|
721
|
+
warnings.push(...findUnknownKeys(step, KNOWN_STEP_KEYS, {
|
|
725
722
|
scope: 'step',
|
|
723
|
+
code: 'UNKNOWN_STEP_KEY',
|
|
726
724
|
step: stepName,
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
// output_schema is only valid on execution: agent steps.
|
|
736
|
-
if (step['output_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
737
|
-
errors.push(`Step '${stepName}': 'output_schema' is only valid on execution: agent steps`);
|
|
738
|
-
}
|
|
739
|
-
// issue #236 (L0 prevention layer): structured_output is only valid on execution: agent
|
|
740
|
-
// steps (mirrors output_schema's rule above), and its only legal value is the literal
|
|
741
|
-
// 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
|
|
742
|
-
// API provably rejects some legal schemas and silently weakens others, so authoring never
|
|
743
|
-
// ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
|
|
744
|
-
// only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
|
|
745
|
-
if (step['structured_output'] !== undefined) {
|
|
746
|
-
if (step['execution'] !== 'agent') {
|
|
747
|
-
errors.push(`Step '${stepName}': 'structured_output' is only valid on execution: agent steps`);
|
|
725
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, key]),
|
|
726
|
+
}));
|
|
727
|
+
// issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
|
|
728
|
+
// namespace it names is not minted until a later PR — else the inter-PR gap could register a
|
|
729
|
+
// `$settlement`-named step that becomes a load-refused fossil the instant the mint ships.
|
|
730
|
+
if (stepName === 'run' || stepName === 'context' || stepName === '$settlement') {
|
|
731
|
+
errors.push(`Step name '${stepName}' is reserved and cannot be used as a step identifier`);
|
|
748
732
|
}
|
|
749
|
-
|
|
750
|
-
|
|
733
|
+
// Reject integer-like step names: JS object iteration reorders integer-like keys ahead
|
|
734
|
+
// of insertion order, which would silently break the declaration-order guarantees the
|
|
735
|
+
// eligibility loops and finalizer drain rely on (both iterate via Object.entries).
|
|
736
|
+
if (/^\d+$/.test(stepName)) {
|
|
737
|
+
errors.push(`Step name '${stepName}' is invalid: integer-like names reorder under JS object ` +
|
|
738
|
+
`iteration and would break declaration-order execution. Use a non-numeric name.`);
|
|
751
739
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
: {}),
|
|
757
|
-
...(step['input_schema'] !== undefined
|
|
758
|
-
? { input_schema: step['input_schema'] }
|
|
759
|
-
: {}),
|
|
760
|
-
...(step['tools'] !== undefined ? { tools: step['tools'] } : {}),
|
|
761
|
-
});
|
|
762
|
-
if (verdict.verdict === 'ineligible') {
|
|
763
|
-
errors.push(`Step '${stepName}': 'structured_output: strict' is not eligible for this step's ` +
|
|
764
|
-
`schema — ${renderIneligibleMessage(verdict.reasons)}`);
|
|
740
|
+
const REQUIRED_STEP = ['description', 'execution'];
|
|
741
|
+
for (const field of REQUIRED_STEP) {
|
|
742
|
+
if (!(field in step)) {
|
|
743
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': missing required field '${field}'`));
|
|
765
744
|
}
|
|
766
745
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
// countable rejection set (VALIDATION_INPUT_SCHEMA/VALIDATION_OUTPUT_SCHEMA) is agent-only by
|
|
770
|
-
// construction (execution-loop.ts's countRejection). Full rule table: `mode` value validated
|
|
771
|
-
// (REFUSE on an unrecognized value — unvalidatable posture, fail-closed); `mode: 'default'`
|
|
772
|
-
// requires `default_output` (REFUSE — nothing to substitute) which in turn requires the step's
|
|
773
|
-
// own `output_schema` (REFUSE — B5, an unvalidatable default) against which `default_output` is
|
|
774
|
-
// then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
|
|
775
|
-
// runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
|
|
776
|
-
// as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
|
|
777
|
-
if (step['validation_exhaustion'] !== undefined) {
|
|
778
|
-
if (step['execution'] !== 'agent') {
|
|
779
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion' is only valid on execution: agent steps`);
|
|
780
|
-
}
|
|
781
|
-
else if (typeof step['validation_exhaustion'] !== 'object' ||
|
|
782
|
-
step['validation_exhaustion'] === null) {
|
|
783
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion' must be an object`);
|
|
746
|
+
if ('execution' in step && !VALID_EXECUTIONS.has(step['execution'])) {
|
|
747
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`));
|
|
784
748
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
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)})`);
|
|
808
|
-
}
|
|
809
|
-
const hasDefaultOutput = 'default_output' in exhaustionBlock;
|
|
810
|
-
if (modeValue === 'default') {
|
|
811
|
-
if (!hasDefaultOutput) {
|
|
812
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion.mode: default' requires ` +
|
|
813
|
-
`'default_output' (nothing to substitute on exhaustion)`);
|
|
814
|
-
}
|
|
815
|
-
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)`);
|
|
818
|
-
}
|
|
819
|
-
else {
|
|
820
|
-
// B10 — load-time AJV proof: REUSE the runtime validator so the load-time verdict can
|
|
821
|
-
// never diverge from the runtime verdict for the exact same (default_output,
|
|
822
|
-
// output_schema) pair. This is the loader's first load-time Ajv compile of an
|
|
823
|
-
// output_schema (today output_schema is only compiled at runtime), so the catch below
|
|
824
|
-
// legitimately sees TWO different populations: a VALIDATION_OUTPUT_SCHEMA
|
|
825
|
-
// WorkflowError (default_output fails the schema) and a raw Ajv schema-compilation
|
|
826
|
-
// Error (a structurally malformed output_schema) — both fail-closed to a load refusal,
|
|
827
|
-
// but they carry their detail DIFFERENTLY: a WorkflowError has `.details.errors`; a raw
|
|
828
|
-
// Error has NO `.details` at all (reading `.details.errors` on it throws a TypeError
|
|
829
|
-
// that would escape the loader mid-walk — verified empirically). Discriminate.
|
|
830
|
-
try {
|
|
831
|
-
validateOutputSchema(exhaustionBlock['default_output'], step['output_schema'], stepName);
|
|
832
|
-
}
|
|
833
|
-
catch (err) {
|
|
834
|
-
const detail = err instanceof WorkflowError
|
|
835
|
-
? JSON.stringify(err.details['errors'])
|
|
836
|
-
: err instanceof Error
|
|
837
|
-
? err.message
|
|
838
|
-
: String(err);
|
|
839
|
-
errors.push(`Step '${stepName}': 'validation_exhaustion.default_output' does not validate ` +
|
|
840
|
-
`against the step's own 'output_schema': ${detail}`);
|
|
841
|
-
}
|
|
749
|
+
// Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
|
|
750
|
+
if (step['execution'] === 'finalizer') {
|
|
751
|
+
const prohibited = [
|
|
752
|
+
'depends_on',
|
|
753
|
+
'trigger_rule',
|
|
754
|
+
'abort_unless',
|
|
755
|
+
'abort_message',
|
|
756
|
+
'output_schema',
|
|
757
|
+
'agent_profile',
|
|
758
|
+
'tools',
|
|
759
|
+
'uses_service',
|
|
760
|
+
'service_method',
|
|
761
|
+
'operation',
|
|
762
|
+
'input_map',
|
|
763
|
+
'when',
|
|
764
|
+
'retry',
|
|
765
|
+
];
|
|
766
|
+
for (const field of prohibited) {
|
|
767
|
+
if (step[field] !== undefined) {
|
|
768
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: finalizer steps`));
|
|
842
769
|
}
|
|
843
770
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
warnings.push({
|
|
848
|
-
code: 'DEAD_VALIDATION_EXHAUSTION_CONFIG',
|
|
849
|
-
severity: resolveSeverity('DEAD_VALIDATION_EXHAUSTION_CONFIG'),
|
|
850
|
-
scope: 'step',
|
|
851
|
-
step: stepName,
|
|
852
|
-
message: `Step '${stepName}': 'validation_exhaustion.default_output' is ignored without ` +
|
|
853
|
-
`'mode: default' — set 'validation_exhaustion.mode: default' to enable it, or ` +
|
|
854
|
-
`remove 'default_output'.`,
|
|
855
|
-
});
|
|
771
|
+
// A finalizer must not gate — reject any human-gate trust level.
|
|
772
|
+
if (step['trust'] !== undefined && step['trust'] !== 'auto') {
|
|
773
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'trust: ${String(step['trust'])}' is not valid on execution: finalizer steps (a finalizer must not gate)`));
|
|
856
774
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
// submitted output validated against BOTH (execution-loop.ts validateInputSchema AND
|
|
861
|
-
// validateOutputSchema) — a divergence between the two degrades to a confusing recoverable
|
|
862
|
-
// VALIDATION_*_SCHEMA error rather than a clean failure. Detection/warn only; no schema change.
|
|
863
|
-
if (step['execution'] === 'agent' &&
|
|
864
|
-
step['input_schema'] !== undefined &&
|
|
865
|
-
step['output_schema'] !== undefined) {
|
|
866
|
-
warnings.push({
|
|
867
|
-
code: 'DUAL_SCHEMA_DECLARED',
|
|
868
|
-
severity: resolveSeverity('DUAL_SCHEMA_DECLARED'),
|
|
869
|
-
scope: 'step',
|
|
870
|
-
step: stepName,
|
|
871
|
-
message: `Step '${stepName}': declares both input_schema and output_schema; the agent's submitted ` +
|
|
872
|
-
`output is validated against both — prefer one to avoid divergence.`,
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
// trace_schema is only valid on execution: agent steps.
|
|
876
|
-
if (step['trace_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
877
|
-
errors.push(`Step '${stepName}': 'trace_schema' is only valid on execution: agent steps`);
|
|
878
|
-
}
|
|
879
|
-
// trace_validation_mode is only valid on execution: agent steps.
|
|
880
|
-
if (step['trace_validation_mode'] !== undefined && step['execution'] !== 'agent') {
|
|
881
|
-
errors.push(`Step '${stepName}': 'trace_validation_mode' is only valid on execution: agent steps`);
|
|
882
|
-
}
|
|
883
|
-
// trace_validation_mode must be 'warn' or 'enforce' when provided.
|
|
884
|
-
if (step['trace_validation_mode'] !== undefined &&
|
|
885
|
-
step['trace_validation_mode'] !== 'warn' &&
|
|
886
|
-
step['trace_validation_mode'] !== 'enforce') {
|
|
887
|
-
errors.push(`Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`);
|
|
888
|
-
}
|
|
889
|
-
// issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
|
|
890
|
-
// the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
|
|
891
|
-
// on_expiry enum, default_choice's required-iff + choice-set validation, and the dead-config
|
|
892
|
-
// warn cells. Runs regardless of `trust` (a `gate:` block with no gate trust is already inert
|
|
893
|
-
// — no separate rejection needed; the existing render/mint paths never read it without a
|
|
894
|
-
// trust value).
|
|
895
|
-
if (step['gate'] !== undefined) {
|
|
896
|
-
if (typeof step['gate'] !== 'object' || step['gate'] === null) {
|
|
897
|
-
errors.push(`Step '${stepName}': 'gate' must be an object`);
|
|
898
|
-
}
|
|
899
|
-
else {
|
|
900
|
-
const gate = step['gate'];
|
|
901
|
-
// WARN (do not reject) on an unknown gate-block key — same non-breaking posture as
|
|
902
|
-
// retry/validation_exhaustion (issues #140/#220).
|
|
903
|
-
warnings.push(...findUnknownKeys(gate, KNOWN_GATE_KEYS, {
|
|
904
|
-
scope: 'step',
|
|
905
|
-
code: 'UNKNOWN_GATE_KEY',
|
|
906
|
-
step: stepName,
|
|
907
|
-
noun: 'gate',
|
|
908
|
-
}));
|
|
909
|
-
// E2: timeout_seconds/reminder_seconds/reminder_max must each be a positive integer
|
|
910
|
-
// (yaml-loader :1164-1174 precedent — the SAME convention as retry.total_timeout_seconds).
|
|
911
|
-
if ('timeout_seconds' in gate &&
|
|
912
|
-
(!Number.isInteger(gate['timeout_seconds']) || gate['timeout_seconds'] <= 0)) {
|
|
913
|
-
errors.push(`Step '${stepName}': 'gate.timeout_seconds' must be a positive integer`);
|
|
775
|
+
// v1 is handler-only.
|
|
776
|
+
if (step['handler'] === undefined) {
|
|
777
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'handler' (handler-only in v1)`));
|
|
914
778
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
779
|
+
// on_outcome is required, non-empty, every value in the FinalizerTrigger enum.
|
|
780
|
+
const rawOutcome = step['on_outcome'];
|
|
781
|
+
if (rawOutcome === undefined) {
|
|
782
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'on_outcome'`));
|
|
918
783
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
// on_expiry must be 'settle_default' or 'abort' when provided.
|
|
924
|
-
const onExpiry = gate['on_expiry'];
|
|
925
|
-
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)})`);
|
|
927
|
-
}
|
|
928
|
-
// default_choice: REQUIRED iff on_expiry === 'settle_default' (E2-style hard error,
|
|
929
|
-
// mirroring validation_exhaustion.mode:'default' requiring default_output); validated
|
|
930
|
-
// against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
|
|
931
|
-
// derivation the engine mints PendingGate.choices from (execution-loop.ts's gate-open
|
|
932
|
-
// site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']) —
|
|
933
|
-
// so a load-time-legal default_choice can NEVER fail at enactment time.
|
|
934
|
-
const hasDefaultChoice = 'default_choice' in gate;
|
|
935
|
-
if (onExpiry === 'settle_default') {
|
|
936
|
-
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)`);
|
|
784
|
+
else {
|
|
785
|
+
const outcomes = Array.isArray(rawOutcome) ? rawOutcome : [rawOutcome];
|
|
786
|
+
if (outcomes.length === 0) {
|
|
787
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'on_outcome' must not be empty`));
|
|
939
788
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const effectiveChoices = Array.isArray(choicesRaw)
|
|
944
|
-
? choicesRaw
|
|
945
|
-
: ['approve', 'reject'];
|
|
946
|
-
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(', ')}`);
|
|
789
|
+
for (const o of outcomes) {
|
|
790
|
+
if (typeof o !== 'string' || !VALID_FINALIZER_TRIGGERS.has(o)) {
|
|
791
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid on_outcome value '${String(o)}'; must be one of ${[...VALID_FINALIZER_TRIGGERS].join(', ')}`));
|
|
949
792
|
}
|
|
950
793
|
}
|
|
951
794
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
795
|
+
}
|
|
796
|
+
// on_outcome is only valid on execution: finalizer steps.
|
|
797
|
+
if (step['on_outcome'] !== undefined && step['execution'] !== 'finalizer') {
|
|
798
|
+
errors.push(
|
|
799
|
+
// Consumer: settlement.ts:145 (`finalizerTriggers`) — it is read only when selecting
|
|
800
|
+
// which finalizers a run's outcome should fire.
|
|
801
|
+
withKeyLine(stepName, 'on_outcome', `Step '${stepName}': 'on_outcome' is only valid on execution: finalizer steps — it ` +
|
|
802
|
+
'selects which finalizers run for a given outcome, and only finalizers are selected ' +
|
|
803
|
+
'that way, so here it would decide nothing. Move it to the finalizer that should ' +
|
|
804
|
+
'react to the outcome, or remove it.'));
|
|
805
|
+
}
|
|
806
|
+
// Guard step constraints.
|
|
807
|
+
if (step['execution'] === 'guard') {
|
|
808
|
+
const prohibited = [
|
|
809
|
+
'uses_service',
|
|
810
|
+
'handler',
|
|
811
|
+
'input_schema',
|
|
812
|
+
'output_schema',
|
|
813
|
+
'trust',
|
|
814
|
+
'agent_profile',
|
|
815
|
+
'trigger_rule',
|
|
816
|
+
'timeout_seconds',
|
|
817
|
+
'service_method',
|
|
818
|
+
'operation',
|
|
819
|
+
'input_map',
|
|
820
|
+
'tools',
|
|
821
|
+
];
|
|
822
|
+
for (const field of prohibited) {
|
|
823
|
+
if (step[field] !== undefined) {
|
|
824
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: guard steps`));
|
|
825
|
+
}
|
|
963
826
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
if (onExpiry !== undefined && gate['timeout_seconds'] === undefined) {
|
|
967
|
-
warnings.push({
|
|
968
|
-
code: 'DEAD_GATE_CONFIG',
|
|
969
|
-
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
970
|
-
scope: 'step',
|
|
971
|
-
step: stepName,
|
|
972
|
-
message: `Step '${stepName}': 'gate.on_expiry' is ignored without 'gate.timeout_seconds' — ` +
|
|
973
|
-
`set a timeout, or remove 'gate.on_expiry'.`,
|
|
974
|
-
});
|
|
827
|
+
if (step['abort_unless'] === undefined) {
|
|
828
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
|
|
975
829
|
}
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
830
|
+
// issue #369: `preconditions` gets its OWN error rather than joining `prohibited` above,
|
|
831
|
+
// because the generic message ("'x' is not valid on execution: guard steps") would not say
|
|
832
|
+
// the thing that matters — this field was ACCEPTED and INERT before this check existed, so
|
|
833
|
+
// an author who wrote one has a workflow that looks guarded and never was. The generic
|
|
834
|
+
// list's own message style is issue #366's territory; the other twelve are left alone.
|
|
835
|
+
//
|
|
836
|
+
// The claim "never evaluates it there" rests on `checkPreconditions` having exactly one
|
|
837
|
+
// engine call site (execution-loop.ts:1380, inside `executeStep`), which `executeGuardStep`
|
|
838
|
+
// never reaches. A test pins that count so a second call site reds this message.
|
|
839
|
+
if (step['preconditions'] !== undefined) {
|
|
840
|
+
errors.push(withKeyLine(stepName, 'preconditions', `Step '${stepName}': 'preconditions' is not valid on execution: guard steps — the ` +
|
|
841
|
+
`engine never evaluates it there (a guard's execution evaluates only 'abort_unless'), ` +
|
|
842
|
+
`so the run would LOOK guarded while the declared check never ran. Move the condition ` +
|
|
843
|
+
`into 'abort_unless'. Whether guards gain a live condition surface is an open design ` +
|
|
844
|
+
`question (issue #366) — if admitted later, existing workflows are unaffected.`));
|
|
990
845
|
}
|
|
991
846
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
847
|
+
// abort_unless and abort_message are only valid on execution: guard steps.
|
|
848
|
+
if (step['abort_unless'] !== undefined && step['execution'] !== 'guard') {
|
|
849
|
+
errors.push(
|
|
850
|
+
// Consumer: execution-loop.ts:4828 — the condition list a guard evaluates before the
|
|
851
|
+
// run is allowed to continue.
|
|
852
|
+
withKeyLine(stepName, 'abort_unless', `Step '${stepName}': 'abort_unless' is only valid on execution: guard steps — it is ` +
|
|
853
|
+
'the condition list a guard evaluates before letting the run continue, and only ' +
|
|
854
|
+
'guard steps are evaluated that way, so here it would gate nothing. Put the check ' +
|
|
855
|
+
'on a guard step, or remove it.'));
|
|
999
856
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
857
|
+
if (step['abort_message'] !== undefined && step['execution'] !== 'guard') {
|
|
858
|
+
errors.push(
|
|
859
|
+
// Consumer: execution-loop.ts:4943 — the text reported when a guard aborts the run.
|
|
860
|
+
// The clause is about READERSHIP, not about who aborts: `handler_abort` and
|
|
861
|
+
// `gate_expiry_abort` are seal arms too (types/run-record.ts:603-617), so "only a guard
|
|
862
|
+
// aborts" would be false. What is true is that every reader of this key is a guard path.
|
|
863
|
+
withKeyLine(stepName, 'abort_message', `Step '${stepName}': 'abort_message' is only valid on execution: guard steps — it is ` +
|
|
864
|
+
'the text reported when a guard aborts the run, and nothing but a guard reads it, ' +
|
|
865
|
+
'so here it would never be read. Move it to the guard that performs the abort, or ' +
|
|
866
|
+
'remove it.'));
|
|
867
|
+
}
|
|
868
|
+
// agent_profile is only valid on agent steps.
|
|
869
|
+
if ('agent_profile' in step && step['execution'] !== 'agent') {
|
|
870
|
+
errors.push(
|
|
871
|
+
// Consumer: run-agent.ts:584 — resolved into the model prompt for the step.
|
|
872
|
+
withKeyLine(stepName, 'agent_profile', `Step '${stepName}': 'agent_profile' is only valid on execution: agent steps — its ` +
|
|
873
|
+
'content is resolved into the model prompt, and only an agent step makes a model ' +
|
|
874
|
+
'request, so here it would reach no model. Move it to the agent step whose prompt ' +
|
|
875
|
+
'it should shape, or remove it.'));
|
|
876
|
+
}
|
|
877
|
+
// llm_timeout_seconds (issue #401) is only valid on agent steps — no other execution kind
|
|
878
|
+
// makes a model request, so the key would be silently inert anywhere else. One `!== 'agent'`
|
|
879
|
+
// check covers auto/guard/finalizer.
|
|
880
|
+
if (step['llm_timeout_seconds'] !== undefined && step['execution'] !== 'agent') {
|
|
881
|
+
errors.push(
|
|
882
|
+
// Consumer: run-agent.ts:501-507 — the per-step clock resolution, which is the
|
|
883
|
+
// per-attempt bound on the step's model request. The range names the resolution rather
|
|
884
|
+
// than each read: :501 and :507 read the KEY, :503 reads the CLI flag it overrides.
|
|
885
|
+
withKeyLine(stepName, 'llm_timeout_seconds', `Step '${stepName}': 'llm_timeout_seconds' is only valid on execution: agent steps — ` +
|
|
886
|
+
'it bounds one model request, and no other kind makes one, so here it would bound ' +
|
|
887
|
+
'nothing. Move it to the agent step whose request it should bound, or remove it. ' +
|
|
888
|
+
"An auto step's dispatch is bounded by 'timeout_seconds', and a " +
|
|
889
|
+
"finalizer's handler by its own 'timeout_seconds'."));
|
|
890
|
+
}
|
|
891
|
+
// ...and when present it must be a positive integer (the same convention as
|
|
892
|
+
// retry.total_timeout_seconds and gate.timeout_seconds).
|
|
893
|
+
if (step['llm_timeout_seconds'] !== undefined &&
|
|
894
|
+
(!Number.isInteger(step['llm_timeout_seconds']) ||
|
|
895
|
+
step['llm_timeout_seconds'] <= 0)) {
|
|
896
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'llm_timeout_seconds' must be a positive integer`));
|
|
897
|
+
}
|
|
898
|
+
// timeout_seconds is NOT valid on an agent step (issue #402). Nothing enforces it there:
|
|
899
|
+
// `shouldEnforceTimeout` is `execution === 'auto'`, and agent dispatch is never wrapped in
|
|
900
|
+
// `withTimeout` at all. The key is now inert as well as unenforced — issue #412 deleted the
|
|
901
|
+
// `expected_timeout` display that used to render it into the NextAction, which is what made
|
|
902
|
+
// it actively misleading rather than merely useless. The error stays: an author who writes a
|
|
903
|
+
// bound should be told it does nothing, not left to find out. The message names both bounds
|
|
904
|
+
// that DO exist, scoped to realm's own drive (an externally driven step gets neither), on
|
|
905
|
+
// the RETRY_INERT_NON_AUTO precedent below.
|
|
906
|
+
//
|
|
907
|
+
// `=== 'agent'` EXACTLY, never `!== 'auto'`: finalizers consume this key twice — the drain
|
|
908
|
+
// lease (execution-loop.ts:5226) and the handler's own bound (:5030) — and guards already
|
|
909
|
+
// reject it in the prohibited-fields list above.
|
|
910
|
+
if (step['timeout_seconds'] !== undefined && step['execution'] === 'agent') {
|
|
911
|
+
errors.push(withKeyLine(stepName, 'timeout_seconds', `Step '${stepName}': 'timeout_seconds' is not valid on execution: agent steps — ` +
|
|
912
|
+
'the engine never enforces it there (agent dispatch is never wrapped in a timeout), ' +
|
|
913
|
+
'so the step would LOOK time-bounded while nothing enforced the bound. ' +
|
|
914
|
+
"In realm's own drive the model request is bounded by 'llm_timeout_seconds' " +
|
|
915
|
+
"(or --llm-timeout) and tool calls by 'tool_timeout'."));
|
|
1005
916
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
917
|
+
// idempotent (issue #101 Phase 2) is only valid on execution: auto steps — the reliably
|
|
918
|
+
// time-boundable, deadline-carrying class. It is inert (no concrete deadline is ever written)
|
|
919
|
+
// on agent/guard/finalizer, so it is rejected there rather than silently ignored.
|
|
920
|
+
if (step['idempotent'] !== undefined && step['execution'] !== 'auto') {
|
|
921
|
+
errors.push(
|
|
922
|
+
// Consumers: execution-loop.ts:2526 (the `willRetry` conjunct gating `retry.on_timeout`;
|
|
923
|
+
// the :2115 advisory mirrors the rule for loader-bypassing definitions and, by its own
|
|
924
|
+
// header, never gates) and reclaim.ts:73 (reclaim eligibility) — both act on auto
|
|
925
|
+
// dispatch.
|
|
926
|
+
withKeyLine(stepName, 'idempotent', `Step '${stepName}': 'idempotent' is only valid on execution: auto steps — it gates ` +
|
|
927
|
+
"'retry.on_timeout' and reclaim eligibility, and both act on auto dispatch, so here " +
|
|
928
|
+
'it would gate nothing. Remove it, or move the work to an auto step if you need ' +
|
|
929
|
+
'either.'));
|
|
930
|
+
}
|
|
931
|
+
// WARN (do not reject): an idempotent auto step in a finalizer-bearing workflow gets
|
|
932
|
+
// `deadline: null` (issue #101), so the RECLAIM function is inert — `realm run reclaim --all`
|
|
933
|
+
// can never select it. The author should know it stays per-step-manual-reclaim-only.
|
|
934
|
+
//
|
|
935
|
+
// Issue #140 C5 (variant-aware reword): `idempotent` now has a SECOND function — gating
|
|
936
|
+
// `retry.on_timeout` — and that GATE function is live in every workflow, finalizer-bearing or
|
|
937
|
+
// not (shouldEnforceTimeout has no finalizer conjunct). A single unconditional message would
|
|
938
|
+
// either keep a falsehood (claiming idempotent is wholly inert when on_timeout is ALSO
|
|
939
|
+
// declared) or gratuitously mention a gate the author never declared (idempotent-alone case)
|
|
940
|
+
// — so the message is keyed on `step.retry?.on_timeout`, pinned by both-variant loader tests.
|
|
941
|
+
if (step['idempotent'] === true && step['execution'] === 'auto' && hasFinalizerStep) {
|
|
942
|
+
const stepRetry = typeof step['retry'] === 'object' && step['retry'] !== null
|
|
943
|
+
? step['retry']
|
|
944
|
+
: undefined;
|
|
945
|
+
const onTimeoutDeclared = stepRetry?.['on_timeout'] === true;
|
|
946
|
+
warnings.push({
|
|
947
|
+
code: 'IDEMPOTENT_INERT_IN_FINALIZER',
|
|
948
|
+
severity: resolveSeverity('IDEMPOTENT_INERT_IN_FINALIZER'),
|
|
1012
949
|
scope: 'step',
|
|
1013
|
-
code: 'UNKNOWN_RETRY_KEY',
|
|
1014
950
|
step: stepName,
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
951
|
+
message: `Step '${stepName}': 'idempotent: true' cannot enable auto-reclaim in a finalizer-bearing ` +
|
|
952
|
+
`workflow (its claim carries no deadline, so 'realm run reclaim --all' can never select ` +
|
|
953
|
+
`it). Recover it with 'realm run reclaim <run-id> --step ${stepName} --force'.` +
|
|
954
|
+
(onTimeoutDeclared
|
|
955
|
+
? ` Its 'retry.on_timeout' gate role is unaffected — timeout retries remain active.`
|
|
956
|
+
: ''),
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
// output_schema is only valid on execution: agent steps.
|
|
960
|
+
if (step['output_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
961
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'output_schema' is only valid on execution: agent steps`));
|
|
962
|
+
}
|
|
963
|
+
// issue #236 (L0 prevention layer): structured_output is only valid on execution: agent
|
|
964
|
+
// steps (mirrors output_schema's rule above), and its only legal value is the literal
|
|
965
|
+
// 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
|
|
966
|
+
// API provably rejects some legal schemas and silently weakens others, so authoring never
|
|
967
|
+
// ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
|
|
968
|
+
// only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
|
|
969
|
+
if (step['structured_output'] !== undefined) {
|
|
970
|
+
if (step['execution'] !== 'agent') {
|
|
971
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' is only valid on execution: agent steps`));
|
|
1030
972
|
}
|
|
1031
|
-
if ('
|
|
1032
|
-
(
|
|
1033
|
-
errors.push(`Step '${stepName}': 'retry.max_delay_ms' must be a non-negative number`);
|
|
973
|
+
else if (step['structured_output'] !== 'strict') {
|
|
974
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`));
|
|
1034
975
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
976
|
+
else {
|
|
977
|
+
const verdict = assessStructuredOutputEligibility({
|
|
978
|
+
...(step['output_schema'] !== undefined
|
|
979
|
+
? { output_schema: step['output_schema'] }
|
|
980
|
+
: {}),
|
|
981
|
+
...(step['input_schema'] !== undefined
|
|
982
|
+
? { input_schema: step['input_schema'] }
|
|
983
|
+
: {}),
|
|
984
|
+
...(step['tools'] !== undefined ? { tools: step['tools'] } : {}),
|
|
985
|
+
});
|
|
986
|
+
if (verdict.verdict === 'ineligible') {
|
|
987
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output: strict' is not eligible for this step's ` +
|
|
988
|
+
`schema — ${renderIneligibleMessage(verdict.reasons)}`));
|
|
989
|
+
}
|
|
1039
990
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
991
|
+
}
|
|
992
|
+
// issue #220 (PR-2): validation_exhaustion is only valid on execution: agent steps — the
|
|
993
|
+
// countable rejection set (VALIDATION_INPUT_SCHEMA/VALIDATION_OUTPUT_SCHEMA) is agent-only by
|
|
994
|
+
// construction (execution-loop.ts's countRejection). Full rule table: `mode` value validated
|
|
995
|
+
// (REFUSE on an unrecognized value — unvalidatable posture, fail-closed); `mode: 'default'`
|
|
996
|
+
// requires `default_output` (REFUSE — nothing to substitute) which in turn requires the step's
|
|
997
|
+
// own `output_schema` (REFUSE — B5, an unvalidatable default) against which `default_output` is
|
|
998
|
+
// then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
|
|
999
|
+
// runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
|
|
1000
|
+
// as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
|
|
1001
|
+
if (step['validation_exhaustion'] !== undefined) {
|
|
1002
|
+
if (step['execution'] !== 'agent') {
|
|
1003
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' is only valid on execution: agent steps`));
|
|
1047
1004
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
errors.push(`Step '${stepName}': 'retry.on_timeout: true' requires 'idempotent: true' declared ` +
|
|
1052
|
-
`on the step — a timeout-retry can run concurrently with the still-in-flight ` +
|
|
1053
|
-
`original attempt, so the step must explicitly attest that any partial prior ` +
|
|
1054
|
-
`application is harmless to re-apply. Declare 'idempotent: true' or remove ` +
|
|
1055
|
-
`'on_timeout'.`);
|
|
1005
|
+
else if (typeof step['validation_exhaustion'] !== 'object' ||
|
|
1006
|
+
step['validation_exhaustion'] === null) {
|
|
1007
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' must be an object`));
|
|
1056
1008
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1009
|
+
else {
|
|
1010
|
+
const exhaustionBlock = step['validation_exhaustion'];
|
|
1011
|
+
// WARN (do not reject) on an unknown validation_exhaustion sub-key — the retry-block-style
|
|
1012
|
+
// pattern (issue #140's UNKNOWN_RETRY_KEY), its OWN code (issue #220 PR-2 mints
|
|
1013
|
+
// UNKNOWN_VALIDATION_EXHAUSTION_KEY, replacing PR-1's UNKNOWN_STEP_KEY noun-override —
|
|
1014
|
+
// closes the #170-flip incoherence against the structurally identical retry-key family).
|
|
1015
|
+
warnings.push(...findUnknownKeys(exhaustionBlock, KNOWN_VALIDATION_EXHAUSTION_KEYS, {
|
|
1064
1016
|
scope: 'step',
|
|
1017
|
+
code: 'UNKNOWN_VALIDATION_EXHAUSTION_KEY',
|
|
1065
1018
|
step: stepName,
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1019
|
+
noun: 'validation_exhaustion',
|
|
1020
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, 'validation_exhaustion', key]),
|
|
1021
|
+
}));
|
|
1022
|
+
if ('threshold' in exhaustionBlock &&
|
|
1023
|
+
(!Number.isInteger(exhaustionBlock['threshold']) ||
|
|
1024
|
+
exhaustionBlock['threshold'] < 1)) {
|
|
1025
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.threshold' must be a positive integer ` +
|
|
1026
|
+
`(1 is legal — it disables in-drive schema-repair, since the first rejection ` +
|
|
1027
|
+
`already meets it)`));
|
|
1028
|
+
}
|
|
1029
|
+
const modeValue = exhaustionBlock['mode'];
|
|
1030
|
+
if (modeValue !== undefined && modeValue !== 'fail' && modeValue !== 'default') {
|
|
1031
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode' must be 'fail' or 'default' ` +
|
|
1032
|
+
`(got: ${JSON.stringify(modeValue)})`));
|
|
1033
|
+
}
|
|
1034
|
+
const hasDefaultOutput = 'default_output' in exhaustionBlock;
|
|
1035
|
+
if (modeValue === 'default') {
|
|
1036
|
+
if (!hasDefaultOutput) {
|
|
1037
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode: default' requires ` +
|
|
1038
|
+
`'default_output' (nothing to substitute on exhaustion)`));
|
|
1039
|
+
}
|
|
1040
|
+
else if (step['output_schema'] === undefined) {
|
|
1041
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' requires the step to ` +
|
|
1042
|
+
`declare 'output_schema' (an undeclared schema makes the default unvalidatable)`));
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
// B10 — load-time AJV proof: REUSE the runtime validator so the load-time verdict can
|
|
1046
|
+
// never diverge from the runtime verdict for the exact same (default_output,
|
|
1047
|
+
// output_schema) pair. This is the loader's first load-time Ajv compile of an
|
|
1048
|
+
// output_schema (today output_schema is only compiled at runtime), so the catch below
|
|
1049
|
+
// legitimately sees TWO different populations: a VALIDATION_OUTPUT_SCHEMA
|
|
1050
|
+
// WorkflowError (default_output fails the schema) and a raw Ajv schema-compilation
|
|
1051
|
+
// Error (a structurally malformed output_schema) — both fail-closed to a load refusal,
|
|
1052
|
+
// but they carry their detail DIFFERENTLY: a WorkflowError has `.details.errors`; a raw
|
|
1053
|
+
// Error has NO `.details` at all (reading `.details.errors` on it throws a TypeError
|
|
1054
|
+
// that would escape the loader mid-walk — verified empirically). Discriminate.
|
|
1055
|
+
try {
|
|
1056
|
+
validateOutputSchema(exhaustionBlock['default_output'], step['output_schema'], stepName);
|
|
1057
|
+
}
|
|
1058
|
+
catch (err) {
|
|
1059
|
+
const detail = err instanceof WorkflowError
|
|
1060
|
+
? JSON.stringify(err.details['errors'])
|
|
1061
|
+
: err instanceof Error
|
|
1062
|
+
? err.message
|
|
1063
|
+
: String(err);
|
|
1064
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' does not validate ` +
|
|
1065
|
+
`against the step's own 'output_schema': ${detail}`));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
else if (hasDefaultOutput) {
|
|
1070
|
+
// default_output present without mode: 'default' (mode: 'fail' or absent) — inert, not
|
|
1071
|
+
// an error: WARN as dead config rather than silently ignoring it.
|
|
1072
|
+
warnings.push({
|
|
1073
|
+
code: 'DEAD_VALIDATION_EXHAUSTION_CONFIG',
|
|
1074
|
+
severity: resolveSeverity('DEAD_VALIDATION_EXHAUSTION_CONFIG'),
|
|
1075
|
+
scope: 'step',
|
|
1076
|
+
step: stepName,
|
|
1077
|
+
message: `Step '${stepName}': 'validation_exhaustion.default_output' is ignored without ` +
|
|
1078
|
+
`'mode: default' — set 'validation_exhaustion.mode: default' to enable it, or ` +
|
|
1079
|
+
`remove 'default_output'.`,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
// WARN (do not reject): an agent step declaring BOTH input_schema and output_schema has its
|
|
1085
|
+
// submitted output validated against BOTH (execution-loop.ts validateInputSchema AND
|
|
1086
|
+
// validateOutputSchema) — a divergence between the two degrades to a confusing recoverable
|
|
1087
|
+
// VALIDATION_*_SCHEMA error rather than a clean failure. Detection/warn only; no schema change.
|
|
1088
|
+
if (step['execution'] === 'agent' &&
|
|
1089
|
+
step['input_schema'] !== undefined &&
|
|
1090
|
+
step['output_schema'] !== undefined) {
|
|
1091
|
+
warnings.push({
|
|
1092
|
+
code: 'DUAL_SCHEMA_DECLARED',
|
|
1093
|
+
severity: resolveSeverity('DUAL_SCHEMA_DECLARED'),
|
|
1094
|
+
scope: 'step',
|
|
1095
|
+
step: stepName,
|
|
1096
|
+
message: `Step '${stepName}': declares both input_schema and output_schema; the agent's submitted ` +
|
|
1097
|
+
`output is validated against both — prefer one to avoid divergence.`,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
// trace_schema is only valid on execution: agent steps.
|
|
1101
|
+
if (step['trace_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
1102
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_schema' is only valid on execution: agent steps`));
|
|
1103
|
+
}
|
|
1104
|
+
// trace_validation_mode is only valid on execution: agent steps.
|
|
1105
|
+
if (step['trace_validation_mode'] !== undefined && step['execution'] !== 'agent') {
|
|
1106
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_validation_mode' is only valid on execution: agent steps`));
|
|
1107
|
+
}
|
|
1108
|
+
// trace_validation_mode must be 'warn' or 'enforce' when provided.
|
|
1109
|
+
if (step['trace_validation_mode'] !== undefined &&
|
|
1110
|
+
step['trace_validation_mode'] !== 'warn' &&
|
|
1111
|
+
step['trace_validation_mode'] !== 'enforce') {
|
|
1112
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`));
|
|
1113
|
+
}
|
|
1114
|
+
// issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
|
|
1115
|
+
// the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
|
|
1116
|
+
// on_expiry enum, default_choice's required-iff + choice-set validation, and the dead-config
|
|
1117
|
+
// warn cells. Runs regardless of `trust` (a `gate:` block with no gate trust is already inert
|
|
1118
|
+
// — no separate rejection needed; the existing render/mint paths never read it without a
|
|
1119
|
+
// trust value).
|
|
1120
|
+
if (step['gate'] !== undefined) {
|
|
1121
|
+
if (typeof step['gate'] !== 'object' || step['gate'] === null) {
|
|
1122
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate' must be an object`));
|
|
1070
1123
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
// already hard-errored above; on_timeout: true already hard-errored via E1 unless
|
|
1077
|
-
// idempotent is also declared, which is itself rejected by the pre-existing
|
|
1078
|
-
// idempotent-non-auto check) exactly ONE of {W5, RETRY_INERT_NON_AUTO} ever fires — never
|
|
1079
|
-
// both, never neither.
|
|
1080
|
-
if (step['execution'] !== 'auto' && retry['total_timeout_seconds'] === undefined) {
|
|
1081
|
-
const isAgent = step['execution'] === 'agent';
|
|
1082
|
-
const message = isAgent
|
|
1083
|
-
? `Step '${stepName}': 'retry' is inert on execution: 'agent' steps — the built-in ` +
|
|
1084
|
-
`dispatch path never throws for agent steps, so this block can never mint a second ` +
|
|
1085
|
-
`attempt here (for schema-repair budgets, use the CLI drive's '--schema-retries' ` +
|
|
1086
|
-
`flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
|
|
1087
|
-
`config — a deliberate public-API capability, not an invalid one.`
|
|
1088
|
-
: `Step '${stepName}': 'retry' is inert on execution: '${String(step['execution'])}' ` +
|
|
1089
|
-
`steps — the built-in dispatch path never throws for these steps, so this block can ` +
|
|
1090
|
-
`never mint a second attempt here. An embedder-supplied throwing dispatcher may ` +
|
|
1091
|
-
`still consume this config — a deliberate public-API capability, not an invalid one.`;
|
|
1092
|
-
warnings.push({
|
|
1093
|
-
code: 'RETRY_INERT_NON_AUTO',
|
|
1094
|
-
severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
|
|
1124
|
+
else {
|
|
1125
|
+
const gate = step['gate'];
|
|
1126
|
+
// WARN (do not reject) on an unknown gate-block key — same non-breaking posture as
|
|
1127
|
+
// retry/validation_exhaustion (issues #140/#220).
|
|
1128
|
+
warnings.push(...findUnknownKeys(gate, KNOWN_GATE_KEYS, {
|
|
1095
1129
|
scope: 'step',
|
|
1130
|
+
code: 'UNKNOWN_GATE_KEY',
|
|
1096
1131
|
step: stepName,
|
|
1097
|
-
|
|
1098
|
-
|
|
1132
|
+
noun: 'gate',
|
|
1133
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, 'gate', key]),
|
|
1134
|
+
}));
|
|
1135
|
+
// E2: timeout_seconds/reminder_seconds/reminder_max must each be a positive integer
|
|
1136
|
+
// (yaml-loader :1164-1174 precedent — the SAME convention as retry.total_timeout_seconds).
|
|
1137
|
+
if ('timeout_seconds' in gate &&
|
|
1138
|
+
(!Number.isInteger(gate['timeout_seconds']) || gate['timeout_seconds'] <= 0)) {
|
|
1139
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.timeout_seconds' must be a positive integer`));
|
|
1140
|
+
}
|
|
1141
|
+
if ('reminder_seconds' in gate &&
|
|
1142
|
+
(!Number.isInteger(gate['reminder_seconds']) ||
|
|
1143
|
+
gate['reminder_seconds'] <= 0)) {
|
|
1144
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_seconds' must be a positive integer`));
|
|
1145
|
+
}
|
|
1146
|
+
if ('reminder_max' in gate &&
|
|
1147
|
+
(!Number.isInteger(gate['reminder_max']) || gate['reminder_max'] <= 0)) {
|
|
1148
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_max' must be a positive integer`));
|
|
1149
|
+
}
|
|
1150
|
+
// on_expiry must be 'settle_default' or 'abort' when provided.
|
|
1151
|
+
const onExpiry = gate['on_expiry'];
|
|
1152
|
+
if (onExpiry !== undefined && onExpiry !== 'settle_default' && onExpiry !== 'abort') {
|
|
1153
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry' must be 'settle_default' or 'abort' (got: ${JSON.stringify(onExpiry)})`));
|
|
1154
|
+
}
|
|
1155
|
+
// default_choice: REQUIRED iff on_expiry === 'settle_default' (E2-style hard error,
|
|
1156
|
+
// mirroring validation_exhaustion.mode:'default' requiring default_output); validated
|
|
1157
|
+
// against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
|
|
1158
|
+
// derivation the engine mints PendingGate.choices from (execution-loop.ts's gate-open
|
|
1159
|
+
// site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']) —
|
|
1160
|
+
// so a load-time-legal default_choice can NEVER fail at enactment time.
|
|
1161
|
+
const hasDefaultChoice = 'default_choice' in gate;
|
|
1162
|
+
if (onExpiry === 'settle_default') {
|
|
1163
|
+
if (!hasDefaultChoice) {
|
|
1164
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
|
|
1165
|
+
`(nothing to resolve the gate with on expiry)`));
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
const choicesRaw = gate['choices'] ??
|
|
1169
|
+
step['input_schema']?.properties?.['choice']?.enum;
|
|
1170
|
+
const effectiveChoices = Array.isArray(choicesRaw)
|
|
1171
|
+
? choicesRaw
|
|
1172
|
+
: ['approve', 'reject'];
|
|
1173
|
+
if (!effectiveChoices.includes(gate['default_choice'])) {
|
|
1174
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.default_choice' (${JSON.stringify(gate['default_choice'])}) ` +
|
|
1175
|
+
`is not one of the step's effective choices: ${effectiveChoices.join(', ')}`));
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
else if (hasDefaultChoice) {
|
|
1180
|
+
// default_choice with on_expiry:'abort' or with no on_expiry at all — inert, not an
|
|
1181
|
+
// error: WARN as dead config (the #220 DEAD_VALIDATION_EXHAUSTION_CONFIG precedent).
|
|
1182
|
+
warnings.push({
|
|
1183
|
+
code: 'DEAD_GATE_CONFIG',
|
|
1184
|
+
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1185
|
+
scope: 'step',
|
|
1186
|
+
step: stepName,
|
|
1187
|
+
message: `Step '${stepName}': 'gate.default_choice' is ignored without ` +
|
|
1188
|
+
`'gate.on_expiry: settle_default' — set it, or remove 'gate.default_choice'.`,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
// Dead config: on_expiry declared but no timeout_seconds — nothing will ever trigger the
|
|
1192
|
+
// enforce clock, so the declared disposition can never enact.
|
|
1193
|
+
if (onExpiry !== undefined && gate['timeout_seconds'] === undefined) {
|
|
1194
|
+
warnings.push({
|
|
1195
|
+
code: 'DEAD_GATE_CONFIG',
|
|
1196
|
+
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1197
|
+
scope: 'step',
|
|
1198
|
+
step: stepName,
|
|
1199
|
+
message: `Step '${stepName}': 'gate.on_expiry' is ignored without 'gate.timeout_seconds' — ` +
|
|
1200
|
+
`set a timeout, or remove 'gate.on_expiry'.`,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
// Dead notification ([F-A2-5]): reminder_seconds >= timeout_seconds means the FIRST
|
|
1204
|
+
// reminder occurrence would never fire before the enforce clock expires.
|
|
1205
|
+
if (typeof gate['reminder_seconds'] === 'number' &&
|
|
1206
|
+
typeof gate['timeout_seconds'] === 'number' &&
|
|
1207
|
+
gate['reminder_seconds'] >= gate['timeout_seconds']) {
|
|
1208
|
+
warnings.push({
|
|
1209
|
+
code: 'DEAD_GATE_CONFIG',
|
|
1210
|
+
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1211
|
+
scope: 'step',
|
|
1212
|
+
step: stepName,
|
|
1213
|
+
message: `Step '${stepName}': 'gate.reminder_seconds' (${String(gate['reminder_seconds'])}) ` +
|
|
1214
|
+
`>= 'gate.timeout_seconds' (${String(gate['timeout_seconds'])}) — the first reminder ` +
|
|
1215
|
+
`would never fire before the gate expires.`,
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1099
1218
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1219
|
+
}
|
|
1220
|
+
if ('uses_service' in step && typeof step['uses_service'] === 'string') {
|
|
1221
|
+
const services = doc['services'];
|
|
1222
|
+
if (typeof services !== 'object' ||
|
|
1223
|
+
services === null ||
|
|
1224
|
+
!(step['uses_service'] in services)) {
|
|
1225
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': uses_service '${step['uses_service']}' is not defined in 'services'`));
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
// Validate retry: backoff must be a recognised value when present.
|
|
1229
|
+
if (step['retry'] !== undefined) {
|
|
1230
|
+
if (typeof step['retry'] !== 'object' || step['retry'] === null) {
|
|
1231
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry' must be an object`));
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
const retry = step['retry'];
|
|
1235
|
+
// WARN (do not reject) on an unknown retry-block key — same non-breaking posture as the
|
|
1236
|
+
// step/workflow-level checks (issue #140). Noun overridden to 'retry' (not 'step') since
|
|
1237
|
+
// this is a nested block, not the step itself.
|
|
1238
|
+
warnings.push(...findUnknownKeys(retry, KNOWN_RETRY_KEYS, {
|
|
1108
1239
|
scope: 'step',
|
|
1240
|
+
code: 'UNKNOWN_RETRY_KEY',
|
|
1109
1241
|
step: stepName,
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
});
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1242
|
+
noun: 'retry',
|
|
1243
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, 'retry', key]),
|
|
1244
|
+
}));
|
|
1245
|
+
if ('backoff' in retry &&
|
|
1246
|
+
retry['backoff'] !== 'fixed' &&
|
|
1247
|
+
retry['backoff'] !== 'linear' &&
|
|
1248
|
+
retry['backoff'] !== 'exponential') {
|
|
1249
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.backoff' must be 'fixed', 'linear', or 'exponential'`));
|
|
1250
|
+
}
|
|
1251
|
+
if ('max_attempts' in retry &&
|
|
1252
|
+
(!Number.isInteger(retry['max_attempts']) || retry['max_attempts'] < 1)) {
|
|
1253
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_attempts' must be a positive integer`));
|
|
1254
|
+
}
|
|
1255
|
+
if ('base_delay_ms' in retry &&
|
|
1256
|
+
(typeof retry['base_delay_ms'] !== 'number' || retry['base_delay_ms'] < 0)) {
|
|
1257
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.base_delay_ms' must be a non-negative number`));
|
|
1258
|
+
}
|
|
1259
|
+
if ('max_delay_ms' in retry &&
|
|
1260
|
+
(typeof retry['max_delay_ms'] !== 'number' || retry['max_delay_ms'] < 0)) {
|
|
1261
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_delay_ms' must be a non-negative number`));
|
|
1262
|
+
}
|
|
1263
|
+
// --- issue #140: on_timeout / total_timeout_seconds --------------------------------
|
|
1264
|
+
// E3: on_timeout must be a boolean (kills the 'on_timeout: "true"' silent-inert case).
|
|
1265
|
+
if ('on_timeout' in retry && typeof retry['on_timeout'] !== 'boolean') {
|
|
1266
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout' must be a boolean`));
|
|
1267
|
+
}
|
|
1268
|
+
// E2: total_timeout_seconds must be a positive integer — same convention as
|
|
1269
|
+
// timeout_seconds (0 is rejected here at load; a hand-built definition bypassing the
|
|
1270
|
+
// loader may still set 0 and have the engine's resolveCapMs honor it as a present cap).
|
|
1271
|
+
if ('total_timeout_seconds' in retry &&
|
|
1272
|
+
(!Number.isInteger(retry['total_timeout_seconds']) ||
|
|
1273
|
+
retry['total_timeout_seconds'] <= 0)) {
|
|
1274
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.total_timeout_seconds' must be a positive integer`));
|
|
1275
|
+
}
|
|
1276
|
+
// E1: on_timeout: true requires idempotent: true — declared, never inferred. Strict
|
|
1277
|
+
// `=== true` on both loci, provably matching the engine's own conjunct.
|
|
1278
|
+
if (retry['on_timeout'] === true && step['idempotent'] !== true) {
|
|
1279
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout: true' requires 'idempotent: true' declared ` +
|
|
1280
|
+
`on the step — a timeout-retry can run concurrently with the still-in-flight ` +
|
|
1281
|
+
`original attempt, so the step must explicitly attest that any partial prior ` +
|
|
1282
|
+
`application is harmless to re-apply. Declare 'idempotent: true' or remove ` +
|
|
1283
|
+
`'on_timeout'.`));
|
|
1284
|
+
}
|
|
1285
|
+
// W5 (CAP-ONLY advisory — the on_timeout half of this is already an E1 hard error, so
|
|
1286
|
+
// it never reaches here as a warning): the total-time cap only bounds `execution: 'auto'`
|
|
1287
|
+
// dispatch — inert on any other step type that legally declares `retry:` today.
|
|
1288
|
+
if (step['execution'] !== 'auto' && retry['total_timeout_seconds'] !== undefined) {
|
|
1289
|
+
warnings.push({
|
|
1290
|
+
code: 'TOTAL_TIMEOUT_NON_AUTO',
|
|
1291
|
+
severity: resolveSeverity('TOTAL_TIMEOUT_NON_AUTO'),
|
|
1292
|
+
scope: 'step',
|
|
1293
|
+
step: stepName,
|
|
1294
|
+
message: `Step '${stepName}': 'retry.total_timeout_seconds' is inert on execution: ` +
|
|
1295
|
+
`'${String(step['execution'])}' steps — the cap only bounds 'execution: auto' ` +
|
|
1296
|
+
`dispatch, which is the only dispatch ever wrapped in a timeout.`,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
// issue #218 (extends the W5 family): the BARE-KEYS advisory — no explicit
|
|
1300
|
+
// total_timeout_seconds (that shape is W5's, above), but retry: is present on a step the
|
|
1301
|
+
// built-in dispatch path never wraps in a throwing retry loop at all. Complementary to
|
|
1302
|
+
// W5's own `!== undefined` conjunct on the SAME `execution !== 'auto'` gate, so for any
|
|
1303
|
+
// non-auto retry block that reaches this point (finalizer+retry and invalid-cap shapes
|
|
1304
|
+
// already hard-errored above; on_timeout: true already hard-errored via E1 unless
|
|
1305
|
+
// idempotent is also declared, which is itself rejected by the pre-existing
|
|
1306
|
+
// idempotent-non-auto check) exactly ONE of {W5, RETRY_INERT_NON_AUTO} ever fires — never
|
|
1307
|
+
// both, never neither.
|
|
1308
|
+
if (step['execution'] !== 'auto' && retry['total_timeout_seconds'] === undefined) {
|
|
1309
|
+
const isAgent = step['execution'] === 'agent';
|
|
1310
|
+
const message = isAgent
|
|
1311
|
+
? `Step '${stepName}': 'retry' is inert on execution: 'agent' steps — the built-in ` +
|
|
1312
|
+
`dispatch path never throws for agent steps, so this block can never mint a second ` +
|
|
1313
|
+
`attempt here (for schema-repair budgets, use the CLI drive's '--schema-retries' ` +
|
|
1314
|
+
`flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
|
|
1315
|
+
`config — a deliberate public-API capability, not an invalid one.`
|
|
1316
|
+
: `Step '${stepName}': 'retry' is inert on execution: '${String(step['execution'])}' ` +
|
|
1317
|
+
`steps — the built-in dispatch path never throws for these steps, so this block can ` +
|
|
1318
|
+
`never mint a second attempt here. An embedder-supplied throwing dispatcher may ` +
|
|
1319
|
+
`still consume this config — a deliberate public-API capability, not an invalid one.`;
|
|
1130
1320
|
warnings.push({
|
|
1131
|
-
code: '
|
|
1132
|
-
severity: resolveSeverity('
|
|
1321
|
+
code: 'RETRY_INERT_NON_AUTO',
|
|
1322
|
+
severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
|
|
1133
1323
|
scope: 'step',
|
|
1134
1324
|
step: stepName,
|
|
1135
|
-
message
|
|
1136
|
-
`or below its own effective per-attempt timeout (${effectivePerAttemptSeconds}s) ` +
|
|
1137
|
-
`— the cap can never cover a single full-length attempt, so a retry can never ` +
|
|
1138
|
-
`occur before the cap fires.`,
|
|
1325
|
+
message,
|
|
1139
1326
|
});
|
|
1140
1327
|
}
|
|
1328
|
+
// W1: on_timeout with an effective max_attempts of 1 (explicit OR absent, since the
|
|
1329
|
+
// loader admits an absent max_attempts and the engine then defaults it to 1) — there is
|
|
1330
|
+
// no second attempt for the opt-in to retry into.
|
|
1331
|
+
const effectiveMaxAttempts = typeof retry['max_attempts'] === 'number' ? retry['max_attempts'] : 1;
|
|
1332
|
+
if (retry['on_timeout'] === true && effectiveMaxAttempts === 1) {
|
|
1333
|
+
warnings.push({
|
|
1334
|
+
code: 'ON_TIMEOUT_SINGLE_ATTEMPT',
|
|
1335
|
+
severity: resolveSeverity('ON_TIMEOUT_SINGLE_ATTEMPT'),
|
|
1336
|
+
scope: 'step',
|
|
1337
|
+
step: stepName,
|
|
1338
|
+
message: `Step '${stepName}': 'retry.on_timeout: true' has no effect with an effective ` +
|
|
1339
|
+
`'max_attempts' of 1 — there is no second attempt to retry into.`,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
// W2: the cap can never cover even a single full-length attempt — (a) an EXPLICIT cap
|
|
1343
|
+
// below an EXPLICIT timeout_seconds, or (b) on_timeout: true with a cap at-or-below the
|
|
1344
|
+
// effective per-attempt timeout (retry-defeating: the opt-in can never yield a viable
|
|
1345
|
+
// second attempt). Both conditions require an EXPLICIT total_timeout_seconds — the
|
|
1346
|
+
// AMENDED default cap (the worst-case schedule) is, by construction, never below a
|
|
1347
|
+
// single attempt for max_attempts ≥ 2, so this never fires on the bare 3600s-default
|
|
1348
|
+
// population.
|
|
1349
|
+
const explicitCapSeconds = typeof retry['total_timeout_seconds'] === 'number'
|
|
1350
|
+
? retry['total_timeout_seconds']
|
|
1351
|
+
: undefined;
|
|
1352
|
+
if (explicitCapSeconds !== undefined) {
|
|
1353
|
+
const explicitTimeoutSeconds = typeof step['timeout_seconds'] === 'number' ? step['timeout_seconds'] : undefined;
|
|
1354
|
+
const effectivePerAttemptSeconds = explicitTimeoutSeconds ?? DEFAULT_EXECUTION_TIMEOUT_SECONDS;
|
|
1355
|
+
const belowExplicitAttempt = explicitTimeoutSeconds !== undefined && explicitCapSeconds < explicitTimeoutSeconds;
|
|
1356
|
+
const capTooTightForRetry = retry['on_timeout'] === true && explicitCapSeconds <= effectivePerAttemptSeconds;
|
|
1357
|
+
if (belowExplicitAttempt || capTooTightForRetry) {
|
|
1358
|
+
warnings.push({
|
|
1359
|
+
code: 'TOTAL_TIMEOUT_BELOW_ATTEMPT',
|
|
1360
|
+
severity: resolveSeverity('TOTAL_TIMEOUT_BELOW_ATTEMPT'),
|
|
1361
|
+
scope: 'step',
|
|
1362
|
+
step: stepName,
|
|
1363
|
+
message: `Step '${stepName}': 'retry.total_timeout_seconds: ${explicitCapSeconds}' is at ` +
|
|
1364
|
+
`or below its own effective per-attempt timeout (${effectivePerAttemptSeconds}s) ` +
|
|
1365
|
+
`— the cap can never cover a single full-length attempt, so a retry can never ` +
|
|
1366
|
+
`occur before the cap fires.`,
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1141
1370
|
}
|
|
1142
1371
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
}
|
|
1147
|
-
// Validate input_map: only valid on execution: auto steps (both uses_service and handler).
|
|
1148
|
-
if (step['input_map'] !== undefined) {
|
|
1149
|
-
if (step['execution'] !== 'auto') {
|
|
1150
|
-
errors.push(`Step '${stepName}': 'input_map' is only valid on execution: auto steps`);
|
|
1151
|
-
}
|
|
1152
|
-
else {
|
|
1153
|
-
validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, errors, 0);
|
|
1372
|
+
if ('service_method' in step &&
|
|
1373
|
+
!VALID_SERVICE_METHODS.has(step['service_method'])) {
|
|
1374
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`));
|
|
1154
1375
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1376
|
+
// Validate input_map: only valid on execution: auto steps (both uses_service and handler).
|
|
1377
|
+
if (step['input_map'] !== undefined) {
|
|
1378
|
+
if (step['execution'] !== 'auto') {
|
|
1379
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'input_map' is only valid on execution: auto steps`));
|
|
1380
|
+
}
|
|
1381
|
+
else {
|
|
1382
|
+
// issue #392: input_map's errors are minted deep inside a recursive walk that knows only
|
|
1383
|
+
// its path string, not the step's position. Collected here and suffixed on the way out,
|
|
1384
|
+
// so ONE step's error list never mixes positioned and bare messages — a reader seeing
|
|
1385
|
+
// "(step at line 12)" on three of five errors would reasonably wonder what is different about
|
|
1386
|
+
// the other two, and nothing is.
|
|
1387
|
+
const inputMapErrors = [];
|
|
1388
|
+
validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
|
|
1389
|
+
errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
|
|
1390
|
+
}
|
|
1168
1391
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1392
|
+
// Step config may hold any JSON value (scalars, arrays, nested objects). It is passed through
|
|
1393
|
+
// opaquely to handlers (context.config) and merged into adapter config for uses_service steps;
|
|
1394
|
+
// the adapter's config_schema (below) remains the real validator for uses_service config.
|
|
1395
|
+
// Validate step config against adapter config_schema (requires registry).
|
|
1396
|
+
if (step['config'] !== undefined && step['uses_service'] !== undefined) {
|
|
1397
|
+
const serviceName = step['uses_service'];
|
|
1398
|
+
const services = doc['services'];
|
|
1399
|
+
const service = services?.[serviceName];
|
|
1400
|
+
const adapterName = service?.['adapter'];
|
|
1401
|
+
const adapter = adapterName !== undefined ? registry?.getAdapter(adapterName) : undefined;
|
|
1402
|
+
if (adapter !== undefined && adapter.config_schema === undefined) {
|
|
1403
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'config' declared but adapter '${adapterName}' does not declare 'config_schema'`));
|
|
1404
|
+
}
|
|
1405
|
+
else if (adapter?.config_schema !== undefined) {
|
|
1406
|
+
const ajv = new Ajv();
|
|
1407
|
+
const valid = ajv.validate(adapter.config_schema, step['config']);
|
|
1408
|
+
if (!valid) {
|
|
1409
|
+
const errMessages = ajv.errors?.map((e) => e.message ?? '').join('; ') ?? 'unknown error';
|
|
1410
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': config validation failed against adapter config_schema: ${errMessages}`));
|
|
1411
|
+
}
|
|
1175
1412
|
}
|
|
1176
1413
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1414
|
+
// Validate uses_resources: each listed step ID must exist in the workflow.
|
|
1415
|
+
if (step['handler'] !== undefined && registry !== undefined) {
|
|
1416
|
+
const handlerName = step['handler'];
|
|
1417
|
+
const handler = registry.getHandler(handlerName);
|
|
1418
|
+
if (handler !== undefined && handler.uses_resources !== undefined) {
|
|
1419
|
+
for (const resourceStepId of handler.uses_resources) {
|
|
1420
|
+
if (!(resourceStepId in stepsRaw)) {
|
|
1421
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': handler '${handlerName}' declares uses_resources '${resourceStepId}' ` +
|
|
1422
|
+
`but no step with that ID exists in this workflow`));
|
|
1423
|
+
}
|
|
1187
1424
|
}
|
|
1188
1425
|
}
|
|
1189
1426
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1427
|
+
// Validate trigger_rule.
|
|
1428
|
+
if ('trigger_rule' in step) {
|
|
1429
|
+
if (!VALID_TRIGGER_RULES.has(step['trigger_rule'])) {
|
|
1430
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid trigger_rule '${String(step['trigger_rule'])}'; must be one of ${[...VALID_TRIGGER_RULES].join(', ')}`));
|
|
1431
|
+
}
|
|
1195
1432
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1433
|
+
// Validate depends_on: must be an array of existing step names.
|
|
1434
|
+
if ('depends_on' in step && step['depends_on'] !== undefined) {
|
|
1435
|
+
if (!Array.isArray(step['depends_on'])) {
|
|
1436
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'depends_on' must be an array`));
|
|
1437
|
+
}
|
|
1438
|
+
else {
|
|
1439
|
+
for (const dep of step['depends_on']) {
|
|
1440
|
+
if (typeof dep !== 'string') {
|
|
1441
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': depends_on entries must be strings`));
|
|
1442
|
+
}
|
|
1443
|
+
else if (dep === stepName) {
|
|
1444
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': a step cannot depend on itself`));
|
|
1445
|
+
}
|
|
1446
|
+
else if (!(dep in stepsRaw)) {
|
|
1447
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references unknown step '${dep}'`));
|
|
1448
|
+
}
|
|
1449
|
+
else if (stepsRaw[dep]['execution'] === 'finalizer') {
|
|
1450
|
+
// A domain step depending on a held-out finalizer would deadlock: the finalizer
|
|
1451
|
+
// never enters the eligible set, so this step never becomes eligible and the run
|
|
1452
|
+
// never seals.
|
|
1453
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references finalizer step '${dep}' — finalizers ` +
|
|
1454
|
+
`run at the terminal transition and are held out of the DAG; a step cannot depend on one.`));
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1201
1458
|
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1459
|
+
// Validate when: string | string[] of single-comparison/bare-path leaves (implicit AND).
|
|
1460
|
+
if ('when' in step && step['when'] !== undefined) {
|
|
1461
|
+
const rawWhen = step['when'];
|
|
1462
|
+
if (typeof rawWhen === 'string') {
|
|
1463
|
+
if (rawWhen.trim() === '') {
|
|
1464
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a non-empty string`));
|
|
1206
1465
|
}
|
|
1207
|
-
else
|
|
1208
|
-
|
|
1466
|
+
else {
|
|
1467
|
+
validateConditionLeaf('when', rawWhen, stepName, dependsOn, errors, withStepLine);
|
|
1209
1468
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1469
|
+
}
|
|
1470
|
+
else if (Array.isArray(rawWhen)) {
|
|
1471
|
+
if (rawWhen.length === 0) {
|
|
1472
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array must not be empty`));
|
|
1212
1473
|
}
|
|
1213
|
-
else
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1474
|
+
else {
|
|
1475
|
+
for (const leaf of rawWhen) {
|
|
1476
|
+
if (typeof leaf !== 'string' || leaf.trim() === '') {
|
|
1477
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array entries must be non-empty strings`));
|
|
1478
|
+
}
|
|
1479
|
+
else {
|
|
1480
|
+
validateConditionLeaf('when', leaf, stepName, dependsOn, errors, withStepLine);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1219
1483
|
}
|
|
1220
1484
|
}
|
|
1485
|
+
else {
|
|
1486
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a string or an array of strings`));
|
|
1487
|
+
}
|
|
1221
1488
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if (
|
|
1228
|
-
|
|
1489
|
+
// Validate abort_unless leaf shape (guard steps only; the LEGACY depends_on/run.params
|
|
1490
|
+
// reference check is when-only — but issue #220 §4c's `$settlement` one-hop check fires here
|
|
1491
|
+
// too, via the hoisted `dependsOn`, SCOPED to `$settlement.`-prefixed paths only).
|
|
1492
|
+
if (step['abort_unless'] !== undefined && step['execution'] === 'guard') {
|
|
1493
|
+
const rawAbort = step['abort_unless'];
|
|
1494
|
+
if (typeof rawAbort === 'string') {
|
|
1495
|
+
if (rawAbort.trim() === '') {
|
|
1496
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a non-empty string`));
|
|
1497
|
+
}
|
|
1498
|
+
else {
|
|
1499
|
+
validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, errors, withStepLine);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
else if (Array.isArray(rawAbort)) {
|
|
1503
|
+
if (rawAbort.length === 0) {
|
|
1504
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array must not be empty`));
|
|
1505
|
+
}
|
|
1506
|
+
else {
|
|
1507
|
+
for (const leaf of rawAbort) {
|
|
1508
|
+
if (typeof leaf !== 'string' || leaf.trim() === '') {
|
|
1509
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array entries must be non-empty strings`));
|
|
1510
|
+
}
|
|
1511
|
+
else {
|
|
1512
|
+
validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, errors, withStepLine);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1229
1516
|
}
|
|
1230
1517
|
else {
|
|
1231
|
-
|
|
1518
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a string or an array of strings`));
|
|
1232
1519
|
}
|
|
1233
1520
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1521
|
+
// Validate preconditions leaf shape (each must be a single comparison). Reference check is
|
|
1522
|
+
// `$settlement`-scoped only (issue #220 §4c) — a non-`$settlement` precondition has no
|
|
1523
|
+
// depends_on/run.params check (unchanged from before this PR).
|
|
1524
|
+
if (step['preconditions'] !== undefined) {
|
|
1525
|
+
const rawPre = step['preconditions'];
|
|
1526
|
+
if (!Array.isArray(rawPre)) {
|
|
1527
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' must be an array of strings`));
|
|
1237
1528
|
}
|
|
1238
1529
|
else {
|
|
1239
|
-
for (const leaf of
|
|
1530
|
+
for (const leaf of rawPre) {
|
|
1240
1531
|
if (typeof leaf !== 'string' || leaf.trim() === '') {
|
|
1241
|
-
errors.push(`Step '${stepName}': '
|
|
1532
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' entries must be non-empty strings`));
|
|
1242
1533
|
}
|
|
1243
1534
|
else {
|
|
1244
|
-
validateConditionLeaf('
|
|
1535
|
+
validateConditionLeaf('preconditions', leaf, stepName, dependsOn, errors, withStepLine);
|
|
1245
1536
|
}
|
|
1246
1537
|
}
|
|
1247
1538
|
}
|
|
1248
1539
|
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1540
|
+
// issue #362 — REJECT A PROVABLY-DEAD FAILURE CONDITION.
|
|
1541
|
+
//
|
|
1542
|
+
// `$settlement.<dep>.failed == true` under a trigger rule that structurally excludes a failed
|
|
1543
|
+
// `<dep>` can never be true. The trigger gate runs BEFORE the condition gate, and both
|
|
1544
|
+
// `all_success` and `none_failed` carry an explicit "no dep in failed_steps" conjunct — so if
|
|
1545
|
+
// the rule is satisfied, `<dep>` did not fail, and the condition is false by construction.
|
|
1546
|
+
//
|
|
1547
|
+
// The author's compensation step therefore never runs. It is not silent at runtime — the run
|
|
1548
|
+
// record says `trigger_rule_unsatisfiable` — but it names the RULE and the blocking dep, never
|
|
1549
|
+
// the condition the author wrote, so the diagnosis points away from the mistake. This is an
|
|
1550
|
+
// authoring-time error precisely because the fix is one word and no legitimate use of the
|
|
1551
|
+
// shape exists.
|
|
1552
|
+
dead_condition: {
|
|
1553
|
+
const rule = step['execution'] === 'guard' ? 'all_success' : (step['trigger_rule'] ?? 'all_success');
|
|
1554
|
+
// A guard may not declare `trigger_rule` at all (it is a prohibited field), so a guard that
|
|
1555
|
+
// declares one must NOT be able to suppress this check by doing so.
|
|
1556
|
+
const ruleDeclared = step['execution'] !== 'guard' && step['trigger_rule'] !== undefined;
|
|
1557
|
+
if (step['execution'] !== 'guard' && !VALID_TRIGGER_RULES.has(rule)) {
|
|
1558
|
+
break dead_condition; // an invalid rule already has its own error — adding noise helps nobody
|
|
1261
1559
|
}
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1560
|
+
if (rule !== 'all_success' && rule !== 'none_failed')
|
|
1561
|
+
break dead_condition;
|
|
1562
|
+
const asLeaves = (v) => Array.isArray(v)
|
|
1563
|
+
? v.filter((x) => typeof x === 'string')
|
|
1564
|
+
: typeof v === 'string'
|
|
1565
|
+
? [v]
|
|
1566
|
+
: [];
|
|
1567
|
+
const surfaces = [
|
|
1568
|
+
{ surface: 'when', leaves: asLeaves(step['when']) },
|
|
1569
|
+
// `abort_unless` is only validated (and only meaningful) on guards; on anything else the
|
|
1570
|
+
// loader leaves it alone and the engine never reads it.
|
|
1571
|
+
{
|
|
1572
|
+
surface: 'abort_unless',
|
|
1573
|
+
leaves: step['execution'] === 'guard' ? asLeaves(step['abort_unless']) : [],
|
|
1574
|
+
},
|
|
1575
|
+
// `preconditions` is collected for EVERY step kind, but it is INERT on a guard: the sole
|
|
1576
|
+
// `checkPreconditions` call site is `executeStep` (execution-loop.ts:1380), and a guard
|
|
1577
|
+
// goes through `executeGuardStep`, which evaluates only `abort_unless`. That is why the
|
|
1578
|
+
// guard arm's consequence below is forked — collapsing it back into one shared string
|
|
1579
|
+
// would make the error claim a wedge that cannot happen.
|
|
1580
|
+
//
|
|
1581
|
+
// Post-#369 a guard declaring `preconditions` is REFUSED outright by the guard block
|
|
1582
|
+
// above, so this arm now only ever fires ALONGSIDE that refusal: errors accumulate rather
|
|
1583
|
+
// than short-circuit, and the guard block runs first, so both messages reach the author
|
|
1584
|
+
// with the prohibition printed above this one. The arm is kept, not deleted — it is what
|
|
1585
|
+
// stops the dead-condition message from claiming a wedge that a guard cannot have, and a
|
|
1586
|
+
// definition reaching this code by any path other than a fresh YAML load (a
|
|
1587
|
+
// store-registered definition, an inline object) is never re-parsed and never sees the
|
|
1588
|
+
// prohibition at all.
|
|
1589
|
+
{ surface: 'preconditions', leaves: asLeaves(step['preconditions']) },
|
|
1590
|
+
];
|
|
1591
|
+
for (const { surface, leaves } of surfaces) {
|
|
1592
|
+
for (const leaf of leaves) {
|
|
1593
|
+
const split = splitComparison(leaf);
|
|
1594
|
+
// Two spellings are equally dead: the explicit `== true`, and the BARE PATH, which the
|
|
1595
|
+
// engine coerces with `Boolean()`. `preconditions` refuses bare paths anyway.
|
|
1596
|
+
const lhsPath = split.kind === 'comparison' && split.op === '==' && split.rhsRaw.trim() === 'true'
|
|
1597
|
+
? split.lhsPath
|
|
1598
|
+
: split.kind === 'path'
|
|
1599
|
+
? split.path
|
|
1600
|
+
: undefined;
|
|
1601
|
+
if (lhsPath === undefined)
|
|
1602
|
+
continue;
|
|
1603
|
+
// EXACTLY three segments. `$settlement.x.failed.deep` is also dead, but for a different
|
|
1604
|
+
// reason, so the trigger-rule remedy would be wrong advice there.
|
|
1605
|
+
const segments = lhsPath.trim().split('.');
|
|
1606
|
+
if (segments.length !== 3 ||
|
|
1607
|
+
segments[0] !== '$settlement' ||
|
|
1608
|
+
segments[2] !== 'failed') {
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
const dep = segments[1];
|
|
1612
|
+
// Without the dep actually being a dependency, the one-hop error fires on its own AND
|
|
1613
|
+
// the trigger gate returns true unconditionally for a step with no deps — so the leaf
|
|
1614
|
+
// is not dead-by-trigger here and the remedy would be false advice.
|
|
1615
|
+
if (!dependsOn.includes(dep))
|
|
1616
|
+
continue;
|
|
1617
|
+
const ruleText = ruleDeclared ? `'${rule}'` : `the default '${rule}'`;
|
|
1618
|
+
const guardPrecondition = step['execution'] === 'guard' && surface === 'preconditions';
|
|
1619
|
+
const consequence = surface === 'when'
|
|
1620
|
+
? `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`
|
|
1621
|
+
: surface === 'preconditions'
|
|
1622
|
+
? guardPrecondition
|
|
1623
|
+
? // NOT the wedge: an unevaluated condition cannot block anything.
|
|
1624
|
+
`the run behaves identically whether this condition is present or absent`
|
|
1625
|
+
: `the step never settles — the run WEDGES in a blocked envelope`
|
|
1626
|
+
: `the guard aborts the run on every execution`;
|
|
1627
|
+
if (step['execution'] === 'guard') {
|
|
1628
|
+
// Guards cannot declare a trigger rule, so the trigger-rule remedy is wrong advice
|
|
1629
|
+
// here. This is a v1 SCOPE narrowing, not an architectural statement — issue #366
|
|
1630
|
+
// carries the design question.
|
|
1631
|
+
//
|
|
1632
|
+
// The middle clause forks with the consequence: "by the time this is evaluated" is
|
|
1633
|
+
// itself false for `preconditions`, which a guard never evaluates at all.
|
|
1634
|
+
const cause = guardPrecondition
|
|
1635
|
+
? `and on an execution: guard step it is never evaluated at all: a guard evaluates ` +
|
|
1636
|
+
`only 'abort_unless', so this condition is inert (${consequence})`
|
|
1637
|
+
: `a guard runs under ${ruleText} and 'trigger_rule' is not a valid field on ` +
|
|
1638
|
+
`execution: guard steps, so '${dep}' has always succeeded by the time this is ` +
|
|
1639
|
+
`evaluated (${consequence})`;
|
|
1640
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — ${cause}. ` +
|
|
1641
|
+
`Guards run only when their dependencies succeeded; for work that must happen AFTER a ` +
|
|
1642
|
+
`failure, use an 'execution: finalizer' step (see issue #366 for widening guards).`));
|
|
1274
1643
|
}
|
|
1275
1644
|
else {
|
|
1276
|
-
|
|
1645
|
+
const remedies = ['all_done', 'one_failed'];
|
|
1646
|
+
if (new Set(dependsOn).size === 1)
|
|
1647
|
+
remedies.push('all_failed');
|
|
1648
|
+
const tail = new Set(dependsOn).size > 1
|
|
1649
|
+
? ` ('all_failed' fires only if EVERY dependency fails; 'one_success' only if at least one other dependency succeeds.)`
|
|
1650
|
+
: '';
|
|
1651
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — under ` +
|
|
1652
|
+
`${ruleText} trigger rule, '${dep}' can never be in failed_steps when this step is ` +
|
|
1653
|
+
`evaluated (${consequence}). To run this step when '${dep}' fails, set trigger_rule to ` +
|
|
1654
|
+
`one of: ${remedies.join(', ')}.${tail}`));
|
|
1277
1655
|
}
|
|
1278
1656
|
}
|
|
1279
1657
|
}
|
|
1280
1658
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1659
|
+
// Validate tools: only valid on execution: agent steps without handler.
|
|
1660
|
+
if (step['tools'] !== undefined &&
|
|
1661
|
+
(step['execution'] !== 'agent' || step['handler'] !== undefined)) {
|
|
1662
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`));
|
|
1283
1663
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1664
|
+
// issue #413: `tool_timeout` requires `tools`. It bounds ONE tool call inside the agentic
|
|
1665
|
+
// loop (run-agent.ts), and a step with no tools never enters that loop — so the key sits
|
|
1666
|
+
// there bounding nothing while its author believes tool calls are capped.
|
|
1667
|
+
//
|
|
1668
|
+
// An EMPTY list counts as missing, and that is not pedantry: run-agent gates the tools path
|
|
1669
|
+
// on `tools.length > 0`, so `tools: []` is exactly as toolless at runtime as no key at all.
|
|
1670
|
+
// This one helper is also the shape check's complement further down, which is what makes
|
|
1671
|
+
// "exactly one error" true by construction rather than by coincidence.
|
|
1672
|
+
//
|
|
1673
|
+
// NOT extended to non-array `tools` spellings — that is #391, still open. Under the
|
|
1674
|
+
// `!toolsMissing` complement below, a non-array `tools` still lets the shape check fire, so
|
|
1675
|
+
// nothing is silently exempted here.
|
|
1676
|
+
const toolsMissing = step['tools'] === undefined ||
|
|
1677
|
+
(Array.isArray(step['tools']) && step['tools'].length === 0);
|
|
1678
|
+
if (step['tool_timeout'] !== undefined && toolsMissing) {
|
|
1679
|
+
errors.push(withKeyLine(stepName, 'tool_timeout', `Step '${stepName}': 'tool_timeout' requires 'tools' (a declared, non-empty list) — ` +
|
|
1680
|
+
'without tool calls there is ' +
|
|
1681
|
+
'nothing for it to bound, so the step would carry a bound with nothing to bind. ' +
|
|
1682
|
+
"In realm's own drive each tool call is capped at tool_timeout seconds (default " +
|
|
1683
|
+
'30); declare at least one tool or remove the key.'));
|
|
1292
1684
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1685
|
+
// Validate tools: requires input_schema.
|
|
1686
|
+
if (step['tools'] !== undefined && step['input_schema'] === undefined) {
|
|
1687
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' requires 'input_schema' to be defined — the agentic loop needs a schema for final output extraction`));
|
|
1688
|
+
}
|
|
1689
|
+
// Validate tools: entries must be in server_id:tool_name format.
|
|
1690
|
+
if (step['tools'] !== undefined && Array.isArray(step['tools'])) {
|
|
1691
|
+
for (const entry of step['tools']) {
|
|
1692
|
+
if (!/^[^:]+:[^:]+$/.test(entry)) {
|
|
1693
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' must be in 'server_id:tool_name' format`));
|
|
1300
1694
|
}
|
|
1301
1695
|
}
|
|
1302
1696
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
// shape exists.
|
|
1316
|
-
dead_condition: {
|
|
1317
|
-
const rule = step['execution'] === 'guard' ? 'all_success' : (step['trigger_rule'] ?? 'all_success');
|
|
1318
|
-
// A guard may not declare `trigger_rule` at all (it is a prohibited field), so a guard that
|
|
1319
|
-
// declares one must NOT be able to suppress this check by doing so.
|
|
1320
|
-
const ruleDeclared = step['execution'] !== 'guard' && step['trigger_rule'] !== undefined;
|
|
1321
|
-
if (step['execution'] !== 'guard' && !VALID_TRIGGER_RULES.has(rule)) {
|
|
1322
|
-
break dead_condition; // an invalid rule already has its own error — adding noise helps nobody
|
|
1697
|
+
// issue #338: the check below only runs when an `mcp_servers` block EXISTS, so the absent-block
|
|
1698
|
+
// variant loaded clean — and every disclosure this loader has for tools lives inside that same
|
|
1699
|
+
// fork, so the corner produced no error, no warning, and a run where the declared tools were
|
|
1700
|
+
// simply never offered. ONE error per step, not one per entry: the entries are not individually
|
|
1701
|
+
// wrong, the workflow is.
|
|
1702
|
+
if (step['tools'] !== undefined &&
|
|
1703
|
+
Array.isArray(step['tools']) &&
|
|
1704
|
+
step['tools'].length > 0 &&
|
|
1705
|
+
!Array.isArray(doc['mcp_servers'])) {
|
|
1706
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': declares tools but the workflow defines no mcp_servers — no drive ` +
|
|
1707
|
+
`can ever offer these tools, so the declaration can never be satisfied. Define an ` +
|
|
1708
|
+
`mcp_servers block, or remove 'tools'.`));
|
|
1323
1709
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
:
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
// `abort_unless` is only validated (and only meaningful) on guards; on anything else the
|
|
1334
|
-
// loader leaves it alone and the engine never reads it.
|
|
1335
|
-
{
|
|
1336
|
-
surface: 'abort_unless',
|
|
1337
|
-
leaves: step['execution'] === 'guard' ? asLeaves(step['abort_unless']) : [],
|
|
1338
|
-
},
|
|
1339
|
-
// `preconditions` is collected for EVERY step kind, but it is INERT on a guard: the sole
|
|
1340
|
-
// `checkPreconditions` call site is `executeStep` (execution-loop.ts:1371), and a guard
|
|
1341
|
-
// goes through `executeGuardStep`, which evaluates only `abort_unless`. That is why the
|
|
1342
|
-
// guard arm's consequence below is forked — collapsing it back into one shared string
|
|
1343
|
-
// would make the error claim a wedge that cannot happen. (The inertness itself is a real
|
|
1344
|
-
// adjacent gap, tracked as issue #369; this check only refuses to lie about it.)
|
|
1345
|
-
{ surface: 'preconditions', leaves: asLeaves(step['preconditions']) },
|
|
1346
|
-
];
|
|
1347
|
-
for (const { surface, leaves } of surfaces) {
|
|
1348
|
-
for (const leaf of leaves) {
|
|
1349
|
-
const split = splitComparison(leaf);
|
|
1350
|
-
// Two spellings are equally dead: the explicit `== true`, and the BARE PATH, which the
|
|
1351
|
-
// engine coerces with `Boolean()`. `preconditions` refuses bare paths anyway.
|
|
1352
|
-
const lhsPath = split.kind === 'comparison' && split.op === '==' && split.rhsRaw.trim() === 'true'
|
|
1353
|
-
? split.lhsPath
|
|
1354
|
-
: split.kind === 'path'
|
|
1355
|
-
? split.path
|
|
1356
|
-
: undefined;
|
|
1357
|
-
if (lhsPath === undefined)
|
|
1358
|
-
continue;
|
|
1359
|
-
// EXACTLY three segments. `$settlement.x.failed.deep` is also dead, but for a different
|
|
1360
|
-
// reason, so the trigger-rule remedy would be wrong advice there.
|
|
1361
|
-
const segments = lhsPath.trim().split('.');
|
|
1362
|
-
if (segments.length !== 3 || segments[0] !== '$settlement' || segments[2] !== 'failed') {
|
|
1363
|
-
continue;
|
|
1364
|
-
}
|
|
1365
|
-
const dep = segments[1];
|
|
1366
|
-
// Without the dep actually being a dependency, the one-hop error fires on its own AND
|
|
1367
|
-
// the trigger gate returns true unconditionally for a step with no deps — so the leaf
|
|
1368
|
-
// is not dead-by-trigger here and the remedy would be false advice.
|
|
1369
|
-
if (!dependsOn.includes(dep))
|
|
1370
|
-
continue;
|
|
1371
|
-
const ruleText = ruleDeclared ? `'${rule}'` : `the default '${rule}'`;
|
|
1372
|
-
const guardPrecondition = step['execution'] === 'guard' && surface === 'preconditions';
|
|
1373
|
-
const consequence = surface === 'when'
|
|
1374
|
-
? `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`
|
|
1375
|
-
: surface === 'preconditions'
|
|
1376
|
-
? guardPrecondition
|
|
1377
|
-
? // NOT the wedge: an unevaluated condition cannot block anything.
|
|
1378
|
-
`the run behaves identically whether this condition is present or absent`
|
|
1379
|
-
: `the step never settles — the run WEDGES in a blocked envelope`
|
|
1380
|
-
: `the guard aborts the run on every execution`;
|
|
1381
|
-
if (step['execution'] === 'guard') {
|
|
1382
|
-
// Guards cannot declare a trigger rule, so the trigger-rule remedy is wrong advice
|
|
1383
|
-
// here. This is a v1 SCOPE narrowing, not an architectural statement — issue #366
|
|
1384
|
-
// carries the design question.
|
|
1385
|
-
//
|
|
1386
|
-
// The middle clause forks with the consequence: "by the time this is evaluated" is
|
|
1387
|
-
// itself false for `preconditions`, which a guard never evaluates at all.
|
|
1388
|
-
const cause = guardPrecondition
|
|
1389
|
-
? `and on an execution: guard step it is never evaluated at all: a guard evaluates ` +
|
|
1390
|
-
`only 'abort_unless', so this condition is inert (${consequence})`
|
|
1391
|
-
: `a guard runs under ${ruleText} and 'trigger_rule' is not a valid field on ` +
|
|
1392
|
-
`execution: guard steps, so '${dep}' has always succeeded by the time this is ` +
|
|
1393
|
-
`evaluated (${consequence})`;
|
|
1394
|
-
errors.push(`Step '${stepName}': '${surface}' condition "${leaf}" can never be true — ${cause}. ` +
|
|
1395
|
-
`Guards run only when their dependencies succeeded; for work that must happen AFTER a ` +
|
|
1396
|
-
`failure, use an 'execution: finalizer' step (see issue #366 for widening guards).`);
|
|
1397
|
-
}
|
|
1398
|
-
else {
|
|
1399
|
-
const remedies = ['all_done', 'one_failed'];
|
|
1400
|
-
if (new Set(dependsOn).size === 1)
|
|
1401
|
-
remedies.push('all_failed');
|
|
1402
|
-
const tail = new Set(dependsOn).size > 1
|
|
1403
|
-
? ` ('all_failed' fires only if EVERY dependency fails; 'one_success' only if at least one other dependency succeeds.)`
|
|
1404
|
-
: '';
|
|
1405
|
-
errors.push(`Step '${stepName}': '${surface}' condition "${leaf}" can never be true — under ` +
|
|
1406
|
-
`${ruleText} trigger rule, '${dep}' can never be in failed_steps when this step is ` +
|
|
1407
|
-
`evaluated (${consequence}). To run this step when '${dep}' fails, set trigger_rule to ` +
|
|
1408
|
-
`one of: ${remedies.join(', ')}.${tail}`);
|
|
1710
|
+
// Validate tools: server_id must reference a defined mcp_server.
|
|
1711
|
+
if (step['tools'] !== undefined &&
|
|
1712
|
+
Array.isArray(step['tools']) &&
|
|
1713
|
+
Array.isArray(doc['mcp_servers'])) {
|
|
1714
|
+
const serverIds = new Set(doc['mcp_servers'].map((s) => s.id));
|
|
1715
|
+
for (const entry of step['tools']) {
|
|
1716
|
+
const serverId = entry.split(':')[0] ?? '';
|
|
1717
|
+
if (!serverIds.has(serverId)) {
|
|
1718
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' references unknown MCP server '${serverId}'`));
|
|
1409
1719
|
}
|
|
1410
1720
|
}
|
|
1411
1721
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1722
|
+
// Validate max_tool_calls: must be a positive integer.
|
|
1723
|
+
if (step['max_tool_calls'] !== undefined &&
|
|
1724
|
+
(!Number.isInteger(step['max_tool_calls']) || step['max_tool_calls'] <= 0)) {
|
|
1725
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'max_tool_calls' must be a positive integer`));
|
|
1726
|
+
}
|
|
1727
|
+
// Validate max_fan_out: must be a positive integer.
|
|
1728
|
+
if (step['max_fan_out'] !== undefined &&
|
|
1729
|
+
(!Number.isInteger(step['max_fan_out']) || step['max_fan_out'] <= 0)) {
|
|
1730
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'max_fan_out' must be a positive integer`));
|
|
1731
|
+
}
|
|
1732
|
+
// Validate tool_timeout: must be a positive integer. Skipped where the key is not valid at
|
|
1733
|
+
// all (issue #413's requires-tools check above already reported that) — the same convention
|
|
1734
|
+
// as `timeout_seconds` below: an author told BOTH that the key does not belong here and that
|
|
1735
|
+
// its value has the wrong shape is being pointed at the shape, which is not the problem.
|
|
1736
|
+
// The `!toolsMissing` complement is the SAME helper the prohibition keys on, so the two are
|
|
1737
|
+
// exhaustive and disjoint by construction: `tools: []` with a negative value reports once.
|
|
1738
|
+
if (step['tool_timeout'] !== undefined &&
|
|
1739
|
+
!toolsMissing &&
|
|
1740
|
+
(!Number.isInteger(step['tool_timeout']) || step['tool_timeout'] <= 0)) {
|
|
1741
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'tool_timeout' must be a positive integer`));
|
|
1742
|
+
}
|
|
1743
|
+
// Validate timeout_seconds: must be a positive integer (issue A3). Skipped on
|
|
1744
|
+
// execution: guard — the guard-prohibited-fields check above already flatly rejects
|
|
1745
|
+
// 'timeout_seconds' there ('is not valid on execution: guard steps'); re-checking its
|
|
1746
|
+
// shape here would double-report the same root cause under a second, confusing message.
|
|
1747
|
+
// Same suppression for the agent prohibition (issue #402), for the same reason: an author
|
|
1748
|
+
// told BOTH that the key is invalid here and that its value has the wrong shape is being
|
|
1749
|
+
// pointed at the shape, which is not the problem.
|
|
1750
|
+
if (step['timeout_seconds'] !== undefined &&
|
|
1751
|
+
step['execution'] !== 'guard' &&
|
|
1752
|
+
step['execution'] !== 'agent' &&
|
|
1753
|
+
(!Number.isInteger(step['timeout_seconds']) || step['timeout_seconds'] <= 0)) {
|
|
1754
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'timeout_seconds' must be a positive integer`));
|
|
1428
1755
|
}
|
|
1429
1756
|
}
|
|
1430
|
-
//
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1757
|
+
// Require at least one non-finalizer step: a workflow of only finalizers is meaningless
|
|
1758
|
+
// (nothing runs in the DAG, so it would seal immediately with no domain work).
|
|
1759
|
+
const stepEntries = Object.values(stepsRaw).filter((s) => typeof s === 'object' && s !== null && !Array.isArray(s));
|
|
1760
|
+
if (stepEntries.length > 0 && stepEntries.every((s) => s['execution'] === 'finalizer')) {
|
|
1761
|
+
errors.push(`Workflow has only finalizer steps — at least one non-finalizer step is required ` +
|
|
1762
|
+
`(finalizers run at the terminal transition of the DAG's domain steps).`);
|
|
1763
|
+
}
|
|
1764
|
+
// Reject depends_on cycles (issue #153): a transitive cycle among otherwise-valid edges is
|
|
1765
|
+
// loadable today (the per-step check above only validates one hop at a time), and at runtime
|
|
1766
|
+
// the cyclic steps are mutually ineligible forever — the run silently seals `completed` with
|
|
1767
|
+
// the stranded steps in NO step set and zero evidence. Build the graph over VALID edges only
|
|
1768
|
+
// (dep exists, isn't self, isn't a finalizer) — the self/unknown/finalizer-dep cases are
|
|
1769
|
+
// already reported by the per-step check above; a real cycle runs entirely through valid
|
|
1770
|
+
// edges, so excluding the already-errored ones here avoids double-reporting them.
|
|
1771
|
+
const dependencyEdges = new Map();
|
|
1772
|
+
for (const stepName of Object.keys(stepsRaw)) {
|
|
1773
|
+
dependencyEdges.set(stepName, []);
|
|
1774
|
+
}
|
|
1775
|
+
for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
|
|
1776
|
+
if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw))
|
|
1777
|
+
continue;
|
|
1778
|
+
const dependsOn = stepRaw['depends_on'];
|
|
1779
|
+
if (!Array.isArray(dependsOn))
|
|
1780
|
+
continue;
|
|
1781
|
+
for (const dep of dependsOn) {
|
|
1782
|
+
if (typeof dep === 'string' &&
|
|
1783
|
+
dep !== stepName &&
|
|
1784
|
+
dep in stepsRaw &&
|
|
1785
|
+
stepsRaw[dep]['execution'] !== 'finalizer') {
|
|
1786
|
+
dependencyEdges.get(stepName).push(dep);
|
|
1439
1787
|
}
|
|
1440
1788
|
}
|
|
1441
1789
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
(!Number.isInteger(step['max_tool_calls']) || step['max_tool_calls'] <= 0)) {
|
|
1445
|
-
errors.push(`Step '${stepName}': 'max_tool_calls' must be a positive integer`);
|
|
1446
|
-
}
|
|
1447
|
-
// Validate max_fan_out: must be a positive integer.
|
|
1448
|
-
if (step['max_fan_out'] !== undefined &&
|
|
1449
|
-
(!Number.isInteger(step['max_fan_out']) || step['max_fan_out'] <= 0)) {
|
|
1450
|
-
errors.push(`Step '${stepName}': 'max_fan_out' must be a positive integer`);
|
|
1790
|
+
for (const cycle of detectDependencyCycles(dependencyEdges)) {
|
|
1791
|
+
errors.push(`Workflow has a dependency cycle: ${cycle.join(' → ')}`);
|
|
1451
1792
|
}
|
|
1452
|
-
// Validate
|
|
1453
|
-
if (
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
// shape here would double-report the same root cause under a second, confusing message.
|
|
1461
|
-
if (step['timeout_seconds'] !== undefined &&
|
|
1462
|
-
step['execution'] !== 'guard' &&
|
|
1463
|
-
(!Number.isInteger(step['timeout_seconds']) || step['timeout_seconds'] <= 0)) {
|
|
1464
|
-
errors.push(`Step '${stepName}': 'timeout_seconds' must be a positive integer`);
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
// Require at least one non-finalizer step: a workflow of only finalizers is meaningless
|
|
1468
|
-
// (nothing runs in the DAG, so it would seal immediately with no domain work).
|
|
1469
|
-
const stepEntries = Object.values(stepsRaw).filter((s) => typeof s === 'object' && s !== null && !Array.isArray(s));
|
|
1470
|
-
if (stepEntries.length > 0 && stepEntries.every((s) => s['execution'] === 'finalizer')) {
|
|
1471
|
-
errors.push(`Workflow has only finalizer steps — at least one non-finalizer step is required ` +
|
|
1472
|
-
`(finalizers run at the terminal transition of the DAG's domain steps).`);
|
|
1473
|
-
}
|
|
1474
|
-
// Reject depends_on cycles (issue #153): a transitive cycle among otherwise-valid edges is
|
|
1475
|
-
// loadable today (the per-step check above only validates one hop at a time), and at runtime
|
|
1476
|
-
// the cyclic steps are mutually ineligible forever — the run silently seals `completed` with
|
|
1477
|
-
// the stranded steps in NO step set and zero evidence. Build the graph over VALID edges only
|
|
1478
|
-
// (dep exists, isn't self, isn't a finalizer) — the self/unknown/finalizer-dep cases are
|
|
1479
|
-
// already reported by the per-step check above; a real cycle runs entirely through valid
|
|
1480
|
-
// edges, so excluding the already-errored ones here avoids double-reporting them.
|
|
1481
|
-
const dependencyEdges = new Map();
|
|
1482
|
-
for (const stepName of Object.keys(stepsRaw)) {
|
|
1483
|
-
dependencyEdges.set(stepName, []);
|
|
1484
|
-
}
|
|
1485
|
-
for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
|
|
1486
|
-
if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw))
|
|
1487
|
-
continue;
|
|
1488
|
-
const dependsOn = stepRaw['depends_on'];
|
|
1489
|
-
if (!Array.isArray(dependsOn))
|
|
1490
|
-
continue;
|
|
1491
|
-
for (const dep of dependsOn) {
|
|
1492
|
-
if (typeof dep === 'string' &&
|
|
1493
|
-
dep !== stepName &&
|
|
1494
|
-
dep in stepsRaw &&
|
|
1495
|
-
stepsRaw[dep]['execution'] !== 'finalizer') {
|
|
1496
|
-
dependencyEdges.get(stepName).push(dep);
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
for (const cycle of detectDependencyCycles(dependencyEdges)) {
|
|
1501
|
-
errors.push(`Workflow has a dependency cycle: ${cycle.join(' → ')}`);
|
|
1502
|
-
}
|
|
1503
|
-
// Validate mcp_servers: ids must be unique (workflow-level check).
|
|
1504
|
-
if (Array.isArray(doc['mcp_servers'])) {
|
|
1505
|
-
const seen = new Set();
|
|
1506
|
-
for (const server of doc['mcp_servers']) {
|
|
1507
|
-
if (seen.has(server.id)) {
|
|
1508
|
-
errors.push(`mcp_servers: duplicate server id '${server.id}'`);
|
|
1793
|
+
// Validate mcp_servers: ids must be unique (workflow-level check).
|
|
1794
|
+
if (Array.isArray(doc['mcp_servers'])) {
|
|
1795
|
+
const seen = new Set();
|
|
1796
|
+
for (const server of doc['mcp_servers']) {
|
|
1797
|
+
if (seen.has(server.id)) {
|
|
1798
|
+
errors.push(`mcp_servers: duplicate server id '${server.id}'`);
|
|
1799
|
+
}
|
|
1800
|
+
seen.add(server.id);
|
|
1509
1801
|
}
|
|
1510
|
-
seen.add(server.id);
|
|
1511
1802
|
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1803
|
+
// Validate services: Ajv-strict entry schema (closed key set) + rate_limit fields.
|
|
1804
|
+
if (typeof doc['services'] === 'object' && doc['services'] !== null) {
|
|
1805
|
+
for (const [serviceName, serviceRaw] of Object.entries(doc['services'])) {
|
|
1806
|
+
if (typeof serviceRaw !== 'object' || serviceRaw === null)
|
|
1807
|
+
continue;
|
|
1808
|
+
const service = serviceRaw;
|
|
1809
|
+
// PERMANENT targeted rejection — must win over the generic unknown-key error.
|
|
1810
|
+
if ('auth' in service || 'token_from' in service) {
|
|
1811
|
+
errors.push(`Service '${serviceName}': 'auth.token_from' was removed in v0.14.0 — bind ` +
|
|
1812
|
+
`credentials in your deployment manifest (realm.yaml); see the migration note.`);
|
|
1813
|
+
}
|
|
1814
|
+
else {
|
|
1815
|
+
const serviceAjv = new Ajv({ strict: true, allErrors: true });
|
|
1816
|
+
if (!serviceAjv.validate(SERVICE_ENTRY_JSON_SCHEMA, service)) {
|
|
1817
|
+
for (const err of serviceAjv.errors ?? []) {
|
|
1818
|
+
const detail = err.keyword === 'additionalProperties'
|
|
1819
|
+
? `unknown key '${String(err.params.additionalProperty)}'`
|
|
1820
|
+
: `${err.instancePath.replace(/^\//, '').replace(/\//g, '.') || 'entry'} ${err.message ?? 'invalid'}`;
|
|
1821
|
+
errors.push(`Service '${serviceName}': ${detail}`);
|
|
1822
|
+
}
|
|
1532
1823
|
}
|
|
1533
1824
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
continue;
|
|
1541
|
-
}
|
|
1542
|
-
const rl = rateLimit;
|
|
1543
|
-
if ('requests_per_second' in rl &&
|
|
1544
|
-
(!Number.isInteger(rl['requests_per_second']) || rl['requests_per_second'] < 1)) {
|
|
1545
|
-
errors.push(`Service '${serviceName}': 'rate_limit.requests_per_second' must be a positive integer (≥ 1)`);
|
|
1546
|
-
}
|
|
1547
|
-
if ('burst' in rl) {
|
|
1548
|
-
if (!Number.isInteger(rl['burst']) || rl['burst'] < 1) {
|
|
1549
|
-
errors.push(`Service '${serviceName}': 'rate_limit.burst' must be a positive integer (≥ 1)`);
|
|
1825
|
+
const rateLimit = service['rate_limit'];
|
|
1826
|
+
if (rateLimit === undefined)
|
|
1827
|
+
continue;
|
|
1828
|
+
if (typeof rateLimit !== 'object' || rateLimit === null) {
|
|
1829
|
+
errors.push(`Service '${serviceName}': 'rate_limit' must be an object`);
|
|
1830
|
+
continue;
|
|
1550
1831
|
}
|
|
1551
|
-
|
|
1552
|
-
|
|
1832
|
+
const rl = rateLimit;
|
|
1833
|
+
if ('requests_per_second' in rl &&
|
|
1834
|
+
(!Number.isInteger(rl['requests_per_second']) ||
|
|
1835
|
+
rl['requests_per_second'] < 1)) {
|
|
1836
|
+
errors.push(`Service '${serviceName}': 'rate_limit.requests_per_second' must be a positive integer (≥ 1)`);
|
|
1837
|
+
}
|
|
1838
|
+
if ('burst' in rl) {
|
|
1839
|
+
if (!Number.isInteger(rl['burst']) || rl['burst'] < 1) {
|
|
1840
|
+
errors.push(`Service '${serviceName}': 'rate_limit.burst' must be a positive integer (≥ 1)`);
|
|
1841
|
+
}
|
|
1842
|
+
if (!('requests_per_second' in rl)) {
|
|
1843
|
+
errors.push(`Service '${serviceName}': 'rate_limit.burst' requires 'rate_limit.requests_per_second' to be set`);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
if ('fallback_retry_seconds' in rl &&
|
|
1847
|
+
(typeof rl['fallback_retry_seconds'] !== 'number' ||
|
|
1848
|
+
rl['fallback_retry_seconds'] <= 0)) {
|
|
1849
|
+
errors.push(`Service '${serviceName}': 'rate_limit.fallback_retry_seconds' must be a positive number (> 0)`);
|
|
1850
|
+
}
|
|
1851
|
+
if ('min_retry_seconds' in rl &&
|
|
1852
|
+
(typeof rl['min_retry_seconds'] !== 'number' || rl['min_retry_seconds'] <= 0)) {
|
|
1853
|
+
errors.push(`Service '${serviceName}': 'rate_limit.min_retry_seconds' must be a positive number (> 0)`);
|
|
1854
|
+
}
|
|
1855
|
+
if ('max_retry_seconds' in rl &&
|
|
1856
|
+
(!Number.isInteger(rl['max_retry_seconds']) || rl['max_retry_seconds'] < 1)) {
|
|
1857
|
+
errors.push(`Service '${serviceName}': 'rate_limit.max_retry_seconds' must be a positive integer (≥ 1)`);
|
|
1553
1858
|
}
|
|
1554
|
-
}
|
|
1555
|
-
if ('fallback_retry_seconds' in rl &&
|
|
1556
|
-
(typeof rl['fallback_retry_seconds'] !== 'number' ||
|
|
1557
|
-
rl['fallback_retry_seconds'] <= 0)) {
|
|
1558
|
-
errors.push(`Service '${serviceName}': 'rate_limit.fallback_retry_seconds' must be a positive number (> 0)`);
|
|
1559
|
-
}
|
|
1560
|
-
if ('min_retry_seconds' in rl &&
|
|
1561
|
-
(typeof rl['min_retry_seconds'] !== 'number' || rl['min_retry_seconds'] <= 0)) {
|
|
1562
|
-
errors.push(`Service '${serviceName}': 'rate_limit.min_retry_seconds' must be a positive number (> 0)`);
|
|
1563
|
-
}
|
|
1564
|
-
if ('max_retry_seconds' in rl &&
|
|
1565
|
-
(!Number.isInteger(rl['max_retry_seconds']) || rl['max_retry_seconds'] < 1)) {
|
|
1566
|
-
errors.push(`Service '${serviceName}': 'rate_limit.max_retry_seconds' must be a positive integer (≥ 1)`);
|
|
1567
1859
|
}
|
|
1568
1860
|
}
|
|
1861
|
+
// Step 3b: Trigger block validation (schema-driven — see trigger-schema.ts)
|
|
1862
|
+
const triggerRaw = doc['trigger'];
|
|
1863
|
+
if (triggerRaw !== undefined) {
|
|
1864
|
+
normalizeTriggerFilter(triggerRaw); // canonicalise shorthand BEFORE validation
|
|
1865
|
+
errors.push(...validateTriggerStructure(triggerRaw));
|
|
1866
|
+
}
|
|
1867
|
+
if (errors.length > 0) {
|
|
1868
|
+
throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
|
|
1869
|
+
// issue #425: the pre-join strings — see the profile collector above.
|
|
1870
|
+
errors: [...errors],
|
|
1871
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
1872
|
+
category: 'VALIDATION',
|
|
1873
|
+
agentAction: 'report_to_user',
|
|
1874
|
+
retryable: false,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
// Step 4: Stamp schema version and return typed result
|
|
1878
|
+
const definition = doc;
|
|
1879
|
+
definition.schema_version = CURRENT_WORKFLOW_SCHEMA_VERSION;
|
|
1880
|
+
return { definition, warnings };
|
|
1569
1881
|
}
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
errors.push(...validateTriggerStructure(triggerRaw));
|
|
1575
|
-
}
|
|
1576
|
-
if (errors.length > 0) {
|
|
1577
|
-
throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
|
|
1578
|
-
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
1579
|
-
category: 'VALIDATION',
|
|
1580
|
-
agentAction: 'report_to_user',
|
|
1581
|
-
retryable: false,
|
|
1582
|
-
});
|
|
1882
|
+
catch (err) {
|
|
1883
|
+
if (err instanceof WorkflowError)
|
|
1884
|
+
attachLoaderWarnings(err, warnings);
|
|
1885
|
+
throw err;
|
|
1583
1886
|
}
|
|
1584
|
-
// Step 4: Stamp schema version and return typed result
|
|
1585
|
-
const definition = doc;
|
|
1586
|
-
definition.schema_version = CURRENT_WORKFLOW_SCHEMA_VERSION;
|
|
1587
|
-
return { definition, warnings };
|
|
1588
1887
|
}
|
|
1589
1888
|
/** Returns true if any step in the raw steps map declares use_template. */
|
|
1590
1889
|
function hasUseTemplateInSteps(steps) {
|