@sensigo/realm 0.40.0 → 0.42.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/adapters/gorgias-adapter.d.ts.map +1 -1
- package/dist/adapters/gorgias-adapter.js +39 -10
- package/dist/adapters/gorgias-adapter.js.map +1 -1
- package/dist/engine/execution-loop.d.ts.map +1 -1
- package/dist/engine/execution-loop.js +124 -21
- 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 +49 -4
- package/dist/engine/run-health.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/types/run-record.d.ts +11 -1
- package/dist/types/run-record.d.ts.map +1 -1
- package/dist/types/run-record.js.map +1 -1
- package/dist/types/workflow-definition.d.ts +146 -10
- package/dist/types/workflow-definition.d.ts.map +1 -1
- package/dist/types/workflow-definition.js +225 -0
- package/dist/types/workflow-definition.js.map +1 -1
- package/dist/types/workflow-error.d.ts +45 -1
- 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 +19 -15
- package/dist/workflow/diagnostics.d.ts.map +1 -1
- package/dist/workflow/diagnostics.js +19 -28
- package/dist/workflow/diagnostics.js.map +1 -1
- package/dist/workflow/registrar.d.ts +68 -0
- package/dist/workflow/registrar.d.ts.map +1 -1
- package/dist/workflow/registrar.js +92 -5
- package/dist/workflow/registrar.js.map +1 -1
- package/dist/workflow/step-key-registry.d.ts +1258 -0
- package/dist/workflow/step-key-registry.d.ts.map +1 -0
- package/dist/workflow/step-key-registry.js +1217 -0
- package/dist/workflow/step-key-registry.js.map +1 -0
- package/dist/workflow/yaml-loader.d.ts +33 -0
- package/dist/workflow/yaml-loader.d.ts.map +1 -1
- package/dist/workflow/yaml-loader.js +1485 -1242
- package/dist/workflow/yaml-loader.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import { dirname, resolve, join, isAbsolute } from 'node:path';
|
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { load } from 'js-yaml';
|
|
6
6
|
import { Ajv } from 'ajv';
|
|
7
|
-
import { KNOWN_STEP_KEYS, KNOWN_WORKFLOW_KEYS, KNOWN_RETRY_KEYS, KNOWN_GATE_KEYS, } from '../types/workflow-definition.js';
|
|
7
|
+
import { KNOWN_STEP_KEYS, KNOWN_WORKFLOW_KEYS, KNOWN_RETRY_KEYS, KNOWN_GATE_KEYS, SERVICE_TRUST_LEVELS, isGateTrust, classifyStepTrust, buildTrustRefusal, renderTrustValue, } 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
10
|
import { createSourcePositionCollector } from './source-positions.js';
|
|
@@ -14,12 +14,39 @@ import { splitComparison, isPathShaped } from '../engine/comparison-expr.js';
|
|
|
14
14
|
import { DEFAULT_EXECUTION_TIMEOUT_SECONDS } from '../engine/claim-liveness.js';
|
|
15
15
|
import { validateOutputSchema } from '../validation/input-schema.js';
|
|
16
16
|
import { assessStructuredOutputEligibility, renderIneligibleMessage, } from './structured-output-eligibility.js';
|
|
17
|
+
import { STEP_KEY_REGISTRY, CONSUMED_HOME, prohibitedKeysFor, consumedKindsFor, homeText, } from './step-key-registry.js';
|
|
18
|
+
/** #517 (the drive-flip): render ONE minted kind-prohibition from registry data. message_data
|
|
19
|
+
* cells render their recorded bespoke text verbatim (byte-identical to the pre-flip checks —
|
|
20
|
+
* golden-proven at the flip). Generic cells render the per-cell FRONT clause (the two-shape
|
|
21
|
+
* truth: 'not_valid' keeps the old loop grammar against THIS kind; 'only_valid' keeps the old
|
|
22
|
+
* twin grammar against the DERIVED consumed-kind set) and append the rung-2 consequence clause
|
|
23
|
+
* from CONSUMED_HOME — witness-backed message truth (its claims are conformance-tested data). */
|
|
24
|
+
function renderRegistryProhibition(key, kind, cell) {
|
|
25
|
+
if (cell.message_data !== undefined)
|
|
26
|
+
return cell.message_data;
|
|
27
|
+
const home = CONSUMED_HOME[key];
|
|
28
|
+
/* istanbul ignore next -- conformance guarantees totality over generic minted keys */
|
|
29
|
+
if (home === undefined)
|
|
30
|
+
return `'${key}' is not valid on execution: ${kind} steps`;
|
|
31
|
+
const front = cell.front === 'only_valid'
|
|
32
|
+
? `'${key}' is only valid on execution: ${consumedKindsFor(key).join('/')} steps`
|
|
33
|
+
: `'${key}' is not valid on execution: ${kind} steps`;
|
|
34
|
+
return `${front} — ${homeText(home.mechanism, kind)} ${homeText(home.remedy, kind)}`;
|
|
35
|
+
}
|
|
17
36
|
/**
|
|
18
37
|
* Validate one condition leaf at load time using the shared quote-aware splitter (the SAME split
|
|
19
38
|
* used at runtime). Rejects compound `and`/`or`, multiple operators, and non-path LHS. For `when`,
|
|
20
39
|
* also enforces the direct-`depends_on` reference check (Change 2). Pushes actionable errors.
|
|
21
40
|
*/
|
|
22
|
-
function validateConditionLeaf(surface, leaf, stepName, dependsOn,
|
|
41
|
+
function validateConditionLeaf(surface, leaf, stepName, dependsOn,
|
|
42
|
+
/**
|
|
43
|
+
* The step's declared `execution` kind (undefined when missing/malformed — those steps are
|
|
44
|
+
* already refused by the invalid-execution error, and the remedy below keeps its generic
|
|
45
|
+
* form). Threaded through so `validateWhenReference` can fork its remedy tail on the
|
|
46
|
+
* registry's own `depends_on` cell — a kind where `depends_on` is prohibited must never be
|
|
47
|
+
* told to add one (the wrong-remedy composition class this correction fixes).
|
|
48
|
+
*/
|
|
49
|
+
kind, errors,
|
|
23
50
|
/**
|
|
24
51
|
* Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
|
|
25
52
|
* the compiler names every call site if this ever gains another one — an omitted resolver
|
|
@@ -68,7 +95,7 @@ withLine) {
|
|
|
68
95
|
return;
|
|
69
96
|
}
|
|
70
97
|
if (surface === 'when')
|
|
71
|
-
validateWhenReference(split.path, stepName, dependsOn, errors, withLine);
|
|
98
|
+
validateWhenReference(split.path, stepName, dependsOn, kind, errors, withLine);
|
|
72
99
|
return;
|
|
73
100
|
}
|
|
74
101
|
// comparison
|
|
@@ -81,7 +108,7 @@ withLine) {
|
|
|
81
108
|
return;
|
|
82
109
|
}
|
|
83
110
|
if (surface === 'when')
|
|
84
|
-
validateWhenReference(split.lhsPath, stepName, dependsOn, errors, withLine);
|
|
111
|
+
validateWhenReference(split.lhsPath, stepName, dependsOn, kind, errors, withLine);
|
|
85
112
|
}
|
|
86
113
|
/**
|
|
87
114
|
* issue #220 §4c (PR-3): validates a `$settlement.<dep>.<field>` reference reached from ANY of
|
|
@@ -133,7 +160,9 @@ withLine) {
|
|
|
133
160
|
* step in this step's DIRECT `depends_on` (one-hop membership — no graph traversal). Field names are
|
|
134
161
|
* not checked (agent-step outputs aren't statically declared).
|
|
135
162
|
*/
|
|
136
|
-
function validateWhenReference(path, stepName, dependsOn,
|
|
163
|
+
function validateWhenReference(path, stepName, dependsOn,
|
|
164
|
+
/** @see validateConditionLeaf — forks the remedy tail on the registry's `depends_on` cell. */
|
|
165
|
+
kind, errors,
|
|
137
166
|
/**
|
|
138
167
|
* Appends the step's source line to a message (issue #392). REQUIRED rather than optional so
|
|
139
168
|
* the compiler names every call site if this ever gains another one — an omitted resolver
|
|
@@ -149,7 +178,16 @@ withLine) {
|
|
|
149
178
|
return;
|
|
150
179
|
}
|
|
151
180
|
if (!dependsOn.includes(first)) {
|
|
152
|
-
|
|
181
|
+
// The remedy's first arm is forked on the registry's own depends_on cell: on a kind where
|
|
182
|
+
// depends_on is prohibited (today exactly finalizer), 'Add it to depends_on' is a dead
|
|
183
|
+
// pointer — following it mints a second refusal (probe-executed; the wrong-remedy
|
|
184
|
+
// composition class this correction fixes). Derived from the cell so the fork can never
|
|
185
|
+
// drift from the mint.
|
|
186
|
+
const dependsOnLegal = kind === undefined || STEP_KEY_REGISTRY.depends_on[kind].c === 'consumed';
|
|
187
|
+
const remedyTail = dependsOnLegal
|
|
188
|
+
? `Add it to depends_on or use 'run.params.*'.`
|
|
189
|
+
: `Use 'run.params.*' — 'depends_on' is not valid on this step's kind.`;
|
|
190
|
+
errors.push(withLine(stepName, `Step '${stepName}': 'when' references step '${first}' which is not in its depends_on [${dependsOn.join(', ')}]. ${remedyTail}`));
|
|
153
191
|
}
|
|
154
192
|
}
|
|
155
193
|
/** Bumped on every breaking change to WorkflowDefinition's serialized format. */
|
|
@@ -166,11 +204,20 @@ const SERVICE_ENTRY_JSON_SCHEMA = {
|
|
|
166
204
|
required: ['adapter'],
|
|
167
205
|
properties: {
|
|
168
206
|
adapter: { type: 'string', minLength: 1 },
|
|
169
|
-
trust: { enum: [
|
|
207
|
+
trust: { enum: [...SERVICE_TRUST_LEVELS] },
|
|
170
208
|
rate_limit: { type: 'object' },
|
|
171
209
|
},
|
|
172
210
|
};
|
|
173
211
|
const VALID_EXECUTIONS = new Set(['auto', 'agent', 'guard', 'finalizer']);
|
|
212
|
+
// issue #517 (the drive-flip): the two kind-prohibition sets are DERIVED from the consumption
|
|
213
|
+
// registry — every key whose cell on the kind is prohibited WITHOUT an except arm. Their meaning
|
|
214
|
+
// upgraded with #517 from "the loop's array" to "the prohibited set": they now also carry the
|
|
215
|
+
// keys whose refusals used to live in per-key only-valid-on checks (guard 12→20 members,
|
|
216
|
+
// finalizer 13→19), and their declared type widened from a literal tuple to a computed readonly
|
|
217
|
+
// array — both disclosed in the changelog. The prohibition loops that consumed the old literal
|
|
218
|
+
// arrays are deleted; the registry-driven mint below is the single enforcement mechanism.
|
|
219
|
+
export const FINALIZER_PROHIBITED_STEP_KEYS = prohibitedKeysFor('finalizer');
|
|
220
|
+
export const GUARD_PROHIBITED_STEP_KEYS = prohibitedKeysFor('guard');
|
|
174
221
|
const VALID_FINALIZER_TRIGGERS = new Set([
|
|
175
222
|
'complete',
|
|
176
223
|
'fail',
|
|
@@ -248,33 +295,41 @@ export function findTrustRoot(dir) {
|
|
|
248
295
|
}
|
|
249
296
|
}
|
|
250
297
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
298
|
+
* issue #424 — attaches the live loader warnings to an error on its way out.
|
|
299
|
+
*
|
|
300
|
+
* Two chokepoints call this (one in `parseWorkflowString`, one in `loadWorkflowFromFileCore`),
|
|
301
|
+
* which is why it exists rather than each throw site building its own error with a `warnings`
|
|
302
|
+
* option: there are eleven throw sites across this file plus four more in template-resolver.ts,
|
|
303
|
+
* the warnings array is out of scope at most of them, and a chokepoint covers every future one
|
|
304
|
+
* for free. The non-empty guard makes the classification automatic — a throw that happens before
|
|
305
|
+
* any warning could exist attaches nothing, by construction rather than by a rule someone has to
|
|
306
|
+
* remember.
|
|
307
|
+
*
|
|
308
|
+
* Attach-once: an inner chokepoint's attachment survives the outer one re-catching the same
|
|
309
|
+
* error, so a file-based load reports the warnings from the parse that produced it rather than
|
|
310
|
+
* an emptier outer set.
|
|
258
311
|
*/
|
|
259
|
-
function
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
312
|
+
export function attachLoaderWarnings(err, warnings) {
|
|
313
|
+
if (warnings.length === 0)
|
|
314
|
+
return;
|
|
315
|
+
if (err.warnings !== undefined)
|
|
316
|
+
return;
|
|
317
|
+
err.warnings = warnings;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Resolves every `agent_profile` a definition declares against `<workflowDir>/<profiles_dir>`
|
|
321
|
+
* (default `profiles/`), stamping `resolved_profiles` on the definition and refusing — one
|
|
322
|
+
* `Invalid workflow:` error, one entry per missing profile (issue #425) — when any is absent.
|
|
323
|
+
*
|
|
324
|
+
* Exported (issue #553) because this is the ONE check a workflow's text cannot answer: it needs
|
|
325
|
+
* the source tree. `loadWorkflowFromFileCore` calls it with the file's directory; `validate
|
|
326
|
+
* --registered` calls it with the `source_dir` the registrar recorded, so the stored copy is
|
|
327
|
+
* audited by the same rule instead of by a synthesized file path (#493's snapshot doctrine: a
|
|
328
|
+
* synthesized path would audit the FILE, not the stored copy). Semantics are those of the
|
|
329
|
+
* former inline loop, byte for byte — the `Searched: <path>` sentence included.
|
|
330
|
+
* @throws WorkflowError (`VALIDATION_WORKFLOW_SCHEMA`) naming every missing profile.
|
|
331
|
+
*/
|
|
332
|
+
export function resolveAgentProfiles(definition, workflowDir) {
|
|
278
333
|
const profilesDir = definition.profiles_dir !== undefined
|
|
279
334
|
? resolve(workflowDir, definition.profiles_dir)
|
|
280
335
|
: join(workflowDir, 'profiles');
|
|
@@ -300,6 +355,9 @@ function loadWorkflowFromFileCore(filePath, registry) {
|
|
|
300
355
|
}
|
|
301
356
|
if (profileErrors.length > 0) {
|
|
302
357
|
throw new WorkflowError(`Invalid workflow: ${profileErrors.join('; ')}`, {
|
|
358
|
+
// issue #425: the pre-join strings, so a render can list them one per line. Two missing
|
|
359
|
+
// profiles are two problems, not one long sentence.
|
|
360
|
+
errors: [...profileErrors],
|
|
303
361
|
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
304
362
|
category: 'VALIDATION',
|
|
305
363
|
agentAction: 'report_to_user',
|
|
@@ -309,71 +367,89 @@ function loadWorkflowFromFileCore(filePath, registry) {
|
|
|
309
367
|
if (Object.keys(resolvedProfiles).length > 0) {
|
|
310
368
|
definition.resolved_profiles = resolvedProfiles;
|
|
311
369
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Pure core of loadWorkflowFromFile (issue #169): parses + resolves everything a file-based load
|
|
373
|
+
* needs, but never prints and never chooses between the two public presentations — it always
|
|
374
|
+
* returns the definition alongside every collected LoaderWarning. `loadWorkflowFromFile` (prints
|
|
375
|
+
* via renderLoaderWarning, returns just the definition) and `loadWorkflowFromFileWithDiagnostics`
|
|
376
|
+
* (prints nothing, returns both) are both thin wrappers over this.
|
|
377
|
+
*
|
|
378
|
+
* The #169-era "byte-identical default behavior" claim was retired in issue #444 (2026-08-31):
|
|
379
|
+
* renderLoaderWarning now prefixes `⚠ ` for every code, so this printer's advisory lines gained
|
|
380
|
+
* the prefix they lacked. The SHAPE of the contract is unchanged — one printing wrapper, one
|
|
381
|
+
* silent one — and the text after the prefix is untouched.
|
|
382
|
+
* @throws WorkflowError on read failure or structural validation errors.
|
|
383
|
+
*/
|
|
384
|
+
function loadWorkflowFromFileCore(filePath, registry) {
|
|
385
|
+
let content;
|
|
386
|
+
try {
|
|
387
|
+
content = readFileSync(filePath, 'utf8');
|
|
323
388
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
389
|
+
catch (err) {
|
|
390
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
391
|
+
throw new WorkflowError(`Failed to read workflow file: ${message}`, {
|
|
392
|
+
code: 'RESOURCE_FETCH_FAILED',
|
|
393
|
+
category: 'RESOURCE',
|
|
394
|
+
agentAction: 'report_to_user',
|
|
395
|
+
retryable: false,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
const { definition, warnings } = parseWorkflowString(content, registry, {
|
|
399
|
+
allowExtensions: true,
|
|
400
|
+
});
|
|
401
|
+
// issue #424 — CHOKEPOINT 2. Everything past the parse can throw with `warnings` already
|
|
402
|
+
// populated (a missing agent_profile, a bad workflow_context source), and those warnings used
|
|
403
|
+
// to unwind with the stack: `register` on a file with a typo AND a missing profile printed the
|
|
404
|
+
// profile error alone, so the author fixed it, re-ran, and only then learned about the typo.
|
|
405
|
+
// The parse call itself is deliberately OUTSIDE this try — chokepoint 1 inside
|
|
406
|
+
// `parseWorkflowString` owns those throws and has already attached, and attach-once means its
|
|
407
|
+
// richer set survives.
|
|
408
|
+
try {
|
|
409
|
+
// Resolve agent profiles — only possible when we have a source tree. Every check in this
|
|
410
|
+
// file-only block is DELEGATED to a named exported resolver (issue #553): `validate
|
|
411
|
+
// --registered` supplies the recorded source tree to the same function, so the stored copy
|
|
412
|
+
// is audited by the rule register applied, not by a paraphrase. An inline `throw` here is
|
|
413
|
+
// exactly what admission-context.test.ts (cli) refuses.
|
|
414
|
+
const workflowDir = dirname(resolve(filePath));
|
|
415
|
+
resolveAgentProfiles(definition, workflowDir);
|
|
416
|
+
// Resolve workflow_context entry paths. The four context-free rules (context_wrapper enum,
|
|
417
|
+
// `.raw` names, the name charset, `source.path` required) live in `parseWorkflowString`
|
|
418
|
+
// Step 3c since issue #553 — every surface, file or string, refuses them identically. Only
|
|
419
|
+
// the TRANSFORM needs `workflowDir`, so only the transform is here.
|
|
420
|
+
if (definition.workflow_context !== undefined) {
|
|
421
|
+
for (const entry of Object.values(definition.workflow_context)) {
|
|
422
|
+
entry.source.path = resolve(workflowDir, entry.source.path);
|
|
352
423
|
}
|
|
353
|
-
// Resolve relative path to absolute.
|
|
354
|
-
entry.source.path = resolve(workflowDir, rawSource['path']);
|
|
355
424
|
}
|
|
425
|
+
// Auto-register schema.json if present and not explicitly declared.
|
|
426
|
+
const schemaPath = join(workflowDir, 'schema.json');
|
|
427
|
+
if (existsSync(schemaPath) && definition.workflow_context?.['schema'] === undefined) {
|
|
428
|
+
definition.workflow_context ??= {};
|
|
429
|
+
definition.workflow_context['schema'] = {
|
|
430
|
+
source: { path: schemaPath },
|
|
431
|
+
description: 'Auto-registered schema.json from workflow directory',
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
// Resolution metadata: stamped for EVERY file-loaded definition (v0.14) — trust_root is
|
|
435
|
+
// the deployment-manifest anchor (`<trust_root>/realm.yaml`), needed by extension-free
|
|
436
|
+
// workflows that consume manifest-constructed adapters by name. Core resolves/stores
|
|
437
|
+
// PATHS only — it never imports modules or reads the manifest; that is the CLI's job.
|
|
438
|
+
// `validate --registered` reads these two back to supply the source tree the stored copy's
|
|
439
|
+
// context-dependent checks need (issue #553).
|
|
440
|
+
definition.source_dir = workflowDir;
|
|
441
|
+
definition.trust_root = findTrustRoot(workflowDir);
|
|
442
|
+
if (definition.extensions !== undefined) {
|
|
443
|
+
definition.extensions =
|
|
444
|
+
typeof definition.extensions === 'string' ? [definition.extensions] : definition.extensions;
|
|
445
|
+
}
|
|
446
|
+
definition.origin = 'human';
|
|
356
447
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
definition.workflow_context['schema'] = {
|
|
362
|
-
source: { path: schemaPath },
|
|
363
|
-
description: 'Auto-registered schema.json from workflow directory',
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
// Resolution metadata: stamped for EVERY file-loaded definition (v0.14) — trust_root is
|
|
367
|
-
// the deployment-manifest anchor (`<trust_root>/realm.yaml`), needed by extension-free
|
|
368
|
-
// workflows that consume manifest-constructed adapters by name. Core resolves/stores
|
|
369
|
-
// PATHS only — it never imports modules or reads the manifest; that is the CLI's job.
|
|
370
|
-
definition.source_dir = workflowDir;
|
|
371
|
-
definition.trust_root = findTrustRoot(workflowDir);
|
|
372
|
-
if (definition.extensions !== undefined) {
|
|
373
|
-
definition.extensions =
|
|
374
|
-
typeof definition.extensions === 'string' ? [definition.extensions] : definition.extensions;
|
|
448
|
+
catch (err) {
|
|
449
|
+
if (err instanceof WorkflowError)
|
|
450
|
+
attachLoaderWarnings(err, warnings);
|
|
451
|
+
throw err;
|
|
375
452
|
}
|
|
376
|
-
definition.origin = 'human';
|
|
377
453
|
return { definition, warnings };
|
|
378
454
|
}
|
|
379
455
|
/**
|
|
@@ -519,1282 +595,1449 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
519
595
|
}
|
|
520
596
|
const errors = [];
|
|
521
597
|
const warnings = [];
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
598
|
+
// issue #424 — CHOKEPOINT 1. Every throw from here down unwinds past a populated `warnings`
|
|
599
|
+
// array, and used to drop it: a workflow with a prohibited key AND a `dependson` typo reported
|
|
600
|
+
// the prohibition alone. This also covers the template-resolver's own throws, which cross this
|
|
601
|
+
// frame and are unreachable from any sweep of this file.
|
|
602
|
+
try {
|
|
603
|
+
// Finalised after the parse succeeded; resolves a semantic path to its place in the source.
|
|
604
|
+
const sourceMap = positions.finish();
|
|
605
|
+
/**
|
|
606
|
+
* Appends ` (step at line N)` when the step's own key can be placed, and nothing when it cannot
|
|
607
|
+
* (issue #392). Used at PUSH time, never at join time — once messages are joined into one
|
|
608
|
+
* string the step each came from is no longer recoverable.
|
|
609
|
+
*
|
|
610
|
+
* The suffix names the STEP because that is the only position this helper ever has, and saying
|
|
611
|
+
* so is the point (issue #420). Across the loader the two forms are univocal:
|
|
612
|
+
*
|
|
613
|
+
* `(line N)` — the OFFENDING KEY's own line. Minted by `withKeyLine`'s first rung
|
|
614
|
+
* below, and by the unknown-key warnings (`renderUnknownKeyMessage`),
|
|
615
|
+
* which is key-exact-or-absent by construction: every `findUnknownKeys`
|
|
616
|
+
* call site passes a `positionOf` that resolves the offending key's own
|
|
617
|
+
* path, with no step fallback anywhere.
|
|
618
|
+
* `(step at line N)` — the STEP's line. This helper, and `withKeyLine`'s fallback rung.
|
|
619
|
+
*
|
|
620
|
+
* Before that split both rungs rendered `(line N)`, so an author could not tell a cite that
|
|
621
|
+
* pointed AT the refused field from one that pointed at the declaration above it. The
|
|
622
|
+
* structured channel (`line`/`column`/`endLine`/`endColumn`) is unaffected — it always carried
|
|
623
|
+
* the distinction; only the prose was ambiguous.
|
|
624
|
+
*/
|
|
625
|
+
const withStepLine = (stepName, message) => {
|
|
626
|
+
const line = sourceMap.posOf(['steps', stepName])?.line;
|
|
627
|
+
return line === undefined ? message : `${message} (step at line ${line})`;
|
|
628
|
+
};
|
|
629
|
+
/**
|
|
630
|
+
* Like `withStepLine`, but names the OFFENDING KEY's own line (issue #417).
|
|
631
|
+
*
|
|
632
|
+
* For a key-scoped refusal the step's line is the wrong place to send someone: a long step has
|
|
633
|
+
* the key twenty lines below its own name, and the author reading `(line 40)` looks at the
|
|
634
|
+
* declaration rather than at the field being refused. The position map records every pairable
|
|
635
|
+
* mapping key, so the key's own line is available wherever the step's is.
|
|
636
|
+
*
|
|
637
|
+
* Falls back to the step's line, and then to no position at all — and the two real shapes land
|
|
638
|
+
* on DIFFERENT rungs, which is why both are pinned. A step body assembled through a merge key
|
|
639
|
+
* (`<<: *anchor`) leaves the KEY unpairable while the step's own name is still placeable, so it
|
|
640
|
+
* falls back to the step's line. A `use_template` step, whose keys are synthesized, exists at no
|
|
641
|
+
* line in the file at all and carries no position. Neither guesses — a wrong line number sends
|
|
642
|
+
* an author confidently to the wrong place, which is worse than sending them nowhere.
|
|
643
|
+
*
|
|
644
|
+
* The two rungs render DIFFERENTLY (issue #420): rung 1 is ` (line N)`, the key's own line;
|
|
645
|
+
* rung 2 is ` (step at line N)`, the step's — the same vocabulary `withStepLine` above
|
|
646
|
+
* documents in full. They were previously indistinguishable, which made the fallback silently
|
|
647
|
+
* claim to be a key-exact cite. The two lookups are separate rather than one `??` chain for
|
|
648
|
+
* exactly that reason: a single chain cannot report WHICH rung answered.
|
|
649
|
+
*/
|
|
650
|
+
const withKeyLine = (stepName, key, message) => {
|
|
651
|
+
const keyLine = sourceMap.posOf(['steps', stepName, key])?.line;
|
|
652
|
+
if (keyLine !== undefined)
|
|
653
|
+
return `${message} (line ${keyLine})`;
|
|
654
|
+
const stepLine = sourceMap.posOf(['steps', stepName])?.line;
|
|
655
|
+
if (stepLine !== undefined)
|
|
656
|
+
return `${message} (step at line ${stepLine})`;
|
|
657
|
+
return message;
|
|
658
|
+
};
|
|
659
|
+
/**
|
|
660
|
+
* The TOP-LEVEL sibling of `withStepLine`/`withKeyLine` (issue #553): names the line of a
|
|
661
|
+
* workflow-level key such as `context_wrapper` or `workflow_context.<name>`. No step
|
|
662
|
+
* fallback — there is no step — and no position at all when the key cannot be placed:
|
|
663
|
+
* absent-never-wrong, the loader's standing cite doctrine.
|
|
664
|
+
*/
|
|
665
|
+
const withTopLevelLine = (path, message) => {
|
|
666
|
+
const line = sourceMap.posOf(path)?.line;
|
|
667
|
+
return line !== undefined ? `${message} (line ${line})` : message;
|
|
668
|
+
};
|
|
669
|
+
// Step 2: Top-level validation
|
|
670
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
671
|
+
throw new WorkflowError('Invalid workflow: Workflow must be a non-null object', {
|
|
585
672
|
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
586
673
|
category: 'VALIDATION',
|
|
587
674
|
agentAction: 'report_to_user',
|
|
588
675
|
retryable: false,
|
|
589
676
|
});
|
|
590
677
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
// Finalizer-bearing workflows write every claim `deadline: null` (issue #101), so a per-step
|
|
621
|
-
// `idempotent` hint is INERT there (never cron-reclaimable). Used only to WARN below.
|
|
622
|
-
const hasFinalizerStep = Object.values(stepsRaw).some((s) => typeof s === 'object' &&
|
|
623
|
-
s !== null &&
|
|
624
|
-
s['execution'] === 'finalizer');
|
|
625
|
-
// Step 3: Per-step validation
|
|
626
|
-
for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
|
|
627
|
-
if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw)) {
|
|
628
|
-
errors.push(withStepLine(stepName, `Step '${stepName}' must be an object`));
|
|
629
|
-
continue;
|
|
630
|
-
}
|
|
631
|
-
const step = stepRaw;
|
|
632
|
-
// issue #220 §4c (PR-3): HOISTED out of the `when`-only block below (was block-local there) so
|
|
633
|
-
// ALL THREE condition surfaces (when/abort_unless/preconditions) can thread the real
|
|
634
|
-
// depends_on list into validateConditionLeaf's `$settlement` one-hop check. Previously
|
|
635
|
-
// abort_unless/preconditions passed a literal `[]` (no reference validation existed for them
|
|
636
|
-
// at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
|
|
637
|
-
// UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
|
|
638
|
-
// computation — byte-identical to the previous block-local `dependsOn` for `when`'s own use.
|
|
639
|
-
const dependsOn = Array.isArray(step['depends_on'])
|
|
640
|
-
? step['depends_on'].filter((d) => typeof d === 'string')
|
|
641
|
-
: [];
|
|
642
|
-
// WARN (do not reject) on an unknown step key — runs after template resolution above, so a
|
|
643
|
-
// template-expanded step's keys are checked too. Same non-breaking posture as the
|
|
644
|
-
// workflow-level check (issue #144).
|
|
645
|
-
warnings.push(...findUnknownKeys(step, KNOWN_STEP_KEYS, {
|
|
646
|
-
scope: 'step',
|
|
647
|
-
code: 'UNKNOWN_STEP_KEY',
|
|
648
|
-
step: stepName,
|
|
649
|
-
positionOf: (key) => sourceMap.posOf(['steps', stepName, key]),
|
|
650
|
-
}));
|
|
651
|
-
// issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
|
|
652
|
-
// namespace it names is not minted until a later PR — else the inter-PR gap could register a
|
|
653
|
-
// `$settlement`-named step that becomes a load-refused fossil the instant the mint ships.
|
|
654
|
-
if (stepName === 'run' || stepName === 'context' || stepName === '$settlement') {
|
|
655
|
-
errors.push(`Step name '${stepName}' is reserved and cannot be used as a step identifier`);
|
|
656
|
-
}
|
|
657
|
-
// Reject integer-like step names: JS object iteration reorders integer-like keys ahead
|
|
658
|
-
// of insertion order, which would silently break the declaration-order guarantees the
|
|
659
|
-
// eligibility loops and finalizer drain rely on (both iterate via Object.entries).
|
|
660
|
-
if (/^\d+$/.test(stepName)) {
|
|
661
|
-
errors.push(`Step name '${stepName}' is invalid: integer-like names reorder under JS object ` +
|
|
662
|
-
`iteration and would break declaration-order execution. Use a non-numeric name.`);
|
|
663
|
-
}
|
|
664
|
-
const REQUIRED_STEP = ['description', 'execution'];
|
|
665
|
-
for (const field of REQUIRED_STEP) {
|
|
666
|
-
if (!(field in step)) {
|
|
667
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': missing required field '${field}'`));
|
|
678
|
+
const doc = raw;
|
|
679
|
+
// WARN (do not reject) on a key that isn't authorable — checked against KNOWN_WORKFLOW_KEYS
|
|
680
|
+
// ONLY (not RUNTIME_ONLY_WORKFLOW_KEYS), and BEFORE any loader-stamped field is added below.
|
|
681
|
+
// Deliberately excluding runtime-only keys from "known" here means hand-authoring one (e.g.
|
|
682
|
+
// `schema_version:` or `model:` in YAML) warns too — those fields are stamped by the loader
|
|
683
|
+
// and any authored value is silently overwritten/ignored, which is exactly the kind of mistake
|
|
684
|
+
// this check exists to surface (issue #144). Non-breaking by design: siblings #170
|
|
685
|
+
// (hard-reject) and #169 (structured warnings channel) are deliberately out of scope here.
|
|
686
|
+
{
|
|
687
|
+
const workflowId = typeof doc['id'] === 'string' ? doc['id'] : '<unknown>';
|
|
688
|
+
warnings.push(...findUnknownKeys(doc, KNOWN_WORKFLOW_KEYS, {
|
|
689
|
+
scope: 'workflow',
|
|
690
|
+
code: 'UNKNOWN_WORKFLOW_KEY',
|
|
691
|
+
id: workflowId,
|
|
692
|
+
positionOf: (key) => sourceMap.posOf([key]),
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
// Project extensions: hard error for string-based loading (fires before any other
|
|
696
|
+
// processing); shape validation (string | string[], relative-only) for file-based loading.
|
|
697
|
+
if ('extensions' in doc && doc['extensions'] !== undefined) {
|
|
698
|
+
if (!opts.allowExtensions) {
|
|
699
|
+
throw new WorkflowError(`Invalid workflow: 'extensions' requires file-based loading — no directory context is ` +
|
|
700
|
+
`available to resolve extension module paths. Register this workflow from its YAML ` +
|
|
701
|
+
`file (realm workflow register <path>).`, {
|
|
702
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
703
|
+
category: 'VALIDATION',
|
|
704
|
+
agentAction: 'report_to_user',
|
|
705
|
+
retryable: false,
|
|
706
|
+
});
|
|
668
707
|
}
|
|
708
|
+
errors.push(...validateExtensionsDeclaration(doc['extensions']));
|
|
669
709
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
if (step['execution'] === 'finalizer') {
|
|
675
|
-
const prohibited = [
|
|
676
|
-
'depends_on',
|
|
677
|
-
'trigger_rule',
|
|
678
|
-
'abort_unless',
|
|
679
|
-
'abort_message',
|
|
680
|
-
'output_schema',
|
|
681
|
-
'agent_profile',
|
|
682
|
-
'tools',
|
|
683
|
-
'uses_service',
|
|
684
|
-
'service_method',
|
|
685
|
-
'operation',
|
|
686
|
-
'input_map',
|
|
687
|
-
'when',
|
|
688
|
-
'retry',
|
|
689
|
-
];
|
|
690
|
-
for (const field of prohibited) {
|
|
691
|
-
if (step[field] !== undefined) {
|
|
692
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: finalizer steps`));
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
// A finalizer must not gate — reject any human-gate trust level.
|
|
696
|
-
if (step['trust'] !== undefined && step['trust'] !== 'auto') {
|
|
697
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'trust: ${String(step['trust'])}' is not valid on execution: finalizer steps (a finalizer must not gate)`));
|
|
698
|
-
}
|
|
699
|
-
// v1 is handler-only.
|
|
700
|
-
if (step['handler'] === undefined) {
|
|
701
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'handler' (handler-only in v1)`));
|
|
702
|
-
}
|
|
703
|
-
// on_outcome is required, non-empty, every value in the FinalizerTrigger enum.
|
|
704
|
-
const rawOutcome = step['on_outcome'];
|
|
705
|
-
if (rawOutcome === undefined) {
|
|
706
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'on_outcome'`));
|
|
707
|
-
}
|
|
708
|
-
else {
|
|
709
|
-
const outcomes = Array.isArray(rawOutcome) ? rawOutcome : [rawOutcome];
|
|
710
|
-
if (outcomes.length === 0) {
|
|
711
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'on_outcome' must not be empty`));
|
|
712
|
-
}
|
|
713
|
-
for (const o of outcomes) {
|
|
714
|
-
if (typeof o !== 'string' || !VALID_FINALIZER_TRIGGERS.has(o)) {
|
|
715
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': invalid on_outcome value '${String(o)}'; must be one of ${[...VALID_FINALIZER_TRIGGERS].join(', ')}`));
|
|
716
|
-
}
|
|
717
|
-
}
|
|
710
|
+
const REQUIRED_TOP_LEVEL = ['id', 'name', 'version', 'steps'];
|
|
711
|
+
for (const field of REQUIRED_TOP_LEVEL) {
|
|
712
|
+
if (!(field in doc)) {
|
|
713
|
+
errors.push(`Missing required field: '${field}'`);
|
|
718
714
|
}
|
|
719
715
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
errors.push(
|
|
723
|
-
// Consumer: settlement.ts:145 (`finalizerTriggers`) — it is read only when selecting
|
|
724
|
-
// which finalizers a run's outcome should fire.
|
|
725
|
-
withKeyLine(stepName, 'on_outcome', `Step '${stepName}': 'on_outcome' is only valid on execution: finalizer steps — it ` +
|
|
726
|
-
'selects which finalizers run for a given outcome, and only finalizers are selected ' +
|
|
727
|
-
'that way, so here it would decide nothing. Move it to the finalizer that should ' +
|
|
728
|
-
'react to the outcome, or remove it.'));
|
|
716
|
+
if ('version' in doc && typeof doc['version'] !== 'number') {
|
|
717
|
+
errors.push(`'version' must be a number`);
|
|
729
718
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
'uses_service',
|
|
734
|
-
'handler',
|
|
735
|
-
'input_schema',
|
|
736
|
-
'output_schema',
|
|
737
|
-
'trust',
|
|
738
|
-
'agent_profile',
|
|
739
|
-
'trigger_rule',
|
|
740
|
-
'timeout_seconds',
|
|
741
|
-
'service_method',
|
|
742
|
-
'operation',
|
|
743
|
-
'input_map',
|
|
744
|
-
'tools',
|
|
745
|
-
];
|
|
746
|
-
for (const field of prohibited) {
|
|
747
|
-
if (step[field] !== undefined) {
|
|
748
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: guard steps`));
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
if (step['abort_unless'] === undefined) {
|
|
752
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
|
|
753
|
-
}
|
|
754
|
-
// issue #369: `preconditions` gets its OWN error rather than joining `prohibited` above,
|
|
755
|
-
// because the generic message ("'x' is not valid on execution: guard steps") would not say
|
|
756
|
-
// the thing that matters — this field was ACCEPTED and INERT before this check existed, so
|
|
757
|
-
// an author who wrote one has a workflow that looks guarded and never was. The generic
|
|
758
|
-
// list's own message style is issue #366's territory; the other twelve are left alone.
|
|
759
|
-
//
|
|
760
|
-
// The claim "never evaluates it there" rests on `checkPreconditions` having exactly one
|
|
761
|
-
// engine call site (execution-loop.ts:1380, inside `executeStep`), which `executeGuardStep`
|
|
762
|
-
// never reaches. A test pins that count so a second call site reds this message.
|
|
763
|
-
if (step['preconditions'] !== undefined) {
|
|
764
|
-
errors.push(withKeyLine(stepName, 'preconditions', `Step '${stepName}': 'preconditions' is not valid on execution: guard steps — the ` +
|
|
765
|
-
`engine never evaluates it there (a guard's execution evaluates only 'abort_unless'), ` +
|
|
766
|
-
`so the run would LOOK guarded while the declared check never ran. Move the condition ` +
|
|
767
|
-
`into 'abort_unless'. Whether guards gain a live condition surface is an open design ` +
|
|
768
|
-
`question (issue #366) — if admitted later, existing workflows are unaffected.`));
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
// abort_unless and abort_message are only valid on execution: guard steps.
|
|
772
|
-
if (step['abort_unless'] !== undefined && step['execution'] !== 'guard') {
|
|
773
|
-
errors.push(
|
|
774
|
-
// Consumer: execution-loop.ts:4828 — the condition list a guard evaluates before the
|
|
775
|
-
// run is allowed to continue.
|
|
776
|
-
withKeyLine(stepName, 'abort_unless', `Step '${stepName}': 'abort_unless' is only valid on execution: guard steps — it is ` +
|
|
777
|
-
'the condition list a guard evaluates before letting the run continue, and only ' +
|
|
778
|
-
'guard steps are evaluated that way, so here it would gate nothing. Put the check ' +
|
|
779
|
-
'on a guard step, or remove it.'));
|
|
780
|
-
}
|
|
781
|
-
if (step['abort_message'] !== undefined && step['execution'] !== 'guard') {
|
|
782
|
-
errors.push(
|
|
783
|
-
// Consumer: execution-loop.ts:4943 — the text reported when a guard aborts the run.
|
|
784
|
-
// The clause is about READERSHIP, not about who aborts: `handler_abort` and
|
|
785
|
-
// `gate_expiry_abort` are seal arms too (types/run-record.ts:603-617), so "only a guard
|
|
786
|
-
// aborts" would be false. What is true is that every reader of this key is a guard path.
|
|
787
|
-
withKeyLine(stepName, 'abort_message', `Step '${stepName}': 'abort_message' is only valid on execution: guard steps — it is ` +
|
|
788
|
-
'the text reported when a guard aborts the run, and nothing but a guard reads it, ' +
|
|
789
|
-
'so here it would never be read. Move it to the guard that performs the abort, or ' +
|
|
790
|
-
'remove it.'));
|
|
791
|
-
}
|
|
792
|
-
// agent_profile is only valid on agent steps.
|
|
793
|
-
if ('agent_profile' in step && step['execution'] !== 'agent') {
|
|
794
|
-
errors.push(
|
|
795
|
-
// Consumer: run-agent.ts:584 — resolved into the model prompt for the step.
|
|
796
|
-
withKeyLine(stepName, 'agent_profile', `Step '${stepName}': 'agent_profile' is only valid on execution: agent steps — its ` +
|
|
797
|
-
'content is resolved into the model prompt, and only an agent step makes a model ' +
|
|
798
|
-
'request, so here it would reach no model. Move it to the agent step whose prompt ' +
|
|
799
|
-
'it should shape, or remove it.'));
|
|
719
|
+
if ('steps' in doc &&
|
|
720
|
+
(typeof doc['steps'] !== 'object' || doc['steps'] === null || Array.isArray(doc['steps']))) {
|
|
721
|
+
errors.push(`'steps' must be a non-null object`);
|
|
800
722
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
'it bounds one model request, and no other kind makes one, so here it would bound ' +
|
|
811
|
-
'nothing. Move it to the agent step whose request it should bound, or remove it. ' +
|
|
812
|
-
"An auto step's dispatch is bounded by 'timeout_seconds', and a " +
|
|
813
|
-
"finalizer's handler by its own 'timeout_seconds'."));
|
|
814
|
-
}
|
|
815
|
-
// ...and when present it must be a positive integer (the same convention as
|
|
816
|
-
// retry.total_timeout_seconds and gate.timeout_seconds).
|
|
817
|
-
if (step['llm_timeout_seconds'] !== undefined &&
|
|
818
|
-
(!Number.isInteger(step['llm_timeout_seconds']) ||
|
|
819
|
-
step['llm_timeout_seconds'] <= 0)) {
|
|
820
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'llm_timeout_seconds' must be a positive integer`));
|
|
821
|
-
}
|
|
822
|
-
// timeout_seconds is NOT valid on an agent step (issue #402). Nothing enforces it there:
|
|
823
|
-
// `shouldEnforceTimeout` is `execution === 'auto'`, and agent dispatch is never wrapped in
|
|
824
|
-
// `withTimeout` at all. The key is now inert as well as unenforced — issue #412 deleted the
|
|
825
|
-
// `expected_timeout` display that used to render it into the NextAction, which is what made
|
|
826
|
-
// it actively misleading rather than merely useless. The error stays: an author who writes a
|
|
827
|
-
// bound should be told it does nothing, not left to find out. The message names both bounds
|
|
828
|
-
// that DO exist, scoped to realm's own drive (an externally driven step gets neither), on
|
|
829
|
-
// the RETRY_INERT_NON_AUTO precedent below.
|
|
830
|
-
//
|
|
831
|
-
// `=== 'agent'` EXACTLY, never `!== 'auto'`: finalizers consume this key twice — the drain
|
|
832
|
-
// lease (execution-loop.ts:5226) and the handler's own bound (:5030) — and guards already
|
|
833
|
-
// reject it in the prohibited-fields list above.
|
|
834
|
-
if (step['timeout_seconds'] !== undefined && step['execution'] === 'agent') {
|
|
835
|
-
errors.push(withKeyLine(stepName, 'timeout_seconds', `Step '${stepName}': 'timeout_seconds' is not valid on execution: agent steps — ` +
|
|
836
|
-
'the engine never enforces it there (agent dispatch is never wrapped in a timeout), ' +
|
|
837
|
-
'so the step would LOOK time-bounded while nothing enforced the bound. ' +
|
|
838
|
-
"In realm's own drive the model request is bounded by 'llm_timeout_seconds' " +
|
|
839
|
-
"(or --llm-timeout) and tool calls by 'tool_timeout'."));
|
|
840
|
-
}
|
|
841
|
-
// idempotent (issue #101 Phase 2) is only valid on execution: auto steps — the reliably
|
|
842
|
-
// time-boundable, deadline-carrying class. It is inert (no concrete deadline is ever written)
|
|
843
|
-
// on agent/guard/finalizer, so it is rejected there rather than silently ignored.
|
|
844
|
-
if (step['idempotent'] !== undefined && step['execution'] !== 'auto') {
|
|
845
|
-
errors.push(
|
|
846
|
-
// Consumers: execution-loop.ts:2526 (the `willRetry` conjunct gating `retry.on_timeout`;
|
|
847
|
-
// the :2115 advisory mirrors the rule for loader-bypassing definitions and, by its own
|
|
848
|
-
// header, never gates) and reclaim.ts:73 (reclaim eligibility) — both act on auto
|
|
849
|
-
// dispatch.
|
|
850
|
-
withKeyLine(stepName, 'idempotent', `Step '${stepName}': 'idempotent' is only valid on execution: auto steps — it gates ` +
|
|
851
|
-
"'retry.on_timeout' and reclaim eligibility, and both act on auto dispatch, so here " +
|
|
852
|
-
'it would gate nothing. Remove it, or move the work to an auto step if you need ' +
|
|
853
|
-
'either.'));
|
|
723
|
+
if (errors.length > 0) {
|
|
724
|
+
throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
|
|
725
|
+
// issue #425: the pre-join strings — see the profile collector above.
|
|
726
|
+
errors: [...errors],
|
|
727
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
728
|
+
category: 'VALIDATION',
|
|
729
|
+
agentAction: 'report_to_user',
|
|
730
|
+
retryable: false,
|
|
731
|
+
});
|
|
854
732
|
}
|
|
855
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
733
|
+
// Step 1b: Resolve template instantiations before validation.
|
|
734
|
+
const rawTemplates = (doc['templates'] ?? {});
|
|
735
|
+
if (Object.keys(rawTemplates).length > 0 || hasUseTemplateInSteps(doc['steps'])) {
|
|
736
|
+
doc['steps'] = resolveTemplates(doc['steps'], rawTemplates);
|
|
737
|
+
}
|
|
738
|
+
const stepsRaw = doc['steps'];
|
|
739
|
+
// Finalizer-bearing workflows write every claim `deadline: null` (issue #101), so a per-step
|
|
740
|
+
// `idempotent` hint is INERT there (never cron-reclaimable). Used only to WARN below.
|
|
741
|
+
const hasFinalizerStep = Object.values(stepsRaw).some((s) => typeof s === 'object' &&
|
|
742
|
+
s !== null &&
|
|
743
|
+
s['execution'] === 'finalizer');
|
|
744
|
+
// Step 3: Per-step validation
|
|
745
|
+
for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
|
|
746
|
+
if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw)) {
|
|
747
|
+
errors.push(withStepLine(stepName, `Step '${stepName}' must be an object`));
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const step = stepRaw;
|
|
751
|
+
// issue #220 §4c (PR-3): HOISTED out of the `when`-only block below (was block-local there) so
|
|
752
|
+
// ALL THREE condition surfaces (when/abort_unless/preconditions) can thread the real
|
|
753
|
+
// depends_on list into validateConditionLeaf's `$settlement` one-hop check. Previously
|
|
754
|
+
// abort_unless/preconditions passed a literal `[]` (no reference validation existed for them
|
|
755
|
+
// at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
|
|
756
|
+
// UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
|
|
757
|
+
// computation — byte-identical to the previous block-local `dependsOn` for `when`'s own use.
|
|
758
|
+
// The step's kind, once, for every kind-forked check below (undefined = malformed or
|
|
759
|
+
// missing execution — already refused by the invalid-execution/required error).
|
|
760
|
+
const stepKind = VALID_EXECUTIONS.has(step['execution'])
|
|
761
|
+
? step['execution']
|
|
868
762
|
: undefined;
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
763
|
+
const dependsOn = Array.isArray(step['depends_on'])
|
|
764
|
+
? step['depends_on'].filter((d) => typeof d === 'string')
|
|
765
|
+
: [];
|
|
766
|
+
// WARN (do not reject) on an unknown step key — runs after template resolution above, so a
|
|
767
|
+
// template-expanded step's keys are checked too. Same non-breaking posture as the
|
|
768
|
+
// workflow-level check (issue #144).
|
|
769
|
+
warnings.push(...findUnknownKeys(step, KNOWN_STEP_KEYS, {
|
|
873
770
|
scope: 'step',
|
|
771
|
+
code: 'UNKNOWN_STEP_KEY',
|
|
874
772
|
step: stepName,
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
}
|
|
883
|
-
// output_schema is only valid on execution: agent steps.
|
|
884
|
-
if (step['output_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
885
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'output_schema' is only valid on execution: agent steps`));
|
|
886
|
-
}
|
|
887
|
-
// issue #236 (L0 prevention layer): structured_output is only valid on execution: agent
|
|
888
|
-
// steps (mirrors output_schema's rule above), and its only legal value is the literal
|
|
889
|
-
// 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
|
|
890
|
-
// API provably rejects some legal schemas and silently weakens others, so authoring never
|
|
891
|
-
// ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
|
|
892
|
-
// only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
|
|
893
|
-
if (step['structured_output'] !== undefined) {
|
|
894
|
-
if (step['execution'] !== 'agent') {
|
|
895
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' is only valid on execution: agent steps`));
|
|
773
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, key]),
|
|
774
|
+
}));
|
|
775
|
+
// issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
|
|
776
|
+
// namespace it names is not minted until a later PR — else the inter-PR gap could register a
|
|
777
|
+
// `$settlement`-named step that becomes a load-refused fossil the instant the mint ships.
|
|
778
|
+
if (stepName === 'run' || stepName === 'context' || stepName === '$settlement') {
|
|
779
|
+
errors.push(`Step name '${stepName}' is reserved and cannot be used as a step identifier`);
|
|
896
780
|
}
|
|
897
|
-
|
|
898
|
-
|
|
781
|
+
// Reject integer-like step names: JS object iteration reorders integer-like keys ahead
|
|
782
|
+
// of insertion order, which would silently break the declaration-order guarantees the
|
|
783
|
+
// eligibility loops and finalizer drain rely on (both iterate via Object.entries).
|
|
784
|
+
if (/^\d+$/.test(stepName)) {
|
|
785
|
+
errors.push(`Step name '${stepName}' is invalid: integer-like names reorder under JS object ` +
|
|
786
|
+
`iteration and would break declaration-order execution. Use a non-numeric name.`);
|
|
899
787
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
: {}),
|
|
905
|
-
...(step['input_schema'] !== undefined
|
|
906
|
-
? { input_schema: step['input_schema'] }
|
|
907
|
-
: {}),
|
|
908
|
-
...(step['tools'] !== undefined ? { tools: step['tools'] } : {}),
|
|
909
|
-
});
|
|
910
|
-
if (verdict.verdict === 'ineligible') {
|
|
911
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output: strict' is not eligible for this step's ` +
|
|
912
|
-
`schema — ${renderIneligibleMessage(verdict.reasons)}`));
|
|
788
|
+
const REQUIRED_STEP = ['description', 'execution'];
|
|
789
|
+
for (const field of REQUIRED_STEP) {
|
|
790
|
+
if (!(field in step)) {
|
|
791
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': missing required field '${field}'`));
|
|
913
792
|
}
|
|
914
793
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
// countable rejection set (VALIDATION_INPUT_SCHEMA/VALIDATION_OUTPUT_SCHEMA) is agent-only by
|
|
918
|
-
// construction (execution-loop.ts's countRejection). Full rule table: `mode` value validated
|
|
919
|
-
// (REFUSE on an unrecognized value — unvalidatable posture, fail-closed); `mode: 'default'`
|
|
920
|
-
// requires `default_output` (REFUSE — nothing to substitute) which in turn requires the step's
|
|
921
|
-
// own `output_schema` (REFUSE — B5, an unvalidatable default) against which `default_output` is
|
|
922
|
-
// then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
|
|
923
|
-
// runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
|
|
924
|
-
// as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
|
|
925
|
-
if (step['validation_exhaustion'] !== undefined) {
|
|
926
|
-
if (step['execution'] !== 'agent') {
|
|
927
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' is only valid on execution: agent steps`));
|
|
794
|
+
if ('execution' in step && !VALID_EXECUTIONS.has(step['execution'])) {
|
|
795
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`));
|
|
928
796
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
797
|
+
// issue #517 (the drive-flip): ONE registry-driven walk mints every kind-prohibition —
|
|
798
|
+
// for each declared key × the step's kind, a `prohibited` cell WITHOUT an except arm
|
|
799
|
+
// mints exactly one refusal, message text per-cell data (renderRegistryProhibition).
|
|
800
|
+
// Sits at the sequence position of the EARLIEST check it replaced (the old finalizer
|
|
801
|
+
// prohibited-field loop), so minted refusals still precede the structural finalizer/guard
|
|
802
|
+
// requirements below. Multi-fire is dead by construction (one lookup, one refusal per
|
|
803
|
+
// key×kind), and a multi-bad-key step now errors in YAML declaration order.
|
|
804
|
+
//
|
|
805
|
+
// Except-bearing cells are SKIPPED — their value-conditional checks stay hand-written
|
|
806
|
+
// (today exactly trust×finalizer, below). Companion/value/sub-key rules are not minted at
|
|
807
|
+
// all (the clang line): toolsMissing, the tools agent+handler clause, the gate block,
|
|
808
|
+
// retry E1-E3, structured_output literal+eligibility, trace_schema compile, pos-int
|
|
809
|
+
// checks all stay hand-written further down.
|
|
810
|
+
//
|
|
811
|
+
// The kind gate is deliberate: on a step whose `execution` is missing or not one of the
|
|
812
|
+
// four kinds, the registry has no row to consult, so NO per-key kind refusal is minted —
|
|
813
|
+
// the invalid-execution/missing-required error above is the whole verdict. (Pre-#517 the
|
|
814
|
+
// per-key `!== '<kind>'` twins ALSO fired on malformed kinds; that was per-key advice
|
|
815
|
+
// keyed to a kind nobody declared. The workflow is refused either way — the refusal
|
|
816
|
+
// POPULATION is unchanged; disclosed in the changelog.)
|
|
817
|
+
if (VALID_EXECUTIONS.has(step['execution'])) {
|
|
818
|
+
const kind = step['execution'];
|
|
819
|
+
for (const key of Object.keys(step)) {
|
|
820
|
+
// Unknown keys are UNKNOWN_STEP_KEY's business (warned above), never a registry row.
|
|
821
|
+
const row = STEP_KEY_REGISTRY[key];
|
|
822
|
+
if (row === undefined || step[key] === undefined)
|
|
823
|
+
continue;
|
|
824
|
+
const cell = row[kind];
|
|
825
|
+
if (cell.c !== 'prohibited' || cell.except !== undefined)
|
|
826
|
+
continue;
|
|
827
|
+
errors.push(withKeyLine(stepName, key, `Step '${stepName}': ${renderRegistryProhibition(key, kind, cell)}`));
|
|
828
|
+
}
|
|
932
829
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
830
|
+
// Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
|
|
831
|
+
if (step['execution'] === 'finalizer') {
|
|
832
|
+
// A finalizer must not gate — reject any human-gate trust level. Value-conditional
|
|
833
|
+
// (`trust: 'auto'` is lawful), which is why this is the registry's except-bearing cell
|
|
834
|
+
// and stays hand-written rather than minted (#517).
|
|
835
|
+
if (step['trust'] !== undefined && step['trust'] !== 'auto') {
|
|
836
|
+
// issue #508: the reason forks on whether the declared value is actually a GATE
|
|
837
|
+
// literal — "a finalizer must not gate" is only true THEN. Any other value (an
|
|
838
|
+
// unrecognized trust, a service-trust literal, the retired human_notified) was never
|
|
839
|
+
// an attempt to gate at all, so that reason would be false for it — the #523 class,
|
|
840
|
+
// caught before shipping rather than after. `isGateTrust` is the pure-value question
|
|
841
|
+
// (no kind involved, since this branch already knows the kind and has already
|
|
842
|
+
// excluded 'auto'); the leading `'trust:` is kept exactly as before so the registry
|
|
843
|
+
// conformance runner's `namesKey` (`error.includes("'trust")`) still matches this arm
|
|
844
|
+
// — `namesKey` needs the quote BEFORE `trust`, not around the value, so switching the
|
|
845
|
+
// value's own rendering below does not touch it.
|
|
846
|
+
//
|
|
847
|
+
// issue #508 (final correction): the gate-literal arm stays a hand-written KIND
|
|
848
|
+
// prohibition (the key is the offense, not the value — #517's own boundary), but now
|
|
849
|
+
// shares `renderTrustValue` with every other arm — a previous ruling to "keep
|
|
850
|
+
// `String()` here to satisfy `namesKey`" was wrong (verified above) and there was
|
|
851
|
+
// never a real reason for two renderers, even though `isGateTrust` only ever admits
|
|
852
|
+
// the two known-string gate literals here in practice. The non-gate branch (an
|
|
853
|
+
// unrecognized trust, a service-trust literal, the retired human_notified) routes
|
|
854
|
+
// through the SAME composer every other refusal surface uses — no second hand-built
|
|
855
|
+
// arm-selector, no second value renderer. The conformance fixture (`buildFixture`)
|
|
856
|
+
// exercises only the gate-literal branch (`human_confirmed`), so the composer's three
|
|
857
|
+
// sub-arms are unreached by it — verified by grepping the fixture builder for this key.
|
|
858
|
+
const rawFinalizerTrust = step['trust'];
|
|
859
|
+
const finalizerMessage = isGateTrust(rawFinalizerTrust)
|
|
860
|
+
? `Step '${stepName}': 'trust: ${renderTrustValue(rawFinalizerTrust)}' is not valid on execution: finalizer steps (a finalizer must not gate)`
|
|
861
|
+
: buildTrustRefusal({
|
|
862
|
+
kind: 'finalizer',
|
|
863
|
+
value: rawFinalizerTrust,
|
|
864
|
+
step: stepName,
|
|
865
|
+
surface: 'load',
|
|
866
|
+
});
|
|
867
|
+
errors.push(withStepLine(stepName, finalizerMessage));
|
|
952
868
|
}
|
|
953
|
-
|
|
954
|
-
if (
|
|
955
|
-
errors.push(withStepLine(stepName, `Step '${stepName}':
|
|
956
|
-
`(got: ${JSON.stringify(modeValue)})`));
|
|
869
|
+
// v1 is handler-only.
|
|
870
|
+
if (step['handler'] === undefined) {
|
|
871
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'handler' (handler-only in v1)`));
|
|
957
872
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
873
|
+
// on_outcome is required, non-empty, every value in the FinalizerTrigger enum.
|
|
874
|
+
const rawOutcome = step['on_outcome'];
|
|
875
|
+
if (rawOutcome === undefined) {
|
|
876
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': execution: finalizer requires 'on_outcome'`));
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
const outcomes = Array.isArray(rawOutcome) ? rawOutcome : [rawOutcome];
|
|
880
|
+
if (outcomes.length === 0) {
|
|
881
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'on_outcome' must not be empty`));
|
|
967
882
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
// output_schema) pair. This is the loader's first load-time Ajv compile of an
|
|
972
|
-
// output_schema (today output_schema is only compiled at runtime), so the catch below
|
|
973
|
-
// legitimately sees TWO different populations: a VALIDATION_OUTPUT_SCHEMA
|
|
974
|
-
// WorkflowError (default_output fails the schema) and a raw Ajv schema-compilation
|
|
975
|
-
// Error (a structurally malformed output_schema) — both fail-closed to a load refusal,
|
|
976
|
-
// but they carry their detail DIFFERENTLY: a WorkflowError has `.details.errors`; a raw
|
|
977
|
-
// Error has NO `.details` at all (reading `.details.errors` on it throws a TypeError
|
|
978
|
-
// that would escape the loader mid-walk — verified empirically). Discriminate.
|
|
979
|
-
try {
|
|
980
|
-
validateOutputSchema(exhaustionBlock['default_output'], step['output_schema'], stepName);
|
|
981
|
-
}
|
|
982
|
-
catch (err) {
|
|
983
|
-
const detail = err instanceof WorkflowError
|
|
984
|
-
? JSON.stringify(err.details['errors'])
|
|
985
|
-
: err instanceof Error
|
|
986
|
-
? err.message
|
|
987
|
-
: String(err);
|
|
988
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' does not validate ` +
|
|
989
|
-
`against the step's own 'output_schema': ${detail}`));
|
|
883
|
+
for (const o of outcomes) {
|
|
884
|
+
if (typeof o !== 'string' || !VALID_FINALIZER_TRIGGERS.has(o)) {
|
|
885
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid on_outcome value '${String(o)}'; must be one of ${[...VALID_FINALIZER_TRIGGERS].join(', ')}`));
|
|
990
886
|
}
|
|
991
887
|
}
|
|
992
888
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
889
|
+
}
|
|
890
|
+
// issue #508 (L1) — trust VALUE validation, auto/agent only. Before this check, an
|
|
891
|
+
// unrecognized or kind-inert `trust` (a typo, a service-trust literal, the retired
|
|
892
|
+
// `human_notified`) loaded clean, warned nothing, and ran with NO gate — the step's own
|
|
893
|
+
// declared human-approval control was silently disabled. Presence-keyed (`'trust' in
|
|
894
|
+
// step`, the same convention `trigger_rule` and `retry.backoff` already use below) so
|
|
895
|
+
// `trust:`/`trust: ~` (a null value, which loads clean today) is caught too — a blank
|
|
896
|
+
// declaration of a safety control is itself a false statement, not a no-op. `trust:`
|
|
897
|
+
// absent entirely is lawful (nothing was declared) and never reaches this block.
|
|
898
|
+
//
|
|
899
|
+
// Guard's OWN trust prohibition is minted by the #517 walk above (every value refused,
|
|
900
|
+
// no except arm); finalizer's is the hand-written except-cell just above (only 'auto' is
|
|
901
|
+
// lawful there). This block is what closes the remaining two kinds — the ones where a
|
|
902
|
+
// RECOGNIZED gate literal is meaningful, so an unrecognized one needs a VALUE verdict,
|
|
903
|
+
// not a kind verdict.
|
|
904
|
+
//
|
|
905
|
+
// issue #508 (final correction) — this whole value-refusal composition, for every kind and
|
|
906
|
+
// every surface, is now `buildTrustRefusal` (types/workflow-definition.ts, beside
|
|
907
|
+
// `classifyStepTrust`). Three prior rounds each hand-composed this text independently on
|
|
908
|
+
// this surface, execution-loop.ts's dispatch refusal, run-health.ts's finding, and the
|
|
909
|
+
// protocol generator's briefing — and every defect those rounds found (arm divergence, a
|
|
910
|
+
// String()-rendered array printing as its own first element, a grammar seam) fell out of
|
|
911
|
+
// that duplication. No site chooses an arm or renders a value on its own again; see the
|
|
912
|
+
// composer's own doc for the arm/mood/rendering contract in full.
|
|
913
|
+
if ((stepKind === 'auto' || stepKind === 'agent') &&
|
|
914
|
+
'trust' in step &&
|
|
915
|
+
classifyStepTrust(stepKind, step['trust']) === 'refuse') {
|
|
916
|
+
errors.push(withKeyLine(stepName, 'trust', buildTrustRefusal({
|
|
917
|
+
kind: stepKind,
|
|
918
|
+
value: step['trust'],
|
|
919
|
+
step: stepName,
|
|
920
|
+
surface: 'load',
|
|
921
|
+
})));
|
|
922
|
+
}
|
|
923
|
+
// Guard step constraints (the guard kind-prohibitions, including `preconditions` — issue
|
|
924
|
+
// #369's own bespoke message, message_data-preserved — are minted by the #517 walk above).
|
|
925
|
+
if (step['execution'] === 'guard') {
|
|
926
|
+
if (step['abort_unless'] === undefined) {
|
|
927
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
|
|
1005
928
|
}
|
|
1006
929
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
code: 'DUAL_SCHEMA_DECLARED',
|
|
1017
|
-
severity: resolveSeverity('DUAL_SCHEMA_DECLARED'),
|
|
1018
|
-
scope: 'step',
|
|
1019
|
-
step: stepName,
|
|
1020
|
-
message: `Step '${stepName}': declares both input_schema and output_schema; the agent's submitted ` +
|
|
1021
|
-
`output is validated against both — prefer one to avoid divergence.`,
|
|
1022
|
-
});
|
|
1023
|
-
}
|
|
1024
|
-
// trace_schema is only valid on execution: agent steps.
|
|
1025
|
-
if (step['trace_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
1026
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_schema' is only valid on execution: agent steps`));
|
|
1027
|
-
}
|
|
1028
|
-
// trace_validation_mode is only valid on execution: agent steps.
|
|
1029
|
-
if (step['trace_validation_mode'] !== undefined && step['execution'] !== 'agent') {
|
|
1030
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_validation_mode' is only valid on execution: agent steps`));
|
|
1031
|
-
}
|
|
1032
|
-
// trace_validation_mode must be 'warn' or 'enforce' when provided.
|
|
1033
|
-
if (step['trace_validation_mode'] !== undefined &&
|
|
1034
|
-
step['trace_validation_mode'] !== 'warn' &&
|
|
1035
|
-
step['trace_validation_mode'] !== 'enforce') {
|
|
1036
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`));
|
|
1037
|
-
}
|
|
1038
|
-
// issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
|
|
1039
|
-
// the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
|
|
1040
|
-
// on_expiry enum, default_choice's required-iff + choice-set validation, and the dead-config
|
|
1041
|
-
// warn cells. Runs regardless of `trust` (a `gate:` block with no gate trust is already inert
|
|
1042
|
-
// — no separate rejection needed; the existing render/mint paths never read it without a
|
|
1043
|
-
// trust value).
|
|
1044
|
-
if (step['gate'] !== undefined) {
|
|
1045
|
-
if (typeof step['gate'] !== 'object' || step['gate'] === null) {
|
|
1046
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate' must be an object`));
|
|
930
|
+
// llm_timeout_seconds must be a positive integer when present (the same convention as
|
|
931
|
+
// retry.total_timeout_seconds and gate.timeout_seconds). Deliberately kind-BLIND, which
|
|
932
|
+
// makes it the pinned DOUBLE-fire control for #517: on a wrong-kind step BOTH the minted
|
|
933
|
+
// prohibition and this shape error fire — this check was never else-if-suppressed, unlike
|
|
934
|
+
// structured_output/validation_exhaustion/input_map's value checks below.
|
|
935
|
+
if (step['llm_timeout_seconds'] !== undefined &&
|
|
936
|
+
(!Number.isInteger(step['llm_timeout_seconds']) ||
|
|
937
|
+
step['llm_timeout_seconds'] <= 0)) {
|
|
938
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'llm_timeout_seconds' must be a positive integer`));
|
|
1047
939
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
940
|
+
// WARN (do not reject): an idempotent auto step in a finalizer-bearing workflow gets
|
|
941
|
+
// `deadline: null` (issue #101), so the RECLAIM function is inert — `realm run reclaim --all`
|
|
942
|
+
// can never select it. The author should know it stays per-step-manual-reclaim-only.
|
|
943
|
+
//
|
|
944
|
+
// Issue #140 C5 (variant-aware reword): `idempotent` now has a SECOND function — gating
|
|
945
|
+
// `retry.on_timeout` — and that GATE function is live in every workflow, finalizer-bearing or
|
|
946
|
+
// not (shouldEnforceTimeout has no finalizer conjunct). A single unconditional message would
|
|
947
|
+
// either keep a falsehood (claiming idempotent is wholly inert when on_timeout is ALSO
|
|
948
|
+
// declared) or gratuitously mention a gate the author never declared (idempotent-alone case)
|
|
949
|
+
// — so the message is keyed on `step.retry?.on_timeout`, pinned by both-variant loader tests.
|
|
950
|
+
if (step['idempotent'] === true && step['execution'] === 'auto' && hasFinalizerStep) {
|
|
951
|
+
const stepRetry = typeof step['retry'] === 'object' && step['retry'] !== null
|
|
952
|
+
? step['retry']
|
|
953
|
+
: undefined;
|
|
954
|
+
const onTimeoutDeclared = stepRetry?.['on_timeout'] === true;
|
|
955
|
+
warnings.push({
|
|
956
|
+
code: 'IDEMPOTENT_INERT_IN_FINALIZER',
|
|
957
|
+
severity: resolveSeverity('IDEMPOTENT_INERT_IN_FINALIZER'),
|
|
1053
958
|
scope: 'step',
|
|
1054
|
-
code: 'UNKNOWN_GATE_KEY',
|
|
1055
959
|
step: stepName,
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
if (onExpiry !== undefined && onExpiry !== 'settle_default' && onExpiry !== 'abort') {
|
|
1076
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry' must be 'settle_default' or 'abort' (got: ${JSON.stringify(onExpiry)})`));
|
|
1077
|
-
}
|
|
1078
|
-
// default_choice: REQUIRED iff on_expiry === 'settle_default' (E2-style hard error,
|
|
1079
|
-
// mirroring validation_exhaustion.mode:'default' requiring default_output); validated
|
|
1080
|
-
// against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
|
|
1081
|
-
// derivation the engine mints PendingGate.choices from (execution-loop.ts's gate-open
|
|
1082
|
-
// site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']) —
|
|
1083
|
-
// so a load-time-legal default_choice can NEVER fail at enactment time.
|
|
1084
|
-
const hasDefaultChoice = 'default_choice' in gate;
|
|
1085
|
-
if (onExpiry === 'settle_default') {
|
|
1086
|
-
if (!hasDefaultChoice) {
|
|
1087
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
|
|
1088
|
-
`(nothing to resolve the gate with on expiry)`));
|
|
1089
|
-
}
|
|
1090
|
-
else {
|
|
1091
|
-
const choicesRaw = gate['choices'] ??
|
|
1092
|
-
step['input_schema']?.properties?.['choice']?.enum;
|
|
1093
|
-
const effectiveChoices = Array.isArray(choicesRaw)
|
|
1094
|
-
? choicesRaw
|
|
1095
|
-
: ['approve', 'reject'];
|
|
1096
|
-
if (!effectiveChoices.includes(gate['default_choice'])) {
|
|
1097
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.default_choice' (${JSON.stringify(gate['default_choice'])}) ` +
|
|
1098
|
-
`is not one of the step's effective choices: ${effectiveChoices.join(', ')}`));
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
960
|
+
message: `Step '${stepName}': 'idempotent: true' cannot enable auto-reclaim in a finalizer-bearing ` +
|
|
961
|
+
`workflow (its claim carries no deadline, so 'realm run reclaim --all' can never select ` +
|
|
962
|
+
`it). Recover it with 'realm run reclaim <run-id> --step ${stepName} --force'.` +
|
|
963
|
+
(onTimeoutDeclared
|
|
964
|
+
? ` Its 'retry.on_timeout' gate role is unaffected — timeout retries remain active.`
|
|
965
|
+
: ''),
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
// issue #236 (L0 prevention layer): structured_output's only legal value is the literal
|
|
969
|
+
// 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
|
|
970
|
+
// API provably rejects some legal schemas and silently weakens others, so authoring never
|
|
971
|
+
// ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
|
|
972
|
+
// only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
|
|
973
|
+
// #517 re-gate: the kind half is minted by the registry walk above; the value checks
|
|
974
|
+
// below keep their old else-branch semantics via an explicit valid-kind conjunct — a
|
|
975
|
+
// wrong-kind step gets ONLY the minted refusal, never the value noise.
|
|
976
|
+
if (step['structured_output'] !== undefined && step['execution'] === 'agent') {
|
|
977
|
+
if (step['structured_output'] !== 'strict') {
|
|
978
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`));
|
|
1101
979
|
}
|
|
1102
|
-
else
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
`'gate.on_expiry: settle_default' — set it, or remove 'gate.default_choice'.`,
|
|
980
|
+
else {
|
|
981
|
+
const verdict = assessStructuredOutputEligibility({
|
|
982
|
+
...(step['output_schema'] !== undefined
|
|
983
|
+
? { output_schema: step['output_schema'] }
|
|
984
|
+
: {}),
|
|
985
|
+
...(step['input_schema'] !== undefined
|
|
986
|
+
? { input_schema: step['input_schema'] }
|
|
987
|
+
: {}),
|
|
988
|
+
...(step['tools'] !== undefined ? { tools: step['tools'] } : {}),
|
|
1112
989
|
});
|
|
990
|
+
if (verdict.verdict === 'ineligible') {
|
|
991
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output: strict' is not eligible for this step's ` +
|
|
992
|
+
`schema — ${renderIneligibleMessage(verdict.reasons)}`));
|
|
993
|
+
}
|
|
1113
994
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
995
|
+
}
|
|
996
|
+
// issue #220 (PR-2): validation_exhaustion is only valid on execution: agent steps — the
|
|
997
|
+
// countable rejection set (VALIDATION_INPUT_SCHEMA/VALIDATION_OUTPUT_SCHEMA) is agent-only by
|
|
998
|
+
// construction (execution-loop.ts's countRejection). Full rule table: `mode` value validated
|
|
999
|
+
// (REFUSE on an unrecognized value — unvalidatable posture, fail-closed); `mode: 'default'`
|
|
1000
|
+
// requires `default_output` (REFUSE — nothing to substitute) which in turn requires the step's
|
|
1001
|
+
// own `output_schema` (REFUSE — B5, an unvalidatable default) against which `default_output` is
|
|
1002
|
+
// then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
|
|
1003
|
+
// runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
|
|
1004
|
+
// as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
|
|
1005
|
+
// #517 re-gate: kind half minted above; else-semantics preserved by the explicit conjunct.
|
|
1006
|
+
if (step['validation_exhaustion'] !== undefined && step['execution'] === 'agent') {
|
|
1007
|
+
if (typeof step['validation_exhaustion'] !== 'object' ||
|
|
1008
|
+
step['validation_exhaustion'] === null) {
|
|
1009
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' must be an object`));
|
|
1125
1010
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1011
|
+
else {
|
|
1012
|
+
const exhaustionBlock = step['validation_exhaustion'];
|
|
1013
|
+
// WARN (do not reject) on an unknown validation_exhaustion sub-key — the retry-block-style
|
|
1014
|
+
// pattern (issue #140's UNKNOWN_RETRY_KEY), its OWN code (issue #220 PR-2 mints
|
|
1015
|
+
// UNKNOWN_VALIDATION_EXHAUSTION_KEY, replacing PR-1's UNKNOWN_STEP_KEY noun-override —
|
|
1016
|
+
// closes the #170-flip incoherence against the structurally identical retry-key family).
|
|
1017
|
+
warnings.push(...findUnknownKeys(exhaustionBlock, KNOWN_VALIDATION_EXHAUSTION_KEYS, {
|
|
1134
1018
|
scope: 'step',
|
|
1019
|
+
code: 'UNKNOWN_VALIDATION_EXHAUSTION_KEY',
|
|
1135
1020
|
step: stepName,
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1021
|
+
noun: 'validation_exhaustion',
|
|
1022
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, 'validation_exhaustion', key]),
|
|
1023
|
+
}));
|
|
1024
|
+
if ('threshold' in exhaustionBlock &&
|
|
1025
|
+
(!Number.isInteger(exhaustionBlock['threshold']) ||
|
|
1026
|
+
exhaustionBlock['threshold'] < 1)) {
|
|
1027
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.threshold' must be a positive integer ` +
|
|
1028
|
+
`(1 is legal — it disables in-drive schema-repair, since the first rejection ` +
|
|
1029
|
+
`already meets it)`));
|
|
1030
|
+
}
|
|
1031
|
+
const modeValue = exhaustionBlock['mode'];
|
|
1032
|
+
if (modeValue !== undefined && modeValue !== 'fail' && modeValue !== 'default') {
|
|
1033
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode' must be 'fail' or 'default' ` +
|
|
1034
|
+
`(got: ${JSON.stringify(modeValue)})`));
|
|
1035
|
+
}
|
|
1036
|
+
const hasDefaultOutput = 'default_output' in exhaustionBlock;
|
|
1037
|
+
if (modeValue === 'default') {
|
|
1038
|
+
if (!hasDefaultOutput) {
|
|
1039
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.mode: default' requires ` +
|
|
1040
|
+
`'default_output' (nothing to substitute on exhaustion)`));
|
|
1041
|
+
}
|
|
1042
|
+
else if (step['output_schema'] === undefined) {
|
|
1043
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' requires the step to ` +
|
|
1044
|
+
`declare 'output_schema' (an undeclared schema makes the default unvalidatable)`));
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
// B10 — load-time AJV proof: REUSE the runtime validator so the load-time verdict can
|
|
1048
|
+
// never diverge from the runtime verdict for the exact same (default_output,
|
|
1049
|
+
// output_schema) pair. This is the loader's first load-time Ajv compile of an
|
|
1050
|
+
// output_schema (today output_schema is only compiled at runtime), so the catch below
|
|
1051
|
+
// legitimately sees TWO different populations: a VALIDATION_OUTPUT_SCHEMA
|
|
1052
|
+
// WorkflowError (default_output fails the schema) and a raw Ajv schema-compilation
|
|
1053
|
+
// Error (a structurally malformed output_schema) — both fail-closed to a load refusal,
|
|
1054
|
+
// but they carry their detail DIFFERENTLY: a WorkflowError has `.details.errors`; a raw
|
|
1055
|
+
// Error has NO `.details` at all (reading `.details.errors` on it throws a TypeError
|
|
1056
|
+
// that would escape the loader mid-walk — verified empirically). Discriminate.
|
|
1057
|
+
try {
|
|
1058
|
+
validateOutputSchema(exhaustionBlock['default_output'], step['output_schema'], stepName);
|
|
1059
|
+
}
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
const detail = err instanceof WorkflowError
|
|
1062
|
+
? JSON.stringify(err.details['errors'])
|
|
1063
|
+
: err instanceof Error
|
|
1064
|
+
? err.message
|
|
1065
|
+
: String(err);
|
|
1066
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion.default_output' does not validate ` +
|
|
1067
|
+
`against the step's own 'output_schema': ${detail}`));
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
else if (hasDefaultOutput) {
|
|
1072
|
+
// default_output present without mode: 'default' (mode: 'fail' or absent) — inert, not
|
|
1073
|
+
// an error: WARN as dead config rather than silently ignoring it.
|
|
1074
|
+
warnings.push({
|
|
1075
|
+
code: 'DEAD_VALIDATION_EXHAUSTION_CONFIG',
|
|
1076
|
+
severity: resolveSeverity('DEAD_VALIDATION_EXHAUSTION_CONFIG'),
|
|
1077
|
+
scope: 'step',
|
|
1078
|
+
step: stepName,
|
|
1079
|
+
message: `Step '${stepName}': 'validation_exhaustion.default_output' is ignored without ` +
|
|
1080
|
+
`'mode: default' — set 'validation_exhaustion.mode: default' to enable it, or ` +
|
|
1081
|
+
`remove 'default_output'.`,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1140
1084
|
}
|
|
1141
1085
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
if (step['retry'] !== undefined) {
|
|
1153
|
-
if (typeof step['retry'] !== 'object' || step['retry'] === null) {
|
|
1154
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry' must be an object`));
|
|
1155
|
-
}
|
|
1156
|
-
else {
|
|
1157
|
-
const retry = step['retry'];
|
|
1158
|
-
// WARN (do not reject) on an unknown retry-block key — same non-breaking posture as the
|
|
1159
|
-
// step/workflow-level checks (issue #140). Noun overridden to 'retry' (not 'step') since
|
|
1160
|
-
// this is a nested block, not the step itself.
|
|
1161
|
-
warnings.push(...findUnknownKeys(retry, KNOWN_RETRY_KEYS, {
|
|
1086
|
+
// WARN (do not reject): an agent step declaring BOTH input_schema and output_schema has its
|
|
1087
|
+
// submitted output validated against BOTH (execution-loop.ts validateInputSchema AND
|
|
1088
|
+
// validateOutputSchema) — a divergence between the two degrades to a confusing recoverable
|
|
1089
|
+
// VALIDATION_*_SCHEMA error rather than a clean failure. Detection/warn only; no schema change.
|
|
1090
|
+
if (step['execution'] === 'agent' &&
|
|
1091
|
+
step['input_schema'] !== undefined &&
|
|
1092
|
+
step['output_schema'] !== undefined) {
|
|
1093
|
+
warnings.push({
|
|
1094
|
+
code: 'DUAL_SCHEMA_DECLARED',
|
|
1095
|
+
severity: resolveSeverity('DUAL_SCHEMA_DECLARED'),
|
|
1162
1096
|
scope: 'step',
|
|
1163
|
-
code: 'UNKNOWN_RETRY_KEY',
|
|
1164
1097
|
step: stepName,
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
})
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1098
|
+
message: `Step '${stepName}': declares both input_schema and output_schema; the agent's submitted ` +
|
|
1099
|
+
`output is validated against both — prefer one to avoid divergence.`,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
// trace_validation_mode must be 'warn' or 'enforce' when provided.
|
|
1103
|
+
if (step['trace_validation_mode'] !== undefined &&
|
|
1104
|
+
step['trace_validation_mode'] !== 'warn' &&
|
|
1105
|
+
step['trace_validation_mode'] !== 'enforce') {
|
|
1106
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`));
|
|
1107
|
+
}
|
|
1108
|
+
// issue #433: the effective STATIC gate choice source, hoisted per-step BEFORE the
|
|
1109
|
+
// `gate:`-block-only region below. Member (b) below must fire even when there is NO `gate:`
|
|
1110
|
+
// key at all (the executed g433b shape: a gate-trusted step with no gate block but an
|
|
1111
|
+
// empty `input_schema.properties.choice.enum`), and the entire #291 region beneath this one
|
|
1112
|
+
// is gated on `step['gate'] !== undefined` — it cannot host a check that must fire without
|
|
1113
|
+
// one. Pure reads; the existing E2/membership cells (below) pin messages, not evaluation
|
|
1114
|
+
// order, so hoisting these three declarations ahead of them is safe.
|
|
1115
|
+
const gateObj = typeof step['gate'] === 'object' && step['gate'] !== null
|
|
1116
|
+
? step['gate']
|
|
1117
|
+
: undefined;
|
|
1118
|
+
const declaredGateChoices = gateObj?.['choices'];
|
|
1119
|
+
const declaredChoiceEnum = step['input_schema']?.properties?.['choice']?.enum;
|
|
1120
|
+
// Renders the OFFENDING KEY's own line via its full nested path, falling back to the
|
|
1121
|
+
// step's line and then to no position — the same two-rung univocal vocabulary
|
|
1122
|
+
// `withKeyLine` documents above (issue #420: `(line N)` for the key, `(step at line N)`
|
|
1123
|
+
// for the step; never conflated). `withKeyLine` itself is single-segment
|
|
1124
|
+
// (`['steps', stepName, key]`) and cannot express a nested path like
|
|
1125
|
+
// `['steps', stepName, 'gate', 'choices']`, so this is a local sibling rather than a call
|
|
1126
|
+
// to it — no existing `withKeyLine` call site is touched.
|
|
1127
|
+
const withPathLine = (path, message) => {
|
|
1128
|
+
const keyLine = sourceMap.posOf(path)?.line;
|
|
1129
|
+
if (keyLine !== undefined)
|
|
1130
|
+
return `${message} (line ${keyLine})`;
|
|
1131
|
+
const stepLine = sourceMap.posOf(['steps', stepName])?.line;
|
|
1132
|
+
if (stepLine !== undefined)
|
|
1133
|
+
return `${message} (step at line ${stepLine})`;
|
|
1134
|
+
return message;
|
|
1135
|
+
};
|
|
1136
|
+
// Member (a) (issue #433): a DECLARED `gate.choices` list that is empty is never right, on
|
|
1137
|
+
// ANY step — gate-trusted or not (the #291 block's own posture just below: a `gate:` key
|
|
1138
|
+
// is validated "regardless of trust"; the #417 strict-on-known-key policy agrees). An empty
|
|
1139
|
+
// list on a gate-trusted step mints an unanswerable gate (every response is refused against
|
|
1140
|
+
// an empty expected set) with no disposal path short of an authored expiry — on an ungated
|
|
1141
|
+
// step it is dead weight either way, so the message is deliberately population-invariant
|
|
1142
|
+
// rather than false for the ungated population.
|
|
1143
|
+
if (Array.isArray(declaredGateChoices) && declaredGateChoices.length === 0) {
|
|
1144
|
+
errors.push(withPathLine(['steps', stepName, 'gate', 'choices'], `Step '${stepName}': 'gate.choices', when declared, must be non-empty — an empty ` +
|
|
1145
|
+
'list is never right: on a gate-trusted step (trust: human_confirmed/human_reviewed) ' +
|
|
1146
|
+
'it mints a gate NO response can ever resolve (every submission is refused against ' +
|
|
1147
|
+
'an empty expected list, and the live run wedges with no disposal path: abandon ' +
|
|
1148
|
+
'refuses a pending gate; purge and drain refuse a live run; only an authored ' +
|
|
1149
|
+
"'gate.timeout_seconds' + 'on_expiry' expiry could ever clear it). Declare at least " +
|
|
1150
|
+
"one choice, or remove the key to fall back to 'input_schema.properties.choice.enum' " +
|
|
1151
|
+
'or the default pair (approve/reject).'));
|
|
1152
|
+
}
|
|
1153
|
+
// Member (b) (issue #433): for a GATE-TRUSTED step with no `gate.choices` list declared
|
|
1154
|
+
// (NULLISH — the mint's own `??` semantics; `gate: {choices:}` with a YAML-null value is
|
|
1155
|
+
// the third executed wedge shape, and presence-keying would let it escape this check), a
|
|
1156
|
+
// DECLARED-and-empty `input_schema.properties.choice.enum` is the effective choice source
|
|
1157
|
+
// and the same class of error, under its own key. `choices: null` with no `enum` at all
|
|
1158
|
+
// stays legal — the mint defaults to ['approve', 'reject'].
|
|
1159
|
+
if (isGateTrust(step['trust']) &&
|
|
1160
|
+
declaredGateChoices == null &&
|
|
1161
|
+
Array.isArray(declaredChoiceEnum) &&
|
|
1162
|
+
declaredChoiceEnum.length === 0) {
|
|
1163
|
+
errors.push(withPathLine(['steps', stepName, 'input_schema', 'properties', 'choice', 'enum'], `Step '${stepName}': 'input_schema.properties.choice.enum' is this gate's effective ` +
|
|
1164
|
+
"choice source (no 'gate.choices' list declared) and, when declared, must be " +
|
|
1165
|
+
'non-empty — an empty list mints a gate NO response can ever resolve (every ' +
|
|
1166
|
+
'submission is refused against an empty expected list, and the live run wedges ' +
|
|
1167
|
+
'with no disposal path: abandon refuses a pending gate; purge and drain refuse a ' +
|
|
1168
|
+
"live run; only an authored 'gate.timeout_seconds' + 'on_expiry' expiry could ever " +
|
|
1169
|
+
"clear it). Declare at least one enum value, or remove 'enum' to get the default " +
|
|
1170
|
+
'pair (approve/reject).'));
|
|
1171
|
+
}
|
|
1172
|
+
// issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
|
|
1173
|
+
// the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
|
|
1174
|
+
// on_expiry enum, default_choice's required-iff + choice-set validation, and the dead-config
|
|
1175
|
+
// warn cells. The hard-error checks run regardless of `trust` (a shape/enum mistake is a
|
|
1176
|
+
// mistake whether or not this step can ever gate). The dead-config ADVISORIES fork on
|
|
1177
|
+
// `isGateTrust` (issue #524): the engine mints a gate only where trust requires human
|
|
1178
|
+
// confirmation (Step 5b, `execution-loop.ts`) — on any other step the WHOLE block is inert,
|
|
1179
|
+
// so a per-member remedy ("set a timeout") would be false: following it never makes the key
|
|
1180
|
+
// live, it only silences the one diagnostic that said so.
|
|
1181
|
+
if (step['gate'] !== undefined) {
|
|
1182
|
+
if (typeof step['gate'] !== 'object' || step['gate'] === null) {
|
|
1183
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate' must be an object`));
|
|
1207
1184
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
warnings.push({
|
|
1213
|
-
code: 'TOTAL_TIMEOUT_NON_AUTO',
|
|
1214
|
-
severity: resolveSeverity('TOTAL_TIMEOUT_NON_AUTO'),
|
|
1185
|
+
else {
|
|
1186
|
+
const gate = step['gate'];
|
|
1187
|
+
// WARN (do not reject) on an unknown gate-block key — same non-breaking posture as
|
|
1188
|
+
// retry/validation_exhaustion (issues #140/#220).
|
|
1189
|
+
warnings.push(...findUnknownKeys(gate, KNOWN_GATE_KEYS, {
|
|
1215
1190
|
scope: 'step',
|
|
1191
|
+
code: 'UNKNOWN_GATE_KEY',
|
|
1216
1192
|
step: stepName,
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1193
|
+
noun: 'gate',
|
|
1194
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, 'gate', key]),
|
|
1195
|
+
}));
|
|
1196
|
+
// E2: timeout_seconds/reminder_seconds/reminder_max must each be a positive integer
|
|
1197
|
+
// (yaml-loader :1164-1174 precedent — the SAME convention as retry.total_timeout_seconds).
|
|
1198
|
+
if ('timeout_seconds' in gate &&
|
|
1199
|
+
(!Number.isInteger(gate['timeout_seconds']) || gate['timeout_seconds'] <= 0)) {
|
|
1200
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.timeout_seconds' must be a positive integer`));
|
|
1201
|
+
}
|
|
1202
|
+
if ('reminder_seconds' in gate &&
|
|
1203
|
+
(!Number.isInteger(gate['reminder_seconds']) ||
|
|
1204
|
+
gate['reminder_seconds'] <= 0)) {
|
|
1205
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_seconds' must be a positive integer`));
|
|
1206
|
+
}
|
|
1207
|
+
if ('reminder_max' in gate &&
|
|
1208
|
+
(!Number.isInteger(gate['reminder_max']) || gate['reminder_max'] <= 0)) {
|
|
1209
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.reminder_max' must be a positive integer`));
|
|
1210
|
+
}
|
|
1211
|
+
// on_expiry must be 'settle_default' or 'abort' when provided.
|
|
1212
|
+
const onExpiry = gate['on_expiry'];
|
|
1213
|
+
if (onExpiry !== undefined && onExpiry !== 'settle_default' && onExpiry !== 'abort') {
|
|
1214
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry' must be 'settle_default' or 'abort' (got: ${JSON.stringify(onExpiry)})`));
|
|
1215
|
+
}
|
|
1216
|
+
// default_choice: REQUIRED iff on_expiry === 'settle_default' (E2-style hard error,
|
|
1217
|
+
// mirroring validation_exhaustion.mode:'default' requiring default_output); validated
|
|
1218
|
+
// against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
|
|
1219
|
+
// derivation the engine mints PendingGate.choices from (execution-loop.ts's gate-open
|
|
1220
|
+
// site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']),
|
|
1221
|
+
// sourced from the issue #433 hoist above (`declaredGateChoices ?? declaredChoiceEnum`
|
|
1222
|
+
// — one chain, so this can never drift from the mint's) — so a load-time-legal
|
|
1223
|
+
// default_choice can NEVER fail at enactment time.
|
|
1224
|
+
const hasDefaultChoice = 'default_choice' in gate;
|
|
1225
|
+
// issue #524: the one place this block's dead-config ADVISORIES fork. The mint only
|
|
1226
|
+
// ever reads `gate.*` where `isGateTrust(trust)` holds (execution-loop.ts:3302,
|
|
1227
|
+
// `W_GATE_MINT_TRUST`) — computed once so the three sites below can never disagree.
|
|
1228
|
+
const gateTrusted = isGateTrust(step['trust']);
|
|
1229
|
+
if (onExpiry === 'settle_default') {
|
|
1230
|
+
if (!hasDefaultChoice) {
|
|
1231
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
|
|
1232
|
+
`(nothing to resolve the gate with on expiry)`));
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
const choicesRaw = declaredGateChoices ?? declaredChoiceEnum;
|
|
1236
|
+
const effectiveChoices = Array.isArray(choicesRaw)
|
|
1237
|
+
? choicesRaw
|
|
1238
|
+
: ['approve', 'reject'];
|
|
1239
|
+
if (!effectiveChoices.includes(gate['default_choice'])) {
|
|
1240
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.default_choice' (${JSON.stringify(gate['default_choice'])}) ` +
|
|
1241
|
+
`is not one of the step's effective choices: ${effectiveChoices.join(', ')}`));
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
else if (hasDefaultChoice && gateTrusted) {
|
|
1246
|
+
// default_choice with on_expiry:'abort' or with no on_expiry at all — inert, not an
|
|
1247
|
+
// error: WARN as dead config (the #220 DEAD_VALIDATION_EXHAUSTION_CONFIG precedent).
|
|
1248
|
+
// Gate-trusted only (issue #524) — off gate trust the block advisory below covers it.
|
|
1249
|
+
warnings.push({
|
|
1250
|
+
code: 'DEAD_GATE_CONFIG',
|
|
1251
|
+
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1252
|
+
scope: 'step',
|
|
1253
|
+
step: stepName,
|
|
1254
|
+
message: `Step '${stepName}': 'gate.default_choice' is ignored without ` +
|
|
1255
|
+
`'gate.on_expiry: settle_default' — set it, or remove 'gate.default_choice'.`,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
if (gateTrusted) {
|
|
1259
|
+
// Dead config: on_expiry declared but no timeout_seconds — nothing will ever trigger
|
|
1260
|
+
// the enforce clock, so the declared disposition can never enact. Gate-trusted only
|
|
1261
|
+
// (issue #524): off gate trust the block advisory below covers it.
|
|
1262
|
+
if (onExpiry !== undefined && gate['timeout_seconds'] === undefined) {
|
|
1263
|
+
warnings.push({
|
|
1264
|
+
code: 'DEAD_GATE_CONFIG',
|
|
1265
|
+
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1266
|
+
scope: 'step',
|
|
1267
|
+
step: stepName,
|
|
1268
|
+
message: `Step '${stepName}': 'gate.on_expiry' is ignored without 'gate.timeout_seconds' ` +
|
|
1269
|
+
`— set a timeout, or remove 'gate.on_expiry'.`,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
// Dead notification ([F-A2-5]): reminder_seconds >= timeout_seconds means the FIRST
|
|
1273
|
+
// reminder occurrence would never fire before the enforce clock expires. Gate-trusted
|
|
1274
|
+
// only (issue #524): off gate trust the block advisory below covers it.
|
|
1275
|
+
if (typeof gate['reminder_seconds'] === 'number' &&
|
|
1276
|
+
typeof gate['timeout_seconds'] === 'number' &&
|
|
1277
|
+
gate['reminder_seconds'] >= gate['timeout_seconds']) {
|
|
1278
|
+
warnings.push({
|
|
1279
|
+
code: 'DEAD_GATE_CONFIG',
|
|
1280
|
+
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1281
|
+
scope: 'step',
|
|
1282
|
+
step: stepName,
|
|
1283
|
+
message: `Step '${stepName}': 'gate.reminder_seconds' (${String(gate['reminder_seconds'])}) ` +
|
|
1284
|
+
`>= 'gate.timeout_seconds' (${String(gate['timeout_seconds'])}) — the first ` +
|
|
1285
|
+
`reminder would never fire before the gate expires.`,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
// issue #524 (the gate-remedy silence): without gate trust the mint never reads ANY
|
|
1291
|
+
// key in this block, so a per-member remedy ("set a timeout") is false — following it
|
|
1292
|
+
// would silence the diagnostic while the whole block stays exactly as dead. ONE
|
|
1293
|
+
// advisory naming the true cause, unconditional on which keys are set (the block is
|
|
1294
|
+
// equally inert whichever ones are). Position on the STRUCTURED channel only
|
|
1295
|
+
// (`withKeyLine`/`withPathLine` are the ERROR-string helpers, consumed only by
|
|
1296
|
+
// `errors.push` — no loader advisory carries a position today, and this one follows
|
|
1297
|
+
// that convention: `renderLoaderWarning` prints `⚠ ${message}` alone). The kind list
|
|
1298
|
+
// is DERIVED from the registry (`consumedKindsFor('trust')`, the #517
|
|
1299
|
+
// `consumed_home.kinds` pattern) so the remedy can never drift from the vocabulary
|
|
1300
|
+
// that actually gates it.
|
|
1301
|
+
const gatePos = sourceMap.posOf(['steps', stepName, 'gate']);
|
|
1302
|
+
const trustKinds = consumedKindsFor('trust');
|
|
1303
|
+
warnings.push({
|
|
1304
|
+
code: 'DEAD_GATE_CONFIG',
|
|
1305
|
+
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1306
|
+
scope: 'step',
|
|
1307
|
+
step: stepName,
|
|
1308
|
+
key: 'gate',
|
|
1309
|
+
...(gatePos !== undefined
|
|
1310
|
+
? {
|
|
1311
|
+
line: gatePos.line,
|
|
1312
|
+
column: gatePos.column,
|
|
1313
|
+
endLine: gatePos.endLine,
|
|
1314
|
+
endColumn: gatePos.endColumn,
|
|
1315
|
+
}
|
|
1316
|
+
: {}),
|
|
1317
|
+
message: `Step '${stepName}': the 'gate:' block is inert — this step declares no gate ` +
|
|
1318
|
+
`trust ('trust: human_confirmed' or 'trust: human_reviewed'), so no gate is ever ` +
|
|
1319
|
+
`minted and none of its keys are read. Remove the block, or (on an ` +
|
|
1320
|
+
`${trustKinds.join(' or ')} step) declare that trust.`,
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1221
1323
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
`dispatch path never throws for agent steps, so this block can never mint a second ` +
|
|
1236
|
-
`attempt here (for schema-repair budgets, use the CLI drive's '--schema-retries' ` +
|
|
1237
|
-
`flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
|
|
1238
|
-
`config — a deliberate public-API capability, not an invalid one.`
|
|
1239
|
-
: `Step '${stepName}': 'retry' is inert on execution: '${String(step['execution'])}' ` +
|
|
1240
|
-
`steps — the built-in dispatch path never throws for these steps, so this block can ` +
|
|
1241
|
-
`never mint a second attempt here. An embedder-supplied throwing dispatcher may ` +
|
|
1242
|
-
`still consume this config — a deliberate public-API capability, not an invalid one.`;
|
|
1243
|
-
warnings.push({
|
|
1244
|
-
code: 'RETRY_INERT_NON_AUTO',
|
|
1245
|
-
severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
|
|
1246
|
-
scope: 'step',
|
|
1247
|
-
step: stepName,
|
|
1248
|
-
message,
|
|
1249
|
-
});
|
|
1324
|
+
}
|
|
1325
|
+
if ('uses_service' in step && typeof step['uses_service'] === 'string') {
|
|
1326
|
+
const services = doc['services'];
|
|
1327
|
+
if (typeof services !== 'object' ||
|
|
1328
|
+
services === null ||
|
|
1329
|
+
!(step['uses_service'] in services)) {
|
|
1330
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': uses_service '${step['uses_service']}' is not defined in 'services'`));
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
// Validate retry: backoff must be a recognised value when present.
|
|
1334
|
+
if (step['retry'] !== undefined) {
|
|
1335
|
+
if (typeof step['retry'] !== 'object' || step['retry'] === null) {
|
|
1336
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry' must be an object`));
|
|
1250
1337
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1338
|
+
else {
|
|
1339
|
+
const retry = step['retry'];
|
|
1340
|
+
// The one population gate both retry advisories (W5 + RETRY_INERT_NON_AUTO) share:
|
|
1341
|
+
// the registry's own retry cell for this step's kind. `inert` = admitted-but-unread
|
|
1342
|
+
// (agent/guard). On `consumed` (auto) neither advisory applies; on `prohibited`
|
|
1343
|
+
// (finalizer) the #517 refusal above is the whole story and an advisory beside it
|
|
1344
|
+
// would contradict it; on a malformed kind the invalid-execution error is the verdict.
|
|
1345
|
+
const retryCellIsInert = VALID_EXECUTIONS.has(step['execution']) &&
|
|
1346
|
+
STEP_KEY_REGISTRY.retry[step['execution']].c === 'inert';
|
|
1347
|
+
// WARN (do not reject) on an unknown retry-block key — same non-breaking posture as the
|
|
1348
|
+
// step/workflow-level checks (issue #140). Noun overridden to 'retry' (not 'step') since
|
|
1349
|
+
// this is a nested block, not the step itself.
|
|
1350
|
+
warnings.push(...findUnknownKeys(retry, KNOWN_RETRY_KEYS, {
|
|
1259
1351
|
scope: 'step',
|
|
1352
|
+
code: 'UNKNOWN_RETRY_KEY',
|
|
1260
1353
|
step: stepName,
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
});
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1354
|
+
noun: 'retry',
|
|
1355
|
+
positionOf: (key) => sourceMap.posOf(['steps', stepName, 'retry', key]),
|
|
1356
|
+
}));
|
|
1357
|
+
if ('backoff' in retry &&
|
|
1358
|
+
retry['backoff'] !== 'fixed' &&
|
|
1359
|
+
retry['backoff'] !== 'linear' &&
|
|
1360
|
+
retry['backoff'] !== 'exponential') {
|
|
1361
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.backoff' must be 'fixed', 'linear', or 'exponential'`));
|
|
1362
|
+
}
|
|
1363
|
+
if ('max_attempts' in retry &&
|
|
1364
|
+
(!Number.isInteger(retry['max_attempts']) || retry['max_attempts'] < 1)) {
|
|
1365
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_attempts' must be a positive integer`));
|
|
1366
|
+
}
|
|
1367
|
+
if ('base_delay_ms' in retry &&
|
|
1368
|
+
(typeof retry['base_delay_ms'] !== 'number' || retry['base_delay_ms'] < 0)) {
|
|
1369
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.base_delay_ms' must be a non-negative number`));
|
|
1370
|
+
}
|
|
1371
|
+
if ('max_delay_ms' in retry &&
|
|
1372
|
+
(typeof retry['max_delay_ms'] !== 'number' || retry['max_delay_ms'] < 0)) {
|
|
1373
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.max_delay_ms' must be a non-negative number`));
|
|
1374
|
+
}
|
|
1375
|
+
// --- issue #140: on_timeout / total_timeout_seconds --------------------------------
|
|
1376
|
+
// E3: on_timeout must be a boolean (kills the 'on_timeout: "true"' silent-inert case).
|
|
1377
|
+
if ('on_timeout' in retry && typeof retry['on_timeout'] !== 'boolean') {
|
|
1378
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout' must be a boolean`));
|
|
1379
|
+
}
|
|
1380
|
+
// E2: total_timeout_seconds must be a positive integer — same convention as
|
|
1381
|
+
// timeout_seconds (0 is rejected here at load; a hand-built definition bypassing the
|
|
1382
|
+
// loader may still set 0 and have the engine's resolveCapMs honor it as a present cap).
|
|
1383
|
+
if ('total_timeout_seconds' in retry &&
|
|
1384
|
+
(!Number.isInteger(retry['total_timeout_seconds']) ||
|
|
1385
|
+
retry['total_timeout_seconds'] <= 0)) {
|
|
1386
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.total_timeout_seconds' must be a positive integer`));
|
|
1387
|
+
}
|
|
1388
|
+
// E1: on_timeout: true requires idempotent: true — declared, never inferred. Strict
|
|
1389
|
+
// `=== true` on both loci, provably matching the engine's own conjunct.
|
|
1390
|
+
if (retry['on_timeout'] === true && step['idempotent'] !== true) {
|
|
1391
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'retry.on_timeout: true' requires 'idempotent: true' declared ` +
|
|
1392
|
+
`on the step — a timeout-retry can run concurrently with the still-in-flight ` +
|
|
1393
|
+
`original attempt, so the step must explicitly attest that any partial prior ` +
|
|
1394
|
+
`application is harmless to re-apply. Declare 'idempotent: true' or remove ` +
|
|
1395
|
+
`'on_timeout'.`));
|
|
1396
|
+
}
|
|
1397
|
+
// W5 (CAP-ONLY advisory — the on_timeout half of this is already an E1 hard error, so
|
|
1398
|
+
// it never reaches here as a warning): the total-time cap only bounds `execution: 'auto'`
|
|
1399
|
+
// dispatch — inert on any other step type that LEGALLY declares `retry:`. The gate is
|
|
1400
|
+
// the registry's own retry cell: the advisory fires only where retry is admitted-but-
|
|
1401
|
+
// inert (agent/guard), never beside the finalizer refusal it would contradict, and
|
|
1402
|
+
// never on a malformed kind (already refused by the invalid-execution error).
|
|
1403
|
+
if (retryCellIsInert && retry['total_timeout_seconds'] !== undefined) {
|
|
1281
1404
|
warnings.push({
|
|
1282
|
-
code: '
|
|
1283
|
-
severity: resolveSeverity('
|
|
1405
|
+
code: 'TOTAL_TIMEOUT_NON_AUTO',
|
|
1406
|
+
severity: resolveSeverity('TOTAL_TIMEOUT_NON_AUTO'),
|
|
1284
1407
|
scope: 'step',
|
|
1285
1408
|
step: stepName,
|
|
1286
|
-
message: `Step '${stepName}': 'retry.total_timeout_seconds
|
|
1287
|
-
`
|
|
1288
|
-
|
|
1289
|
-
`occur before the cap fires.`,
|
|
1409
|
+
message: `Step '${stepName}': 'retry.total_timeout_seconds' is inert on execution: ` +
|
|
1410
|
+
`'${String(step['execution'])}' steps — the cap only bounds 'execution: auto' ` +
|
|
1411
|
+
`dispatch; no other kind's dispatch ever consumes it.`,
|
|
1290
1412
|
});
|
|
1291
1413
|
}
|
|
1414
|
+
// issue #218 (extends the W5 family): the BARE-KEYS advisory — no explicit
|
|
1415
|
+
// total_timeout_seconds (that shape is W5's, above), but retry: is present on a step no
|
|
1416
|
+
// dispatching retry loop ever consumes it on. Complementary to W5's own `!== undefined`
|
|
1417
|
+
// conjunct on the SAME registry-derived inert gate, so for any admitted-but-inert retry
|
|
1418
|
+
// block exactly ONE of {W5, RETRY_INERT_NON_AUTO} ever fires — never both, never
|
|
1419
|
+
// neither. The gate EXCLUDES the prohibited kind (finalizer): errors accumulate rather
|
|
1420
|
+
// than halt, so the old `!== 'auto'` gate leaked this advisory beside the finalizer
|
|
1421
|
+
// refusal, where every clause of it was false ("never throws" — the drain throws
|
|
1422
|
+
// routinely; "may still consume" — no dispatcher can reach a finalizer's retry;
|
|
1423
|
+
// "not an invalid one" — the co-fired error says it IS invalid). Registry-derived:
|
|
1424
|
+
// it fires exactly where the retry cell is inert (agent/guard).
|
|
1425
|
+
if (retryCellIsInert && retry['total_timeout_seconds'] === undefined) {
|
|
1426
|
+
const isAgent = step['execution'] === 'agent';
|
|
1427
|
+
const message = isAgent
|
|
1428
|
+
? `Step '${stepName}': 'retry' is inert on execution: 'agent' steps — the built-in ` +
|
|
1429
|
+
`dispatch path never throws for agent steps, so this block can never mint a second ` +
|
|
1430
|
+
`attempt here (for schema-repair budgets, use the CLI drive's '--schema-retries' ` +
|
|
1431
|
+
`flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
|
|
1432
|
+
`config — a deliberate public-API capability, not an invalid one.`
|
|
1433
|
+
: `Step '${stepName}': 'retry' is inert on execution: '${String(step['execution'])}' ` +
|
|
1434
|
+
`steps — a guard's evaluation never traverses the dispatch path (its conditions ` +
|
|
1435
|
+
`are evaluated inline, with no dispatcher and no retry read), so this block can ` +
|
|
1436
|
+
`never mint a second attempt here.`;
|
|
1437
|
+
warnings.push({
|
|
1438
|
+
code: 'RETRY_INERT_NON_AUTO',
|
|
1439
|
+
severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
|
|
1440
|
+
scope: 'step',
|
|
1441
|
+
step: stepName,
|
|
1442
|
+
message,
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
// W1: on_timeout with an effective max_attempts of 1 (explicit OR absent, since the
|
|
1446
|
+
// loader admits an absent max_attempts and the engine then defaults it to 1) — there is
|
|
1447
|
+
// no second attempt for the opt-in to retry into.
|
|
1448
|
+
const effectiveMaxAttempts = typeof retry['max_attempts'] === 'number' ? retry['max_attempts'] : 1;
|
|
1449
|
+
if (retry['on_timeout'] === true && effectiveMaxAttempts === 1) {
|
|
1450
|
+
warnings.push({
|
|
1451
|
+
code: 'ON_TIMEOUT_SINGLE_ATTEMPT',
|
|
1452
|
+
severity: resolveSeverity('ON_TIMEOUT_SINGLE_ATTEMPT'),
|
|
1453
|
+
scope: 'step',
|
|
1454
|
+
step: stepName,
|
|
1455
|
+
message: `Step '${stepName}': 'retry.on_timeout: true' has no effect with an effective ` +
|
|
1456
|
+
`'max_attempts' of 1 — there is no second attempt to retry into.`,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
// W2 (issue #524 correction): the declared cap is at or below the per-attempt timeout —
|
|
1460
|
+
// (a) an EXPLICIT cap below an EXPLICIT timeout_seconds, or (b) on_timeout: true with a
|
|
1461
|
+
// cap at-or-below the effective per-attempt timeout. This does NOT mean "no retry can
|
|
1462
|
+
// ever occur": `willRetry`'s first disjunct (execution-loop.ts) has no cap conjunct, so
|
|
1463
|
+
// a retryable failure that returns faster than the (clipped) attempt bound still retries
|
|
1464
|
+
// while 'max_attempts' allows another attempt — only an attempt that runs OUT its full
|
|
1465
|
+
// bound exhausts the cap with nothing left for a retry. Both arms require an EXPLICIT
|
|
1466
|
+
// total_timeout_seconds — the AMENDED default cap (the worst-case schedule) is, by
|
|
1467
|
+
// construction, never below a single attempt for max_attempts ≥ 2, so this never fires
|
|
1468
|
+
// on the bare 3600s-default population.
|
|
1469
|
+
const explicitCapSeconds = typeof retry['total_timeout_seconds'] === 'number'
|
|
1470
|
+
? retry['total_timeout_seconds']
|
|
1471
|
+
: undefined;
|
|
1472
|
+
if (explicitCapSeconds !== undefined) {
|
|
1473
|
+
const explicitTimeoutSeconds = typeof step['timeout_seconds'] === 'number' ? step['timeout_seconds'] : undefined;
|
|
1474
|
+
const effectivePerAttemptSeconds = explicitTimeoutSeconds ?? DEFAULT_EXECUTION_TIMEOUT_SECONDS;
|
|
1475
|
+
const belowExplicitAttempt = explicitTimeoutSeconds !== undefined && explicitCapSeconds < explicitTimeoutSeconds;
|
|
1476
|
+
const capTooTightForRetry = retry['on_timeout'] === true && explicitCapSeconds <= effectivePerAttemptSeconds;
|
|
1477
|
+
if (belowExplicitAttempt || capTooTightForRetry) {
|
|
1478
|
+
warnings.push({
|
|
1479
|
+
code: 'TOTAL_TIMEOUT_BELOW_ATTEMPT',
|
|
1480
|
+
severity: resolveSeverity('TOTAL_TIMEOUT_BELOW_ATTEMPT'),
|
|
1481
|
+
scope: 'step',
|
|
1482
|
+
step: stepName,
|
|
1483
|
+
message: `Step '${stepName}': 'retry.total_timeout_seconds: ${explicitCapSeconds}' is at ` +
|
|
1484
|
+
`or below its per-attempt timeout (${effectivePerAttemptSeconds}s` +
|
|
1485
|
+
`${explicitTimeoutSeconds === undefined ? ', the default' : ''}) — each attempt ` +
|
|
1486
|
+
`is bounded by what remains of the cap, so an attempt that runs to its bound ` +
|
|
1487
|
+
`exhausts the cap with no retry; a faster failure still retries while ` +
|
|
1488
|
+
`'max_attempts' allows another attempt and its backoff wait fits the remaining cap.`,
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1292
1492
|
}
|
|
1293
1493
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
}
|
|
1298
|
-
// Validate input_map: only valid on execution: auto steps (both uses_service and handler).
|
|
1299
|
-
if (step['input_map'] !== undefined) {
|
|
1300
|
-
if (step['execution'] !== 'auto') {
|
|
1301
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'input_map' is only valid on execution: auto steps`));
|
|
1494
|
+
if ('service_method' in step &&
|
|
1495
|
+
!VALID_SERVICE_METHODS.has(step['service_method'])) {
|
|
1496
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`));
|
|
1302
1497
|
}
|
|
1303
|
-
|
|
1498
|
+
// Validate input_map VALUES (auto only — the kind half is minted by the #517 walk; the
|
|
1499
|
+
// explicit conjunct preserves the old else-branch: a wrong-kind step gets only the minted
|
|
1500
|
+
// refusal, never the value noise).
|
|
1501
|
+
if (step['input_map'] !== undefined && step['execution'] === 'auto') {
|
|
1304
1502
|
// issue #392: input_map's errors are minted deep inside a recursive walk that knows only
|
|
1305
1503
|
// its path string, not the step's position. Collected here and suffixed on the way out,
|
|
1306
1504
|
// so ONE step's error list never mixes positioned and bare messages — a reader seeing
|
|
1307
|
-
// "(line 12)" on three of five errors would reasonably wonder what is different about
|
|
1505
|
+
// "(step at line 12)" on three of five errors would reasonably wonder what is different about
|
|
1308
1506
|
// the other two, and nothing is.
|
|
1309
1507
|
const inputMapErrors = [];
|
|
1310
1508
|
validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
|
|
1311
1509
|
errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
|
|
1312
1510
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1511
|
+
// Step config may hold any JSON value (scalars, arrays, nested objects). It is passed through
|
|
1512
|
+
// opaquely to handlers (context.config) and merged into adapter config for uses_service steps;
|
|
1513
|
+
// the adapter's config_schema (below) remains the real validator for uses_service config.
|
|
1514
|
+
// Validate step config against adapter config_schema (requires registry).
|
|
1515
|
+
if (step['config'] !== undefined && step['uses_service'] !== undefined) {
|
|
1516
|
+
const serviceName = step['uses_service'];
|
|
1517
|
+
const services = doc['services'];
|
|
1518
|
+
const service = services?.[serviceName];
|
|
1519
|
+
const adapterName = service?.['adapter'];
|
|
1520
|
+
const adapter = adapterName !== undefined ? registry?.getAdapter(adapterName) : undefined;
|
|
1521
|
+
if (adapter !== undefined && adapter.config_schema === undefined) {
|
|
1522
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'config' declared but adapter '${adapterName}' does not declare 'config_schema'`));
|
|
1523
|
+
}
|
|
1524
|
+
else if (adapter?.config_schema !== undefined) {
|
|
1525
|
+
const ajv = new Ajv();
|
|
1526
|
+
const valid = ajv.validate(adapter.config_schema, step['config']);
|
|
1527
|
+
if (!valid) {
|
|
1528
|
+
const errMessages = ajv.errors?.map((e) => e.message ?? '').join('; ') ?? 'unknown error';
|
|
1529
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': config validation failed against adapter config_schema: ${errMessages}`));
|
|
1530
|
+
}
|
|
1333
1531
|
}
|
|
1334
1532
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1533
|
+
// Validate uses_resources: each listed step ID must exist in the workflow.
|
|
1534
|
+
if (step['handler'] !== undefined && registry !== undefined) {
|
|
1535
|
+
const handlerName = step['handler'];
|
|
1536
|
+
const handler = registry.getHandler(handlerName);
|
|
1537
|
+
if (handler !== undefined && handler.uses_resources !== undefined) {
|
|
1538
|
+
for (const resourceStepId of handler.uses_resources) {
|
|
1539
|
+
if (!(resourceStepId in stepsRaw)) {
|
|
1540
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': handler '${handlerName}' declares uses_resources '${resourceStepId}' ` +
|
|
1541
|
+
`but no step with that ID exists in this workflow`));
|
|
1542
|
+
}
|
|
1345
1543
|
}
|
|
1346
1544
|
}
|
|
1347
1545
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1546
|
+
// Validate trigger_rule.
|
|
1547
|
+
if ('trigger_rule' in step) {
|
|
1548
|
+
if (!VALID_TRIGGER_RULES.has(step['trigger_rule'])) {
|
|
1549
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': invalid trigger_rule '${String(step['trigger_rule'])}'; must be one of ${[...VALID_TRIGGER_RULES].join(', ')}`));
|
|
1550
|
+
}
|
|
1353
1551
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1552
|
+
// Validate depends_on: must be an array of existing step names.
|
|
1553
|
+
if ('depends_on' in step && step['depends_on'] !== undefined) {
|
|
1554
|
+
if (!Array.isArray(step['depends_on'])) {
|
|
1555
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'depends_on' must be an array`));
|
|
1556
|
+
}
|
|
1557
|
+
else {
|
|
1558
|
+
for (const dep of step['depends_on']) {
|
|
1559
|
+
if (typeof dep !== 'string') {
|
|
1560
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': depends_on entries must be strings`));
|
|
1561
|
+
}
|
|
1562
|
+
else if (dep === stepName) {
|
|
1563
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': a step cannot depend on itself`));
|
|
1564
|
+
}
|
|
1565
|
+
else if (!(dep in stepsRaw)) {
|
|
1566
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references unknown step '${dep}'`));
|
|
1567
|
+
}
|
|
1568
|
+
else if (stepsRaw[dep]['execution'] === 'finalizer') {
|
|
1569
|
+
// A domain step depending on a held-out finalizer would deadlock: the finalizer
|
|
1570
|
+
// never enters the eligible set, so this step never becomes eligible and the run
|
|
1571
|
+
// never seals.
|
|
1572
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': depends_on references finalizer step '${dep}' — finalizers ` +
|
|
1573
|
+
`run at the terminal transition and are held out of the DAG; a step cannot depend on one.`));
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1359
1577
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1578
|
+
// Validate when: string | string[] of single-comparison/bare-path leaves (implicit AND).
|
|
1579
|
+
if ('when' in step && step['when'] !== undefined) {
|
|
1580
|
+
const rawWhen = step['when'];
|
|
1581
|
+
if (typeof rawWhen === 'string') {
|
|
1582
|
+
if (rawWhen.trim() === '') {
|
|
1583
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a non-empty string`));
|
|
1364
1584
|
}
|
|
1365
|
-
else
|
|
1366
|
-
|
|
1585
|
+
else {
|
|
1586
|
+
validateConditionLeaf('when', rawWhen, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1367
1587
|
}
|
|
1368
|
-
|
|
1369
|
-
|
|
1588
|
+
}
|
|
1589
|
+
else if (Array.isArray(rawWhen)) {
|
|
1590
|
+
if (rawWhen.length === 0) {
|
|
1591
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array must not be empty`));
|
|
1370
1592
|
}
|
|
1371
|
-
else
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1593
|
+
else {
|
|
1594
|
+
for (const leaf of rawWhen) {
|
|
1595
|
+
if (typeof leaf !== 'string' || leaf.trim() === '') {
|
|
1596
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array entries must be non-empty strings`));
|
|
1597
|
+
}
|
|
1598
|
+
else {
|
|
1599
|
+
validateConditionLeaf('when', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1377
1602
|
}
|
|
1378
1603
|
}
|
|
1604
|
+
else {
|
|
1605
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a string or an array of strings`));
|
|
1606
|
+
}
|
|
1379
1607
|
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
if (
|
|
1386
|
-
|
|
1608
|
+
// Validate abort_unless leaf shape (guard steps only; the LEGACY depends_on/run.params
|
|
1609
|
+
// reference check is when-only — but issue #220 §4c's `$settlement` one-hop check fires here
|
|
1610
|
+
// too, via the hoisted `dependsOn`, SCOPED to `$settlement.`-prefixed paths only).
|
|
1611
|
+
if (step['abort_unless'] !== undefined && step['execution'] === 'guard') {
|
|
1612
|
+
const rawAbort = step['abort_unless'];
|
|
1613
|
+
if (typeof rawAbort === 'string') {
|
|
1614
|
+
if (rawAbort.trim() === '') {
|
|
1615
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a non-empty string`));
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
else if (Array.isArray(rawAbort)) {
|
|
1622
|
+
if (rawAbort.length === 0) {
|
|
1623
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array must not be empty`));
|
|
1624
|
+
}
|
|
1625
|
+
else {
|
|
1626
|
+
for (const leaf of rawAbort) {
|
|
1627
|
+
if (typeof leaf !== 'string' || leaf.trim() === '') {
|
|
1628
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array entries must be non-empty strings`));
|
|
1629
|
+
}
|
|
1630
|
+
else {
|
|
1631
|
+
validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1387
1635
|
}
|
|
1388
1636
|
else {
|
|
1389
|
-
|
|
1637
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a string or an array of strings`));
|
|
1390
1638
|
}
|
|
1391
1639
|
}
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1640
|
+
// Validate preconditions leaf shape (each must be a single comparison). Reference check is
|
|
1641
|
+
// `$settlement`-scoped only (issue #220 §4c) — a non-`$settlement` precondition has no
|
|
1642
|
+
// depends_on/run.params check (unchanged from before this PR).
|
|
1643
|
+
if (step['preconditions'] !== undefined) {
|
|
1644
|
+
const rawPre = step['preconditions'];
|
|
1645
|
+
if (!Array.isArray(rawPre)) {
|
|
1646
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' must be an array of strings`));
|
|
1395
1647
|
}
|
|
1396
1648
|
else {
|
|
1397
|
-
for (const leaf of
|
|
1649
|
+
for (const leaf of rawPre) {
|
|
1398
1650
|
if (typeof leaf !== 'string' || leaf.trim() === '') {
|
|
1399
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': '
|
|
1651
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' entries must be non-empty strings`));
|
|
1400
1652
|
}
|
|
1401
1653
|
else {
|
|
1402
|
-
validateConditionLeaf('
|
|
1654
|
+
validateConditionLeaf('preconditions', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1403
1655
|
}
|
|
1404
1656
|
}
|
|
1405
1657
|
}
|
|
1406
1658
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array must not be empty`));
|
|
1659
|
+
// issue #362 — REJECT A PROVABLY-DEAD FAILURE CONDITION.
|
|
1660
|
+
//
|
|
1661
|
+
// `$settlement.<dep>.failed == true` under a trigger rule that structurally excludes a failed
|
|
1662
|
+
// `<dep>` can never be true. The trigger gate runs BEFORE the condition gate, and both
|
|
1663
|
+
// `all_success` and `none_failed` carry an explicit "no dep in failed_steps" conjunct — so if
|
|
1664
|
+
// the rule is satisfied, `<dep>` did not fail, and the condition is false by construction.
|
|
1665
|
+
//
|
|
1666
|
+
// The author's compensation step therefore never runs. It is not silent at runtime — the run
|
|
1667
|
+
// record says `trigger_rule_unsatisfiable` — but it names the RULE and the blocking dep, never
|
|
1668
|
+
// the condition the author wrote, so the diagnosis points away from the mistake. This is an
|
|
1669
|
+
// authoring-time error precisely because the fix is one word and no legitimate use of the
|
|
1670
|
+
// shape exists.
|
|
1671
|
+
dead_condition: {
|
|
1672
|
+
const rule = step['execution'] === 'guard' ? 'all_success' : (step['trigger_rule'] ?? 'all_success');
|
|
1673
|
+
// A guard may not declare `trigger_rule` at all (it is a prohibited field), so a guard that
|
|
1674
|
+
// declares one must NOT be able to suppress this check by doing so.
|
|
1675
|
+
const ruleDeclared = step['execution'] !== 'guard' && step['trigger_rule'] !== undefined;
|
|
1676
|
+
if (step['execution'] !== 'guard' && !VALID_TRIGGER_RULES.has(rule)) {
|
|
1677
|
+
break dead_condition; // an invalid rule already has its own error — adding noise helps nobody
|
|
1427
1678
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1679
|
+
if (rule !== 'all_success' && rule !== 'none_failed')
|
|
1680
|
+
break dead_condition;
|
|
1681
|
+
const asLeaves = (v) => Array.isArray(v)
|
|
1682
|
+
? v.filter((x) => typeof x === 'string')
|
|
1683
|
+
: typeof v === 'string'
|
|
1684
|
+
? [v]
|
|
1685
|
+
: [];
|
|
1686
|
+
const surfaces = [
|
|
1687
|
+
{ surface: 'when', leaves: asLeaves(step['when']) },
|
|
1688
|
+
// `abort_unless` is only validated (and only meaningful) on guards; on anything else the
|
|
1689
|
+
// loader leaves it alone and the engine never reads it.
|
|
1690
|
+
{
|
|
1691
|
+
surface: 'abort_unless',
|
|
1692
|
+
leaves: step['execution'] === 'guard' ? asLeaves(step['abort_unless']) : [],
|
|
1693
|
+
},
|
|
1694
|
+
// `preconditions` is collected for EVERY step kind, but it is INERT on a guard: the sole
|
|
1695
|
+
// `checkPreconditions` call site is `executeStep` (execution-loop.ts:1380), and a guard
|
|
1696
|
+
// goes through `executeGuardStep`, which evaluates only `abort_unless`. That is why the
|
|
1697
|
+
// guard arm's consequence below is forked — collapsing it back into one shared string
|
|
1698
|
+
// would make the error claim a wedge that cannot happen.
|
|
1699
|
+
//
|
|
1700
|
+
// Post-#369 a guard declaring `preconditions` is REFUSED outright — since #517, by
|
|
1701
|
+
// the registry mint near the top of Step 3 — so this arm now only ever fires ALONGSIDE
|
|
1702
|
+
// that refusal: errors accumulate rather than short-circuit, and the mint runs first, so both messages reach the author
|
|
1703
|
+
// with the prohibition printed above this one. The arm is kept, not deleted — it is what
|
|
1704
|
+
// stops the dead-condition message from claiming a wedge that a guard cannot have, and a
|
|
1705
|
+
// definition reaching this code by any path other than a fresh YAML load (a
|
|
1706
|
+
// store-registered definition, an inline object) is never re-parsed and never sees the
|
|
1707
|
+
// prohibition at all.
|
|
1708
|
+
{ surface: 'preconditions', leaves: asLeaves(step['preconditions']) },
|
|
1709
|
+
];
|
|
1710
|
+
for (const { surface, leaves } of surfaces) {
|
|
1711
|
+
for (const leaf of leaves) {
|
|
1712
|
+
const split = splitComparison(leaf);
|
|
1713
|
+
// Two spellings are equally dead: the explicit `== true`, and the BARE PATH, which the
|
|
1714
|
+
// engine coerces with `Boolean()`. `preconditions` refuses bare paths anyway.
|
|
1715
|
+
const lhsPath = split.kind === 'comparison' && split.op === '==' && split.rhsRaw.trim() === 'true'
|
|
1716
|
+
? split.lhsPath
|
|
1717
|
+
: split.kind === 'path'
|
|
1718
|
+
? split.path
|
|
1719
|
+
: undefined;
|
|
1720
|
+
if (lhsPath === undefined)
|
|
1721
|
+
continue;
|
|
1722
|
+
// EXACTLY three segments. `$settlement.x.failed.deep` is also dead, but for a different
|
|
1723
|
+
// reason, so the trigger-rule remedy would be wrong advice there.
|
|
1724
|
+
const segments = lhsPath.trim().split('.');
|
|
1725
|
+
if (segments.length !== 3 ||
|
|
1726
|
+
segments[0] !== '$settlement' ||
|
|
1727
|
+
segments[2] !== 'failed') {
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
const dep = segments[1];
|
|
1731
|
+
// Without the dep actually being a dependency, the one-hop error fires on its own AND
|
|
1732
|
+
// the trigger gate returns true unconditionally for a step with no deps — so the leaf
|
|
1733
|
+
// is not dead-by-trigger here and the remedy would be false advice.
|
|
1734
|
+
if (!dependsOn.includes(dep))
|
|
1735
|
+
continue;
|
|
1736
|
+
const ruleText = ruleDeclared ? `'${rule}'` : `the default '${rule}'`;
|
|
1737
|
+
const guardPrecondition = step['execution'] === 'guard' && surface === 'preconditions';
|
|
1738
|
+
const consequence = surface === 'when'
|
|
1739
|
+
? `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`
|
|
1740
|
+
: surface === 'preconditions'
|
|
1741
|
+
? guardPrecondition
|
|
1742
|
+
? // NOT the wedge: an unevaluated condition cannot block anything.
|
|
1743
|
+
`the run behaves identically whether this condition is present or absent`
|
|
1744
|
+
: `the step never settles — the run WEDGES in a blocked envelope`
|
|
1745
|
+
: `the guard aborts the run on every execution`;
|
|
1746
|
+
if (step['execution'] === 'guard') {
|
|
1747
|
+
// Guards cannot declare a trigger rule, so the trigger-rule remedy is wrong advice
|
|
1748
|
+
// here. This is a v1 SCOPE narrowing, not an architectural statement — issue #366
|
|
1749
|
+
// carries the design question.
|
|
1750
|
+
//
|
|
1751
|
+
// The middle clause forks with the consequence: "by the time this is evaluated" is
|
|
1752
|
+
// itself false for `preconditions`, which a guard never evaluates at all.
|
|
1753
|
+
const cause = guardPrecondition
|
|
1754
|
+
? `and on an execution: guard step it is never evaluated at all: a guard evaluates ` +
|
|
1755
|
+
`only 'abort_unless', so this condition is inert (${consequence})`
|
|
1756
|
+
: `a guard runs under ${ruleText} and 'trigger_rule' is not a valid field on ` +
|
|
1757
|
+
`execution: guard steps, so '${dep}' has always succeeded by the time this is ` +
|
|
1758
|
+
`evaluated (${consequence})`;
|
|
1759
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — ${cause}. ` +
|
|
1760
|
+
`Guards run only when their dependencies succeeded; for work that must happen AFTER a ` +
|
|
1761
|
+
`failure, use an 'execution: finalizer' step (see issue #366 for widening guards).`));
|
|
1432
1762
|
}
|
|
1433
1763
|
else {
|
|
1434
|
-
|
|
1764
|
+
const remedies = ['all_done', 'one_failed'];
|
|
1765
|
+
if (new Set(dependsOn).size === 1)
|
|
1766
|
+
remedies.push('all_failed');
|
|
1767
|
+
const tail = new Set(dependsOn).size > 1
|
|
1768
|
+
? ` ('all_failed' fires only if EVERY dependency fails; 'one_success' only if at least one other dependency succeeds.)`
|
|
1769
|
+
: '';
|
|
1770
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — under ` +
|
|
1771
|
+
`${ruleText} trigger rule, '${dep}' can never be in failed_steps when this step is ` +
|
|
1772
|
+
`evaluated (${consequence}). To run this step when '${dep}' fails, set trigger_rule to ` +
|
|
1773
|
+
`one of: ${remedies.join(', ')}.${tail}`));
|
|
1435
1774
|
}
|
|
1436
1775
|
}
|
|
1437
1776
|
}
|
|
1438
1777
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1778
|
+
// Validate tools × handler (the COMPOUND half of the old tools rule — #517 split it: the
|
|
1779
|
+
// non-agent kinds are minted from the registry above; this hand-written check keeps ONLY
|
|
1780
|
+
// the agent-with-handler arm, whose predicate is a companion conflict, not a kind rule).
|
|
1781
|
+
// Populations are disjoint by construction (this fires only on execution: 'agent'; the
|
|
1782
|
+
// mint only on non-agent kinds), so the old multi-fire cannot re-appear.
|
|
1783
|
+
if (step['tools'] !== undefined &&
|
|
1784
|
+
step['execution'] === 'agent' &&
|
|
1785
|
+
step['handler'] !== undefined) {
|
|
1786
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`));
|
|
1441
1787
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1788
|
+
// issue #413: `tool_timeout` requires `tools`. It bounds ONE tool call inside the agentic
|
|
1789
|
+
// loop (run-agent.ts), and a step with no tools never enters that loop — so the key sits
|
|
1790
|
+
// there bounding nothing while its author believes tool calls are capped.
|
|
1791
|
+
//
|
|
1792
|
+
// An EMPTY list counts as missing, and that is not pedantry: run-agent gates the tools path
|
|
1793
|
+
// on `tools.length > 0`, so `tools: []` is exactly as toolless at runtime as no key at all.
|
|
1794
|
+
// This one helper is also the shape check's complement further down, which is what makes
|
|
1795
|
+
// "exactly one error" true by construction rather than by coincidence.
|
|
1796
|
+
//
|
|
1797
|
+
// NOT extended to non-array `tools` spellings — that is #391, still open. Under the
|
|
1798
|
+
// `!toolsMissing` complement below, a non-array `tools` still lets the shape check fire, so
|
|
1799
|
+
// nothing is silently exempted here.
|
|
1800
|
+
const toolsMissing = step['tools'] === undefined ||
|
|
1801
|
+
(Array.isArray(step['tools']) && step['tools'].length === 0);
|
|
1802
|
+
if (step['tool_timeout'] !== undefined && toolsMissing) {
|
|
1803
|
+
errors.push(withKeyLine(stepName, 'tool_timeout', `Step '${stepName}': 'tool_timeout' requires 'tools' (a declared, non-empty list) — ` +
|
|
1804
|
+
'without tool calls there is ' +
|
|
1805
|
+
'nothing for it to bound, so the step would carry a bound with nothing to bind. ' +
|
|
1806
|
+
"In realm's own drive each tool call is capped at tool_timeout seconds (default " +
|
|
1807
|
+
'30); declare at least one tool or remove the key.'));
|
|
1450
1808
|
}
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1809
|
+
// Validate tools: requires input_schema.
|
|
1810
|
+
if (step['tools'] !== undefined && step['input_schema'] === undefined) {
|
|
1811
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' requires 'input_schema' to be defined — the agentic loop needs a schema for final output extraction`));
|
|
1812
|
+
}
|
|
1813
|
+
// Validate tools: entries must be in server_id:tool_name format.
|
|
1814
|
+
if (step['tools'] !== undefined && Array.isArray(step['tools'])) {
|
|
1815
|
+
for (const entry of step['tools']) {
|
|
1816
|
+
if (!/^[^:]+:[^:]+$/.test(entry)) {
|
|
1817
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' must be in 'server_id:tool_name' format`));
|
|
1458
1818
|
}
|
|
1459
1819
|
}
|
|
1460
1820
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
// shape exists.
|
|
1474
|
-
dead_condition: {
|
|
1475
|
-
const rule = step['execution'] === 'guard' ? 'all_success' : (step['trigger_rule'] ?? 'all_success');
|
|
1476
|
-
// A guard may not declare `trigger_rule` at all (it is a prohibited field), so a guard that
|
|
1477
|
-
// declares one must NOT be able to suppress this check by doing so.
|
|
1478
|
-
const ruleDeclared = step['execution'] !== 'guard' && step['trigger_rule'] !== undefined;
|
|
1479
|
-
if (step['execution'] !== 'guard' && !VALID_TRIGGER_RULES.has(rule)) {
|
|
1480
|
-
break dead_condition; // an invalid rule already has its own error — adding noise helps nobody
|
|
1821
|
+
// issue #338: the check below only runs when an `mcp_servers` block EXISTS, so the absent-block
|
|
1822
|
+
// variant loaded clean — and every disclosure this loader has for tools lives inside that same
|
|
1823
|
+
// fork, so the corner produced no error, no warning, and a run where the declared tools were
|
|
1824
|
+
// simply never offered. ONE error per step, not one per entry: the entries are not individually
|
|
1825
|
+
// wrong, the workflow is.
|
|
1826
|
+
if (step['tools'] !== undefined &&
|
|
1827
|
+
Array.isArray(step['tools']) &&
|
|
1828
|
+
step['tools'].length > 0 &&
|
|
1829
|
+
!Array.isArray(doc['mcp_servers'])) {
|
|
1830
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': declares tools but the workflow defines no mcp_servers — no drive ` +
|
|
1831
|
+
`can ever offer these tools, so the declaration can never be satisfied. Define an ` +
|
|
1832
|
+
`mcp_servers block, or remove 'tools'.`));
|
|
1481
1833
|
}
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
:
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
// `abort_unless` is only validated (and only meaningful) on guards; on anything else the
|
|
1492
|
-
// loader leaves it alone and the engine never reads it.
|
|
1493
|
-
{
|
|
1494
|
-
surface: 'abort_unless',
|
|
1495
|
-
leaves: step['execution'] === 'guard' ? asLeaves(step['abort_unless']) : [],
|
|
1496
|
-
},
|
|
1497
|
-
// `preconditions` is collected for EVERY step kind, but it is INERT on a guard: the sole
|
|
1498
|
-
// `checkPreconditions` call site is `executeStep` (execution-loop.ts:1380), and a guard
|
|
1499
|
-
// goes through `executeGuardStep`, which evaluates only `abort_unless`. That is why the
|
|
1500
|
-
// guard arm's consequence below is forked — collapsing it back into one shared string
|
|
1501
|
-
// would make the error claim a wedge that cannot happen.
|
|
1502
|
-
//
|
|
1503
|
-
// Post-#369 a guard declaring `preconditions` is REFUSED outright by the guard block
|
|
1504
|
-
// above, so this arm now only ever fires ALONGSIDE that refusal: errors accumulate rather
|
|
1505
|
-
// than short-circuit, and the guard block runs first, so both messages reach the author
|
|
1506
|
-
// with the prohibition printed above this one. The arm is kept, not deleted — it is what
|
|
1507
|
-
// stops the dead-condition message from claiming a wedge that a guard cannot have, and a
|
|
1508
|
-
// definition reaching this code by any path other than a fresh YAML load (a
|
|
1509
|
-
// store-registered definition, an inline object) is never re-parsed and never sees the
|
|
1510
|
-
// prohibition at all.
|
|
1511
|
-
{ surface: 'preconditions', leaves: asLeaves(step['preconditions']) },
|
|
1512
|
-
];
|
|
1513
|
-
for (const { surface, leaves } of surfaces) {
|
|
1514
|
-
for (const leaf of leaves) {
|
|
1515
|
-
const split = splitComparison(leaf);
|
|
1516
|
-
// Two spellings are equally dead: the explicit `== true`, and the BARE PATH, which the
|
|
1517
|
-
// engine coerces with `Boolean()`. `preconditions` refuses bare paths anyway.
|
|
1518
|
-
const lhsPath = split.kind === 'comparison' && split.op === '==' && split.rhsRaw.trim() === 'true'
|
|
1519
|
-
? split.lhsPath
|
|
1520
|
-
: split.kind === 'path'
|
|
1521
|
-
? split.path
|
|
1522
|
-
: undefined;
|
|
1523
|
-
if (lhsPath === undefined)
|
|
1524
|
-
continue;
|
|
1525
|
-
// EXACTLY three segments. `$settlement.x.failed.deep` is also dead, but for a different
|
|
1526
|
-
// reason, so the trigger-rule remedy would be wrong advice there.
|
|
1527
|
-
const segments = lhsPath.trim().split('.');
|
|
1528
|
-
if (segments.length !== 3 || segments[0] !== '$settlement' || segments[2] !== 'failed') {
|
|
1529
|
-
continue;
|
|
1530
|
-
}
|
|
1531
|
-
const dep = segments[1];
|
|
1532
|
-
// Without the dep actually being a dependency, the one-hop error fires on its own AND
|
|
1533
|
-
// the trigger gate returns true unconditionally for a step with no deps — so the leaf
|
|
1534
|
-
// is not dead-by-trigger here and the remedy would be false advice.
|
|
1535
|
-
if (!dependsOn.includes(dep))
|
|
1536
|
-
continue;
|
|
1537
|
-
const ruleText = ruleDeclared ? `'${rule}'` : `the default '${rule}'`;
|
|
1538
|
-
const guardPrecondition = step['execution'] === 'guard' && surface === 'preconditions';
|
|
1539
|
-
const consequence = surface === 'when'
|
|
1540
|
-
? `if '${dep}' fails the step is skipped as trigger_rule_unsatisfiable before the condition is evaluated; if '${dep}' succeeds the condition evaluates to false (when_false) — either way the step never runs`
|
|
1541
|
-
: surface === 'preconditions'
|
|
1542
|
-
? guardPrecondition
|
|
1543
|
-
? // NOT the wedge: an unevaluated condition cannot block anything.
|
|
1544
|
-
`the run behaves identically whether this condition is present or absent`
|
|
1545
|
-
: `the step never settles — the run WEDGES in a blocked envelope`
|
|
1546
|
-
: `the guard aborts the run on every execution`;
|
|
1547
|
-
if (step['execution'] === 'guard') {
|
|
1548
|
-
// Guards cannot declare a trigger rule, so the trigger-rule remedy is wrong advice
|
|
1549
|
-
// here. This is a v1 SCOPE narrowing, not an architectural statement — issue #366
|
|
1550
|
-
// carries the design question.
|
|
1551
|
-
//
|
|
1552
|
-
// The middle clause forks with the consequence: "by the time this is evaluated" is
|
|
1553
|
-
// itself false for `preconditions`, which a guard never evaluates at all.
|
|
1554
|
-
const cause = guardPrecondition
|
|
1555
|
-
? `and on an execution: guard step it is never evaluated at all: a guard evaluates ` +
|
|
1556
|
-
`only 'abort_unless', so this condition is inert (${consequence})`
|
|
1557
|
-
: `a guard runs under ${ruleText} and 'trigger_rule' is not a valid field on ` +
|
|
1558
|
-
`execution: guard steps, so '${dep}' has always succeeded by the time this is ` +
|
|
1559
|
-
`evaluated (${consequence})`;
|
|
1560
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — ${cause}. ` +
|
|
1561
|
-
`Guards run only when their dependencies succeeded; for work that must happen AFTER a ` +
|
|
1562
|
-
`failure, use an 'execution: finalizer' step (see issue #366 for widening guards).`));
|
|
1563
|
-
}
|
|
1564
|
-
else {
|
|
1565
|
-
const remedies = ['all_done', 'one_failed'];
|
|
1566
|
-
if (new Set(dependsOn).size === 1)
|
|
1567
|
-
remedies.push('all_failed');
|
|
1568
|
-
const tail = new Set(dependsOn).size > 1
|
|
1569
|
-
? ` ('all_failed' fires only if EVERY dependency fails; 'one_success' only if at least one other dependency succeeds.)`
|
|
1570
|
-
: '';
|
|
1571
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': '${surface}' condition "${leaf}" can never be true — under ` +
|
|
1572
|
-
`${ruleText} trigger rule, '${dep}' can never be in failed_steps when this step is ` +
|
|
1573
|
-
`evaluated (${consequence}). To run this step when '${dep}' fails, set trigger_rule to ` +
|
|
1574
|
-
`one of: ${remedies.join(', ')}.${tail}`));
|
|
1834
|
+
// Validate tools: server_id must reference a defined mcp_server.
|
|
1835
|
+
if (step['tools'] !== undefined &&
|
|
1836
|
+
Array.isArray(step['tools']) &&
|
|
1837
|
+
Array.isArray(doc['mcp_servers'])) {
|
|
1838
|
+
const serverIds = new Set(doc['mcp_servers'].map((s) => s.id));
|
|
1839
|
+
for (const entry of step['tools']) {
|
|
1840
|
+
const serverId = entry.split(':')[0] ?? '';
|
|
1841
|
+
if (!serverIds.has(serverId)) {
|
|
1842
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' references unknown MCP server '${serverId}'`));
|
|
1575
1843
|
}
|
|
1576
1844
|
}
|
|
1577
1845
|
}
|
|
1846
|
+
// Validate max_tool_calls: must be a positive integer.
|
|
1847
|
+
if (step['max_tool_calls'] !== undefined &&
|
|
1848
|
+
(!Number.isInteger(step['max_tool_calls']) || step['max_tool_calls'] <= 0)) {
|
|
1849
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'max_tool_calls' must be a positive integer`));
|
|
1850
|
+
}
|
|
1851
|
+
// Validate max_fan_out: must be a positive integer.
|
|
1852
|
+
if (step['max_fan_out'] !== undefined &&
|
|
1853
|
+
(!Number.isInteger(step['max_fan_out']) || step['max_fan_out'] <= 0)) {
|
|
1854
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'max_fan_out' must be a positive integer`));
|
|
1855
|
+
}
|
|
1856
|
+
// Validate tool_timeout: must be a positive integer. Skipped where the key is not valid at
|
|
1857
|
+
// all (issue #413's requires-tools check above already reported that) — the same convention
|
|
1858
|
+
// as `timeout_seconds` below: an author told BOTH that the key does not belong here and that
|
|
1859
|
+
// its value has the wrong shape is being pointed at the shape, which is not the problem.
|
|
1860
|
+
// The `!toolsMissing` complement is the SAME helper the prohibition keys on, so the two are
|
|
1861
|
+
// exhaustive and disjoint by construction: `tools: []` with a negative value reports once.
|
|
1862
|
+
if (step['tool_timeout'] !== undefined &&
|
|
1863
|
+
!toolsMissing &&
|
|
1864
|
+
(!Number.isInteger(step['tool_timeout']) || step['tool_timeout'] <= 0)) {
|
|
1865
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'tool_timeout' must be a positive integer`));
|
|
1866
|
+
}
|
|
1867
|
+
// Validate timeout_seconds: must be a positive integer (issue A3). Skipped on
|
|
1868
|
+
// execution: guard and agent — the #517 registry mint already flatly rejects
|
|
1869
|
+
// 'timeout_seconds' on both kinds; re-checking its shape here would double-report the
|
|
1870
|
+
// same root cause under a second, confusing message (an author told BOTH that the key is
|
|
1871
|
+
// invalid here and that its value has the wrong shape is being pointed at the shape,
|
|
1872
|
+
// which is not the problem).
|
|
1873
|
+
if (step['timeout_seconds'] !== undefined &&
|
|
1874
|
+
step['execution'] !== 'guard' &&
|
|
1875
|
+
step['execution'] !== 'agent' &&
|
|
1876
|
+
(!Number.isInteger(step['timeout_seconds']) || step['timeout_seconds'] <= 0)) {
|
|
1877
|
+
errors.push(withStepLine(stepName, `Step '${stepName}': 'timeout_seconds' must be a positive integer`));
|
|
1878
|
+
}
|
|
1578
1879
|
}
|
|
1579
|
-
//
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
//
|
|
1587
|
-
//
|
|
1588
|
-
//
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
// Validate tools: entries must be in server_id:tool_name format.
|
|
1610
|
-
if (step['tools'] !== undefined && Array.isArray(step['tools'])) {
|
|
1611
|
-
for (const entry of step['tools']) {
|
|
1612
|
-
if (!/^[^:]+:[^:]+$/.test(entry)) {
|
|
1613
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' must be in 'server_id:tool_name' format`));
|
|
1880
|
+
// Require at least one non-finalizer step: a workflow of only finalizers is meaningless
|
|
1881
|
+
// (nothing runs in the DAG, so it would seal immediately with no domain work).
|
|
1882
|
+
const stepEntries = Object.values(stepsRaw).filter((s) => typeof s === 'object' && s !== null && !Array.isArray(s));
|
|
1883
|
+
if (stepEntries.length > 0 && stepEntries.every((s) => s['execution'] === 'finalizer')) {
|
|
1884
|
+
errors.push(`Workflow has only finalizer steps — at least one non-finalizer step is required ` +
|
|
1885
|
+
`(finalizers run at the terminal transition of the DAG's domain steps).`);
|
|
1886
|
+
}
|
|
1887
|
+
// Reject depends_on cycles (issue #153): a transitive cycle among otherwise-valid edges is
|
|
1888
|
+
// loadable today (the per-step check above only validates one hop at a time), and at runtime
|
|
1889
|
+
// the cyclic steps are mutually ineligible forever — the run silently seals `completed` with
|
|
1890
|
+
// the stranded steps in NO step set and zero evidence. Build the graph over VALID edges only
|
|
1891
|
+
// (dep exists, isn't self, isn't a finalizer) — the self/unknown/finalizer-dep cases are
|
|
1892
|
+
// already reported by the per-step check above; a real cycle runs entirely through valid
|
|
1893
|
+
// edges, so excluding the already-errored ones here avoids double-reporting them.
|
|
1894
|
+
const dependencyEdges = new Map();
|
|
1895
|
+
for (const stepName of Object.keys(stepsRaw)) {
|
|
1896
|
+
dependencyEdges.set(stepName, []);
|
|
1897
|
+
}
|
|
1898
|
+
for (const [stepName, stepRaw] of Object.entries(stepsRaw)) {
|
|
1899
|
+
if (typeof stepRaw !== 'object' || stepRaw === null || Array.isArray(stepRaw))
|
|
1900
|
+
continue;
|
|
1901
|
+
const dependsOn = stepRaw['depends_on'];
|
|
1902
|
+
if (!Array.isArray(dependsOn))
|
|
1903
|
+
continue;
|
|
1904
|
+
for (const dep of dependsOn) {
|
|
1905
|
+
if (typeof dep === 'string' &&
|
|
1906
|
+
dep !== stepName &&
|
|
1907
|
+
dep in stepsRaw &&
|
|
1908
|
+
stepsRaw[dep]['execution'] !== 'finalizer') {
|
|
1909
|
+
dependencyEdges.get(stepName).push(dep);
|
|
1614
1910
|
}
|
|
1615
1911
|
}
|
|
1616
1912
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
// fork, so the corner produced no error, no warning, and a run where the declared tools were
|
|
1620
|
-
// simply never offered. ONE error per step, not one per entry: the entries are not individually
|
|
1621
|
-
// wrong, the workflow is.
|
|
1622
|
-
if (step['tools'] !== undefined &&
|
|
1623
|
-
Array.isArray(step['tools']) &&
|
|
1624
|
-
step['tools'].length > 0 &&
|
|
1625
|
-
!Array.isArray(doc['mcp_servers'])) {
|
|
1626
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': declares tools but the workflow defines no mcp_servers — no drive ` +
|
|
1627
|
-
`can ever offer these tools, so the declaration can never be satisfied. Define an ` +
|
|
1628
|
-
`mcp_servers block, or remove 'tools'.`));
|
|
1913
|
+
for (const cycle of detectDependencyCycles(dependencyEdges)) {
|
|
1914
|
+
errors.push(`Workflow has a dependency cycle: ${cycle.join(' → ')}`);
|
|
1629
1915
|
}
|
|
1630
|
-
// Validate
|
|
1631
|
-
if (
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
const serverId = entry.split(':')[0] ?? '';
|
|
1637
|
-
if (!serverIds.has(serverId)) {
|
|
1638
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': tools entry '${entry}' references unknown MCP server '${serverId}'`));
|
|
1916
|
+
// Validate mcp_servers: ids must be unique (workflow-level check).
|
|
1917
|
+
if (Array.isArray(doc['mcp_servers'])) {
|
|
1918
|
+
const seen = new Set();
|
|
1919
|
+
for (const server of doc['mcp_servers']) {
|
|
1920
|
+
if (seen.has(server.id)) {
|
|
1921
|
+
errors.push(`mcp_servers: duplicate server id '${server.id}'`);
|
|
1639
1922
|
}
|
|
1923
|
+
seen.add(server.id);
|
|
1640
1924
|
}
|
|
1641
1925
|
}
|
|
1642
|
-
// Validate
|
|
1643
|
-
if (
|
|
1644
|
-
(
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
const dependsOn = stepRaw['depends_on'];
|
|
1699
|
-
if (!Array.isArray(dependsOn))
|
|
1700
|
-
continue;
|
|
1701
|
-
for (const dep of dependsOn) {
|
|
1702
|
-
if (typeof dep === 'string' &&
|
|
1703
|
-
dep !== stepName &&
|
|
1704
|
-
dep in stepsRaw &&
|
|
1705
|
-
stepsRaw[dep]['execution'] !== 'finalizer') {
|
|
1706
|
-
dependencyEdges.get(stepName).push(dep);
|
|
1926
|
+
// Validate services: Ajv-strict entry schema (closed key set) + rate_limit fields.
|
|
1927
|
+
if (typeof doc['services'] === 'object' && doc['services'] !== null) {
|
|
1928
|
+
for (const [serviceName, serviceRaw] of Object.entries(doc['services'])) {
|
|
1929
|
+
if (typeof serviceRaw !== 'object' || serviceRaw === null)
|
|
1930
|
+
continue;
|
|
1931
|
+
const service = serviceRaw;
|
|
1932
|
+
// PERMANENT targeted rejection — must win over the generic unknown-key error.
|
|
1933
|
+
if ('auth' in service || 'token_from' in service) {
|
|
1934
|
+
errors.push(`Service '${serviceName}': 'auth.token_from' was removed in v0.14.0 — bind ` +
|
|
1935
|
+
`credentials in your deployment manifest (realm.yaml); see the migration note.`);
|
|
1936
|
+
}
|
|
1937
|
+
else {
|
|
1938
|
+
const serviceAjv = new Ajv({ strict: true, allErrors: true });
|
|
1939
|
+
if (!serviceAjv.validate(SERVICE_ENTRY_JSON_SCHEMA, service)) {
|
|
1940
|
+
for (const err of serviceAjv.errors ?? []) {
|
|
1941
|
+
const detail = err.keyword === 'additionalProperties'
|
|
1942
|
+
? `unknown key '${String(err.params.additionalProperty)}'`
|
|
1943
|
+
: `${err.instancePath.replace(/^\//, '').replace(/\//g, '.') || 'entry'} ${err.message ?? 'invalid'}`;
|
|
1944
|
+
errors.push(`Service '${serviceName}': ${detail}`);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
const rateLimit = service['rate_limit'];
|
|
1949
|
+
if (rateLimit === undefined)
|
|
1950
|
+
continue;
|
|
1951
|
+
if (typeof rateLimit !== 'object' || rateLimit === null) {
|
|
1952
|
+
errors.push(`Service '${serviceName}': 'rate_limit' must be an object`);
|
|
1953
|
+
continue;
|
|
1954
|
+
}
|
|
1955
|
+
const rl = rateLimit;
|
|
1956
|
+
if ('requests_per_second' in rl &&
|
|
1957
|
+
(!Number.isInteger(rl['requests_per_second']) ||
|
|
1958
|
+
rl['requests_per_second'] < 1)) {
|
|
1959
|
+
errors.push(`Service '${serviceName}': 'rate_limit.requests_per_second' must be a positive integer (≥ 1)`);
|
|
1960
|
+
}
|
|
1961
|
+
if ('burst' in rl) {
|
|
1962
|
+
if (!Number.isInteger(rl['burst']) || rl['burst'] < 1) {
|
|
1963
|
+
errors.push(`Service '${serviceName}': 'rate_limit.burst' must be a positive integer (≥ 1)`);
|
|
1964
|
+
}
|
|
1965
|
+
if (!('requests_per_second' in rl)) {
|
|
1966
|
+
errors.push(`Service '${serviceName}': 'rate_limit.burst' requires 'rate_limit.requests_per_second' to be set`);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
if ('fallback_retry_seconds' in rl &&
|
|
1970
|
+
(typeof rl['fallback_retry_seconds'] !== 'number' ||
|
|
1971
|
+
rl['fallback_retry_seconds'] <= 0)) {
|
|
1972
|
+
errors.push(`Service '${serviceName}': 'rate_limit.fallback_retry_seconds' must be a positive number (> 0)`);
|
|
1973
|
+
}
|
|
1974
|
+
if ('min_retry_seconds' in rl &&
|
|
1975
|
+
(typeof rl['min_retry_seconds'] !== 'number' || rl['min_retry_seconds'] <= 0)) {
|
|
1976
|
+
errors.push(`Service '${serviceName}': 'rate_limit.min_retry_seconds' must be a positive number (> 0)`);
|
|
1977
|
+
}
|
|
1978
|
+
if ('max_retry_seconds' in rl &&
|
|
1979
|
+
(!Number.isInteger(rl['max_retry_seconds']) || rl['max_retry_seconds'] < 1)) {
|
|
1980
|
+
errors.push(`Service '${serviceName}': 'rate_limit.max_retry_seconds' must be a positive integer (≥ 1)`);
|
|
1981
|
+
}
|
|
1707
1982
|
}
|
|
1708
1983
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1984
|
+
// Step 3b: Trigger block validation (schema-driven — see trigger-schema.ts)
|
|
1985
|
+
const triggerRaw = doc['trigger'];
|
|
1986
|
+
if (triggerRaw !== undefined) {
|
|
1987
|
+
normalizeTriggerFilter(triggerRaw); // canonicalise shorthand BEFORE validation
|
|
1988
|
+
errors.push(...validateTriggerStructure(triggerRaw));
|
|
1989
|
+
}
|
|
1990
|
+
// Step 3c: workflow-level context blocks (issue #553). These four rules need nothing but the
|
|
1991
|
+
// text, so they belong to every surface — file, string, `validate --registered`, the public
|
|
1992
|
+
// `loadWorkflowFromString`. They lived in the file loader until #553 and were therefore
|
|
1993
|
+
// invisible to `validate` (which parsed extension-free workflows from string) and to
|
|
1994
|
+
// `--registered`; the public string loader silently accepted all four shapes. Pushed, never
|
|
1995
|
+
// thrown: the accumulator mints `Invalid workflow:` once and the #425 per-line grammar composes.
|
|
1996
|
+
const contextWrapperRaw = doc['context_wrapper'];
|
|
1997
|
+
if (contextWrapperRaw !== undefined) {
|
|
1998
|
+
const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
|
|
1999
|
+
if (!VALID_WRAPPER_FORMATS.has(contextWrapperRaw)) {
|
|
2000
|
+
errors.push(withTopLevelLine(['context_wrapper'], `'context_wrapper' must be 'xml', 'brackets', or 'none' (found: '${String(contextWrapperRaw)}')`));
|
|
1719
2001
|
}
|
|
1720
|
-
seen.add(server.id);
|
|
1721
2002
|
}
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
continue;
|
|
1728
|
-
const service = serviceRaw;
|
|
1729
|
-
// PERMANENT targeted rejection — must win over the generic unknown-key error.
|
|
1730
|
-
if ('auth' in service || 'token_from' in service) {
|
|
1731
|
-
errors.push(`Service '${serviceName}': 'auth.token_from' was removed in v0.14.0 — bind ` +
|
|
1732
|
-
`credentials in your deployment manifest (realm.yaml); see the migration note.`);
|
|
1733
|
-
}
|
|
1734
|
-
else {
|
|
1735
|
-
const serviceAjv = new Ajv({ strict: true, allErrors: true });
|
|
1736
|
-
if (!serviceAjv.validate(SERVICE_ENTRY_JSON_SCHEMA, service)) {
|
|
1737
|
-
for (const err of serviceAjv.errors ?? []) {
|
|
1738
|
-
const detail = err.keyword === 'additionalProperties'
|
|
1739
|
-
? `unknown key '${String(err.params.additionalProperty)}'`
|
|
1740
|
-
: `${err.instancePath.replace(/^\//, '').replace(/\//g, '.') || 'entry'} ${err.message ?? 'invalid'}`;
|
|
1741
|
-
errors.push(`Service '${serviceName}': ${detail}`);
|
|
1742
|
-
}
|
|
2003
|
+
const workflowContextRaw = doc['workflow_context'];
|
|
2004
|
+
if (workflowContextRaw !== undefined) {
|
|
2005
|
+
for (const [name, entry] of Object.entries(workflowContextRaw)) {
|
|
2006
|
+
if (name.endsWith('.raw')) {
|
|
2007
|
+
errors.push(withTopLevelLine(['workflow_context', name], `workflow_context entry '${name}' must not end with '.raw'`));
|
|
1743
2008
|
}
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
if (rateLimit === undefined)
|
|
1747
|
-
continue;
|
|
1748
|
-
if (typeof rateLimit !== 'object' || rateLimit === null) {
|
|
1749
|
-
errors.push(`Service '${serviceName}': 'rate_limit' must be an object`);
|
|
1750
|
-
continue;
|
|
1751
|
-
}
|
|
1752
|
-
const rl = rateLimit;
|
|
1753
|
-
if ('requests_per_second' in rl &&
|
|
1754
|
-
(!Number.isInteger(rl['requests_per_second']) || rl['requests_per_second'] < 1)) {
|
|
1755
|
-
errors.push(`Service '${serviceName}': 'rate_limit.requests_per_second' must be a positive integer (≥ 1)`);
|
|
1756
|
-
}
|
|
1757
|
-
if ('burst' in rl) {
|
|
1758
|
-
if (!Number.isInteger(rl['burst']) || rl['burst'] < 1) {
|
|
1759
|
-
errors.push(`Service '${serviceName}': 'rate_limit.burst' must be a positive integer (≥ 1)`);
|
|
2009
|
+
if (!/^[\w.]+$/.test(name)) {
|
|
2010
|
+
errors.push(withTopLevelLine(['workflow_context', name], `workflow_context entry '${name}' must match [\\w.]+ (underscores and dots only — no hyphens)`));
|
|
1760
2011
|
}
|
|
1761
|
-
|
|
1762
|
-
|
|
2012
|
+
const rawEntry = entry;
|
|
2013
|
+
const rawSource = rawEntry?.['source'];
|
|
2014
|
+
if (rawSource === undefined || typeof rawSource['path'] !== 'string') {
|
|
2015
|
+
// The ENTRY's line: the missing key has no line, and a cite must never name an absent
|
|
2016
|
+
// key.
|
|
2017
|
+
errors.push(withTopLevelLine(['workflow_context', name], `workflow_context.${name}.source.path is required`));
|
|
1763
2018
|
}
|
|
1764
2019
|
}
|
|
1765
|
-
if ('fallback_retry_seconds' in rl &&
|
|
1766
|
-
(typeof rl['fallback_retry_seconds'] !== 'number' ||
|
|
1767
|
-
rl['fallback_retry_seconds'] <= 0)) {
|
|
1768
|
-
errors.push(`Service '${serviceName}': 'rate_limit.fallback_retry_seconds' must be a positive number (> 0)`);
|
|
1769
|
-
}
|
|
1770
|
-
if ('min_retry_seconds' in rl &&
|
|
1771
|
-
(typeof rl['min_retry_seconds'] !== 'number' || rl['min_retry_seconds'] <= 0)) {
|
|
1772
|
-
errors.push(`Service '${serviceName}': 'rate_limit.min_retry_seconds' must be a positive number (> 0)`);
|
|
1773
|
-
}
|
|
1774
|
-
if ('max_retry_seconds' in rl &&
|
|
1775
|
-
(!Number.isInteger(rl['max_retry_seconds']) || rl['max_retry_seconds'] < 1)) {
|
|
1776
|
-
errors.push(`Service '${serviceName}': 'rate_limit.max_retry_seconds' must be a positive integer (≥ 1)`);
|
|
1777
|
-
}
|
|
1778
2020
|
}
|
|
2021
|
+
if (errors.length > 0) {
|
|
2022
|
+
throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
|
|
2023
|
+
// issue #425: the pre-join strings — see the profile collector above.
|
|
2024
|
+
errors: [...errors],
|
|
2025
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
2026
|
+
category: 'VALIDATION',
|
|
2027
|
+
agentAction: 'report_to_user',
|
|
2028
|
+
retryable: false,
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
// Step 4: Stamp schema version and return typed result
|
|
2032
|
+
const definition = doc;
|
|
2033
|
+
definition.schema_version = CURRENT_WORKFLOW_SCHEMA_VERSION;
|
|
2034
|
+
return { definition, warnings };
|
|
1779
2035
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
errors.push(...validateTriggerStructure(triggerRaw));
|
|
1785
|
-
}
|
|
1786
|
-
if (errors.length > 0) {
|
|
1787
|
-
throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
|
|
1788
|
-
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
1789
|
-
category: 'VALIDATION',
|
|
1790
|
-
agentAction: 'report_to_user',
|
|
1791
|
-
retryable: false,
|
|
1792
|
-
});
|
|
2036
|
+
catch (err) {
|
|
2037
|
+
if (err instanceof WorkflowError)
|
|
2038
|
+
attachLoaderWarnings(err, warnings);
|
|
2039
|
+
throw err;
|
|
1793
2040
|
}
|
|
1794
|
-
// Step 4: Stamp schema version and return typed result
|
|
1795
|
-
const definition = doc;
|
|
1796
|
-
definition.schema_version = CURRENT_WORKFLOW_SCHEMA_VERSION;
|
|
1797
|
-
return { definition, warnings };
|
|
1798
2041
|
}
|
|
1799
2042
|
/** Returns true if any step in the raw steps map declares use_template. */
|
|
1800
2043
|
function hasUseTemplateInSteps(steps) {
|