@sensigo/realm 0.41.0 → 0.43.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 +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- 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 +1 -1
- package/dist/types/workflow-error.d.ts.map +1 -1
- package/dist/types/workflow-error.js.map +1 -1
- package/dist/workflow/diagnostics.d.ts +41 -5
- package/dist/workflow/diagnostics.d.ts.map +1 -1
- package/dist/workflow/diagnostics.js +34 -6
- package/dist/workflow/diagnostics.js.map +1 -1
- package/dist/workflow/registrar.d.ts +42 -0
- package/dist/workflow/registrar.d.ts.map +1 -1
- package/dist/workflow/registrar.js +57 -0
- 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 +16 -0
- package/dist/workflow/yaml-loader.d.ts.map +1 -1
- package/dist/workflow/yaml-loader.js +581 -345
- package/dist/workflow/yaml-loader.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,9 +4,9 @@ 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
|
-
import { findUnknownKeys, renderLoaderWarning, resolveSeverity, closestKey, } from './diagnostics.js';
|
|
9
|
+
import { findUnknownKeys, renderLoaderWarning, resolveSeverity, closestKey, isExtensionKey, EXTENSION_KEY_PREFIX, RESERVED_EXTENSION_PREFIX, } from './diagnostics.js';
|
|
10
10
|
import { createSourcePositionCollector } from './source-positions.js';
|
|
11
11
|
import { resolveTemplates } from './template-resolver.js';
|
|
12
12
|
import { normalizeTriggerFilter, validateTriggerStructure } from './trigger-schema.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',
|
|
@@ -269,6 +316,58 @@ export function attachLoaderWarnings(err, warnings) {
|
|
|
269
316
|
return;
|
|
270
317
|
err.warnings = warnings;
|
|
271
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) {
|
|
333
|
+
const profilesDir = definition.profiles_dir !== undefined
|
|
334
|
+
? resolve(workflowDir, definition.profiles_dir)
|
|
335
|
+
: join(workflowDir, 'profiles');
|
|
336
|
+
const resolvedProfiles = {};
|
|
337
|
+
const profileErrors = [];
|
|
338
|
+
for (const [stepName, step] of Object.entries(definition.steps)) {
|
|
339
|
+
if (step.agent_profile === undefined)
|
|
340
|
+
continue;
|
|
341
|
+
const profileName = step.agent_profile;
|
|
342
|
+
if (profileName in resolvedProfiles)
|
|
343
|
+
continue;
|
|
344
|
+
const profilePath = join(profilesDir, `${profileName}.md`);
|
|
345
|
+
let profileContent;
|
|
346
|
+
try {
|
|
347
|
+
profileContent = readFileSync(profilePath, 'utf8');
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
profileErrors.push(`Step '${stepName}': agent_profile '${profileName}' not found. Searched: ${profilePath}`);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const contentHash = createHash('sha256').update(profileContent).digest('hex');
|
|
354
|
+
resolvedProfiles[profileName] = { content: profileContent, content_hash: contentHash };
|
|
355
|
+
}
|
|
356
|
+
if (profileErrors.length > 0) {
|
|
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],
|
|
361
|
+
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
362
|
+
category: 'VALIDATION',
|
|
363
|
+
agentAction: 'report_to_user',
|
|
364
|
+
retryable: false,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (Object.keys(resolvedProfiles).length > 0) {
|
|
368
|
+
definition.resolved_profiles = resolvedProfiles;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
272
371
|
/**
|
|
273
372
|
* Pure core of loadWorkflowFromFile (issue #169): parses + resolves everything a file-based load
|
|
274
373
|
* needs, but never prints and never chooses between the two public presentations — it always
|
|
@@ -307,88 +406,20 @@ function loadWorkflowFromFileCore(filePath, registry) {
|
|
|
307
406
|
// `parseWorkflowString` owns those throws and has already attached, and attach-once means its
|
|
308
407
|
// richer set survives.
|
|
309
408
|
try {
|
|
310
|
-
// Resolve agent profiles — only possible when we have a
|
|
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.
|
|
311
414
|
const workflowDir = dirname(resolve(filePath));
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
for (const [stepName, step] of Object.entries(definition.steps)) {
|
|
318
|
-
if (step.agent_profile === undefined)
|
|
319
|
-
continue;
|
|
320
|
-
const profileName = step.agent_profile;
|
|
321
|
-
if (profileName in resolvedProfiles)
|
|
322
|
-
continue;
|
|
323
|
-
const profilePath = join(profilesDir, `${profileName}.md`);
|
|
324
|
-
let profileContent;
|
|
325
|
-
try {
|
|
326
|
-
profileContent = readFileSync(profilePath, 'utf8');
|
|
327
|
-
}
|
|
328
|
-
catch {
|
|
329
|
-
profileErrors.push(`Step '${stepName}': agent_profile '${profileName}' not found. Searched: ${profilePath}`);
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
const contentHash = createHash('sha256').update(profileContent).digest('hex');
|
|
333
|
-
resolvedProfiles[profileName] = { content: profileContent, content_hash: contentHash };
|
|
334
|
-
}
|
|
335
|
-
if (profileErrors.length > 0) {
|
|
336
|
-
throw new WorkflowError(`Invalid workflow: ${profileErrors.join('; ')}`, {
|
|
337
|
-
// issue #425: the pre-join strings, so a render can list them one per line. Two missing
|
|
338
|
-
// profiles are two problems, not one long sentence.
|
|
339
|
-
errors: [...profileErrors],
|
|
340
|
-
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
341
|
-
category: 'VALIDATION',
|
|
342
|
-
agentAction: 'report_to_user',
|
|
343
|
-
retryable: false,
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
if (Object.keys(resolvedProfiles).length > 0) {
|
|
347
|
-
definition.resolved_profiles = resolvedProfiles;
|
|
348
|
-
}
|
|
349
|
-
// Validate context_wrapper if present.
|
|
350
|
-
if (definition.context_wrapper !== undefined) {
|
|
351
|
-
const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
|
|
352
|
-
if (!VALID_WRAPPER_FORMATS.has(definition.context_wrapper)) {
|
|
353
|
-
throw new WorkflowError(`Invalid context_wrapper '${String(definition.context_wrapper)}'; must be 'xml', 'brackets', or 'none'`, {
|
|
354
|
-
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
355
|
-
category: 'VALIDATION',
|
|
356
|
-
agentAction: 'report_to_user',
|
|
357
|
-
retryable: false,
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
// Validate and resolve workflow_context entry paths.
|
|
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.
|
|
362
420
|
if (definition.workflow_context !== undefined) {
|
|
363
|
-
for (const
|
|
364
|
-
|
|
365
|
-
throw new WorkflowError(`workflow_context entry names must not end with '.raw' (found: '${name}')`, {
|
|
366
|
-
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
367
|
-
category: 'VALIDATION',
|
|
368
|
-
agentAction: 'report_to_user',
|
|
369
|
-
retryable: false,
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
if (!/^[\w.]+$/.test(name)) {
|
|
373
|
-
throw new WorkflowError(`workflow_context entry name '${name}' is invalid; names must match [\\w.]+ (underscores and dots only — no hyphens)`, {
|
|
374
|
-
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
375
|
-
category: 'VALIDATION',
|
|
376
|
-
agentAction: 'report_to_user',
|
|
377
|
-
retryable: false,
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
const rawEntry = entry;
|
|
381
|
-
const rawSource = rawEntry['source'];
|
|
382
|
-
if (rawSource === undefined || typeof rawSource['path'] !== 'string') {
|
|
383
|
-
throw new WorkflowError(`workflow_context.${name}.source.path is required`, {
|
|
384
|
-
code: 'VALIDATION_WORKFLOW_SCHEMA',
|
|
385
|
-
category: 'VALIDATION',
|
|
386
|
-
agentAction: 'report_to_user',
|
|
387
|
-
retryable: false,
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
// Resolve relative path to absolute.
|
|
391
|
-
entry.source.path = resolve(workflowDir, rawSource['path']);
|
|
421
|
+
for (const entry of Object.values(definition.workflow_context)) {
|
|
422
|
+
entry.source.path = resolve(workflowDir, entry.source.path);
|
|
392
423
|
}
|
|
393
424
|
}
|
|
394
425
|
// Auto-register schema.json if present and not explicitly declared.
|
|
@@ -404,6 +435,8 @@ function loadWorkflowFromFileCore(filePath, registry) {
|
|
|
404
435
|
// the deployment-manifest anchor (`<trust_root>/realm.yaml`), needed by extension-free
|
|
405
436
|
// workflows that consume manifest-constructed adapters by name. Core resolves/stores
|
|
406
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).
|
|
407
440
|
definition.source_dir = workflowDir;
|
|
408
441
|
definition.trust_root = findTrustRoot(workflowDir);
|
|
409
442
|
if (definition.extensions !== undefined) {
|
|
@@ -623,6 +656,16 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
623
656
|
return `${message} (step at line ${stepLine})`;
|
|
624
657
|
return message;
|
|
625
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
|
+
};
|
|
626
669
|
// Step 2: Top-level validation
|
|
627
670
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
628
671
|
throw new WorkflowError('Invalid workflow: Workflow must be a non-null object', {
|
|
@@ -633,20 +676,72 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
633
676
|
});
|
|
634
677
|
}
|
|
635
678
|
const doc = raw;
|
|
636
|
-
//
|
|
637
|
-
//
|
|
679
|
+
// Mints a warning for a key that isn't authorable — checked against KNOWN_WORKFLOW_KEYS ONLY
|
|
680
|
+
// (not RUNTIME_ONLY_WORKFLOW_KEYS), and BEFORE any loader-stamped field is added below.
|
|
638
681
|
// Deliberately excluding runtime-only keys from "known" here means hand-authoring one (e.g.
|
|
639
682
|
// `schema_version:` or `model:` in YAML) warns too — those fields are stamped by the loader
|
|
640
683
|
// and any authored value is silently overwritten/ignored, which is exactly the kind of mistake
|
|
641
|
-
// this check exists to surface (issue #144).
|
|
642
|
-
//
|
|
684
|
+
// this check exists to surface (issue #144). Under the #170 flip UNKNOWN_WORKFLOW_KEY is
|
|
685
|
+
// 'error', so the authoring boundary (validate/register/watch) REFUSES the file over it while
|
|
686
|
+
// execution surfaces stay lenient.
|
|
687
|
+
//
|
|
688
|
+
// ONE exception (issue #559): a key in the author's extension namespace (`isExtensionKey`)
|
|
689
|
+
// mints NOTHING — it is the author's own field (a YAML anchor host, a tooling note), carried
|
|
690
|
+
// verbatim into the definition (the cast below adds no field pick) and never read by realm.
|
|
691
|
+
// The reserved sub-namespace does NOT get that pass: a `x-realm-` key falls through to the
|
|
692
|
+
// ordinary mint below and is retargeted (the `.map` after) to a message naming the reservation.
|
|
643
693
|
{
|
|
644
694
|
const workflowId = typeof doc['id'] === 'string' ? doc['id'] : '<unknown>';
|
|
645
695
|
warnings.push(...findUnknownKeys(doc, KNOWN_WORKFLOW_KEYS, {
|
|
646
696
|
scope: 'workflow',
|
|
647
697
|
code: 'UNKNOWN_WORKFLOW_KEY',
|
|
648
698
|
id: workflowId,
|
|
699
|
+
isExtension: isExtensionKey,
|
|
649
700
|
positionOf: (key) => sourceMap.posOf([key]),
|
|
701
|
+
}).map((w) => {
|
|
702
|
+
// TWO targeted messages (issue #559): same code, same policy — sentences built by
|
|
703
|
+
// interpolating the exported consts, never a quoted literal, because the D5 never-read
|
|
704
|
+
// witness scans this very file for that exact quoting shape.
|
|
705
|
+
//
|
|
706
|
+
// Both arms are POSITIVE `if`s that return, never a guard-and-early-return — that shape
|
|
707
|
+
// is load-bearing: a guard followed by an unconditional `return` makes any arm placed
|
|
708
|
+
// after it unreachable (and fails to compile — TS narrows `w.key` to `undefined` past
|
|
709
|
+
// the point the guard already excluded it). Ending in a bare `return w;` is what lets a
|
|
710
|
+
// future third arm append cleanly.
|
|
711
|
+
//
|
|
712
|
+
// ORDER matters: the reserved-prefix check runs FIRST, matched case-insensitively (a
|
|
713
|
+
// key that lowercases into `x-realm-…` is realm's reserved sub-namespace regardless of
|
|
714
|
+
// how the author capitalized it — the alternative, checking case-only first, would
|
|
715
|
+
// catch `X-Realm-Foo` under "you spelled the namespace right, just wrong case" and hand
|
|
716
|
+
// it a remedy that runs the author straight into the RESERVED refusal on the very next
|
|
717
|
+
// try). Because the reserved check already consumes every case-variant of its own
|
|
718
|
+
// prefix, the case-only arm below only ever sees a key that is NOT in the reserved
|
|
719
|
+
// sub-namespace under any capitalization.
|
|
720
|
+
if (w.key === undefined)
|
|
721
|
+
return w;
|
|
722
|
+
const at = w.line === undefined ? '' : ` (line ${w.line})`;
|
|
723
|
+
if (w.key.toLowerCase().startsWith(RESERVED_EXTENSION_PREFIX)) {
|
|
724
|
+
return {
|
|
725
|
+
...w,
|
|
726
|
+
message: `workflow '${workflowId}': unknown key '${w.key}'${at} — the ` +
|
|
727
|
+
`'${RESERVED_EXTENSION_PREFIX}' prefix is reserved for realm's own future ` +
|
|
728
|
+
`extension keys; any other '${EXTENSION_KEY_PREFIX}' name is yours to use (an ` +
|
|
729
|
+
`extension key is carried verbatim and never read).`,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
// A CASE-ONLY miss (e.g. `X-Foo`): the likeliest authoring slip for a prefix
|
|
733
|
+
// convention — the capital-`X-` habit comes from HTTP headers. Never reached by an
|
|
734
|
+
// actual lowercase `x-` key (those were already skipped by `isExtension` before the
|
|
735
|
+
// mint), so this arm fires only for a key that MEANT the namespace and missed the case.
|
|
736
|
+
if (w.key.toLowerCase().startsWith(EXTENSION_KEY_PREFIX)) {
|
|
737
|
+
return {
|
|
738
|
+
...w,
|
|
739
|
+
message: `workflow '${workflowId}': unknown key '${w.key}'${at} — the extension ` +
|
|
740
|
+
`namespace is lowercase: a key starting '${EXTENSION_KEY_PREFIX}' is the ` +
|
|
741
|
+
`author's, carried verbatim and never read; '${w.key}' is not one.`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
return w;
|
|
650
745
|
}));
|
|
651
746
|
}
|
|
652
747
|
// Project extensions: hard error for string-based loading (fires before any other
|
|
@@ -712,17 +807,52 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
712
807
|
// at all); the legacy when-only depends_on/run.params check (validateWhenReference) is
|
|
713
808
|
// UNCHANGED — it still fires ONLY for `surface === 'when'`. This is a LIFT, not a new
|
|
714
809
|
// computation — byte-identical to the previous block-local `dependsOn` for `when`'s own use.
|
|
810
|
+
// The step's kind, once, for every kind-forked check below (undefined = malformed or
|
|
811
|
+
// missing execution — already refused by the invalid-execution/required error).
|
|
812
|
+
const stepKind = VALID_EXECUTIONS.has(step['execution'])
|
|
813
|
+
? step['execution']
|
|
814
|
+
: undefined;
|
|
715
815
|
const dependsOn = Array.isArray(step['depends_on'])
|
|
716
816
|
? step['depends_on'].filter((d) => typeof d === 'string')
|
|
717
817
|
: [];
|
|
718
818
|
// WARN (do not reject) on an unknown step key — runs after template resolution above, so a
|
|
719
819
|
// template-expanded step's keys are checked too. Same non-breaking posture as the
|
|
720
820
|
// workflow-level check (issue #144).
|
|
821
|
+
//
|
|
822
|
+
// NO extension namespace at step level (issue #559): step keys are the #417 consumption
|
|
823
|
+
// registry, a closed set where an inert key is a load error by ratified policy. So NO
|
|
824
|
+
// `isExtension` ctx is passed here, and EVERY key starting with the raw `x-` prefix —
|
|
825
|
+
// `x-realm-…` included, since the reservation is a top-level-only concept — still mints
|
|
826
|
+
// UNKNOWN_STEP_KEY, retargeted below to a message pointing the author at the top level.
|
|
721
827
|
warnings.push(...findUnknownKeys(step, KNOWN_STEP_KEYS, {
|
|
722
828
|
scope: 'step',
|
|
723
829
|
code: 'UNKNOWN_STEP_KEY',
|
|
724
830
|
step: stepName,
|
|
725
831
|
positionOf: (key) => sourceMap.posOf(['steps', stepName, key]),
|
|
832
|
+
}).map((w) => {
|
|
833
|
+
// TARGETED message for an `x-` step key (issue #559): the namespace is top-level only,
|
|
834
|
+
// so the remedy is "move it", not "is this a typo" — did_you_mean is dropped because
|
|
835
|
+
// the targeted message is complete (a suggestion across the boundary would be noise).
|
|
836
|
+
//
|
|
837
|
+
// The tail forks on whether the key falls in the RESERVED sub-namespace (issue #582):
|
|
838
|
+
// following the plain "move it to the top" remedy for a reserved name (e.g.
|
|
839
|
+
// `x-realm-foo`, `x-Realm-foo`) lands the author on the RESERVATION refusal one level
|
|
840
|
+
// up — a two-round-trip. The reserved check is case-insensitive here for the same
|
|
841
|
+
// reason `isExtensionKey` is: the reservation holds in every capitalization.
|
|
842
|
+
if (w.key === undefined || !w.key.startsWith(EXTENSION_KEY_PREFIX))
|
|
843
|
+
return w;
|
|
844
|
+
const at = w.line === undefined ? '' : ` (line ${w.line})`;
|
|
845
|
+
const { did_you_mean: _dropped, ...rest } = w;
|
|
846
|
+
const reserved = w.key.toLowerCase().startsWith(RESERVED_EXTENSION_PREFIX);
|
|
847
|
+
return {
|
|
848
|
+
...rest,
|
|
849
|
+
message: `step '${stepName}': unknown key '${w.key}'${at} — '${EXTENSION_KEY_PREFIX}' ` +
|
|
850
|
+
`extension keys are accepted only at the top level of a workflow file; step keys ` +
|
|
851
|
+
`are a closed set. Move it to the top of the file` +
|
|
852
|
+
(reserved
|
|
853
|
+
? ` under a name outside the reserved '${RESERVED_EXTENSION_PREFIX}' prefix.`
|
|
854
|
+
: `.`),
|
|
855
|
+
};
|
|
726
856
|
}));
|
|
727
857
|
// issue #220 §4c PR ordering interlock: `$settlement` is reserved NOW (PR-1) even though the
|
|
728
858
|
// namespace it names is not minted until a later PR — else the inter-PR gap could register a
|
|
@@ -746,31 +876,77 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
746
876
|
if ('execution' in step && !VALID_EXECUTIONS.has(step['execution'])) {
|
|
747
877
|
errors.push(withStepLine(stepName, `Step '${stepName}': invalid execution value '${String(step['execution'])}'; must be 'auto', 'agent', 'guard', or 'finalizer'`));
|
|
748
878
|
}
|
|
879
|
+
// issue #517 (the drive-flip): ONE registry-driven walk mints every kind-prohibition —
|
|
880
|
+
// for each declared key × the step's kind, a `prohibited` cell WITHOUT an except arm
|
|
881
|
+
// mints exactly one refusal, message text per-cell data (renderRegistryProhibition).
|
|
882
|
+
// Sits at the sequence position of the EARLIEST check it replaced (the old finalizer
|
|
883
|
+
// prohibited-field loop), so minted refusals still precede the structural finalizer/guard
|
|
884
|
+
// requirements below. Multi-fire is dead by construction (one lookup, one refusal per
|
|
885
|
+
// key×kind), and a multi-bad-key step now errors in YAML declaration order.
|
|
886
|
+
//
|
|
887
|
+
// Except-bearing cells are SKIPPED — their value-conditional checks stay hand-written
|
|
888
|
+
// (today exactly trust×finalizer, below). Companion/value/sub-key rules are not minted at
|
|
889
|
+
// all (the clang line): toolsMissing, the tools agent+handler clause, the gate block,
|
|
890
|
+
// retry E1-E3, structured_output literal+eligibility, trace_schema compile, pos-int
|
|
891
|
+
// checks all stay hand-written further down.
|
|
892
|
+
//
|
|
893
|
+
// The kind gate is deliberate: on a step whose `execution` is missing or not one of the
|
|
894
|
+
// four kinds, the registry has no row to consult, so NO per-key kind refusal is minted —
|
|
895
|
+
// the invalid-execution/missing-required error above is the whole verdict. (Pre-#517 the
|
|
896
|
+
// per-key `!== '<kind>'` twins ALSO fired on malformed kinds; that was per-key advice
|
|
897
|
+
// keyed to a kind nobody declared. The workflow is refused either way — the refusal
|
|
898
|
+
// POPULATION is unchanged; disclosed in the changelog.)
|
|
899
|
+
if (VALID_EXECUTIONS.has(step['execution'])) {
|
|
900
|
+
const kind = step['execution'];
|
|
901
|
+
for (const key of Object.keys(step)) {
|
|
902
|
+
// Unknown keys are UNKNOWN_STEP_KEY's business (warned above), never a registry row.
|
|
903
|
+
const row = STEP_KEY_REGISTRY[key];
|
|
904
|
+
if (row === undefined || step[key] === undefined)
|
|
905
|
+
continue;
|
|
906
|
+
const cell = row[kind];
|
|
907
|
+
if (cell.c !== 'prohibited' || cell.except !== undefined)
|
|
908
|
+
continue;
|
|
909
|
+
errors.push(withKeyLine(stepName, key, `Step '${stepName}': ${renderRegistryProhibition(key, kind, cell)}`));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
749
912
|
// Finalizer step constraints (a workflow-level try/catch/finally). handler-only in v1.
|
|
750
913
|
if (step['execution'] === 'finalizer') {
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
'abort_unless',
|
|
755
|
-
'abort_message',
|
|
756
|
-
'output_schema',
|
|
757
|
-
'agent_profile',
|
|
758
|
-
'tools',
|
|
759
|
-
'uses_service',
|
|
760
|
-
'service_method',
|
|
761
|
-
'operation',
|
|
762
|
-
'input_map',
|
|
763
|
-
'when',
|
|
764
|
-
'retry',
|
|
765
|
-
];
|
|
766
|
-
for (const field of prohibited) {
|
|
767
|
-
if (step[field] !== undefined) {
|
|
768
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: finalizer steps`));
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
// A finalizer must not gate — reject any human-gate trust level.
|
|
914
|
+
// A finalizer must not gate — reject any human-gate trust level. Value-conditional
|
|
915
|
+
// (`trust: 'auto'` is lawful), which is why this is the registry's except-bearing cell
|
|
916
|
+
// and stays hand-written rather than minted (#517).
|
|
772
917
|
if (step['trust'] !== undefined && step['trust'] !== 'auto') {
|
|
773
|
-
|
|
918
|
+
// issue #508: the reason forks on whether the declared value is actually a GATE
|
|
919
|
+
// literal — "a finalizer must not gate" is only true THEN. Any other value (an
|
|
920
|
+
// unrecognized trust, a service-trust literal, the retired human_notified) was never
|
|
921
|
+
// an attempt to gate at all, so that reason would be false for it — the #523 class,
|
|
922
|
+
// caught before shipping rather than after. `isGateTrust` is the pure-value question
|
|
923
|
+
// (no kind involved, since this branch already knows the kind and has already
|
|
924
|
+
// excluded 'auto'); the leading `'trust:` is kept exactly as before so the registry
|
|
925
|
+
// conformance runner's `namesKey` (`error.includes("'trust")`) still matches this arm
|
|
926
|
+
// — `namesKey` needs the quote BEFORE `trust`, not around the value, so switching the
|
|
927
|
+
// value's own rendering below does not touch it.
|
|
928
|
+
//
|
|
929
|
+
// issue #508 (final correction): the gate-literal arm stays a hand-written KIND
|
|
930
|
+
// prohibition (the key is the offense, not the value — #517's own boundary), but now
|
|
931
|
+
// shares `renderTrustValue` with every other arm — a previous ruling to "keep
|
|
932
|
+
// `String()` here to satisfy `namesKey`" was wrong (verified above) and there was
|
|
933
|
+
// never a real reason for two renderers, even though `isGateTrust` only ever admits
|
|
934
|
+
// the two known-string gate literals here in practice. The non-gate branch (an
|
|
935
|
+
// unrecognized trust, a service-trust literal, the retired human_notified) routes
|
|
936
|
+
// through the SAME composer every other refusal surface uses — no second hand-built
|
|
937
|
+
// arm-selector, no second value renderer. The conformance fixture (`buildFixture`)
|
|
938
|
+
// exercises only the gate-literal branch (`human_confirmed`), so the composer's three
|
|
939
|
+
// sub-arms are unreached by it — verified by grepping the fixture builder for this key.
|
|
940
|
+
const rawFinalizerTrust = step['trust'];
|
|
941
|
+
const finalizerMessage = isGateTrust(rawFinalizerTrust)
|
|
942
|
+
? `Step '${stepName}': 'trust: ${renderTrustValue(rawFinalizerTrust)}' is not valid on execution: finalizer steps (a finalizer must not gate)`
|
|
943
|
+
: buildTrustRefusal({
|
|
944
|
+
kind: 'finalizer',
|
|
945
|
+
value: rawFinalizerTrust,
|
|
946
|
+
step: stepName,
|
|
947
|
+
surface: 'load',
|
|
948
|
+
});
|
|
949
|
+
errors.push(withStepLine(stepName, finalizerMessage));
|
|
774
950
|
}
|
|
775
951
|
// v1 is handler-only.
|
|
776
952
|
if (step['handler'] === undefined) {
|
|
@@ -793,141 +969,56 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
793
969
|
}
|
|
794
970
|
}
|
|
795
971
|
}
|
|
796
|
-
//
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
972
|
+
// issue #508 (L1) — trust VALUE validation, auto/agent only. Before this check, an
|
|
973
|
+
// unrecognized or kind-inert `trust` (a typo, a service-trust literal, the retired
|
|
974
|
+
// `human_notified`) loaded clean, warned nothing, and ran with NO gate — the step's own
|
|
975
|
+
// declared human-approval control was silently disabled. Presence-keyed (`'trust' in
|
|
976
|
+
// step`, the same convention `trigger_rule` and `retry.backoff` already use below) so
|
|
977
|
+
// `trust:`/`trust: ~` (a null value, which loads clean today) is caught too — a blank
|
|
978
|
+
// declaration of a safety control is itself a false statement, not a no-op. `trust:`
|
|
979
|
+
// absent entirely is lawful (nothing was declared) and never reaches this block.
|
|
980
|
+
//
|
|
981
|
+
// Guard's OWN trust prohibition is minted by the #517 walk above (every value refused,
|
|
982
|
+
// no except arm); finalizer's is the hand-written except-cell just above (only 'auto' is
|
|
983
|
+
// lawful there). This block is what closes the remaining two kinds — the ones where a
|
|
984
|
+
// RECOGNIZED gate literal is meaningful, so an unrecognized one needs a VALUE verdict,
|
|
985
|
+
// not a kind verdict.
|
|
986
|
+
//
|
|
987
|
+
// issue #508 (final correction) — this whole value-refusal composition, for every kind and
|
|
988
|
+
// every surface, is now `buildTrustRefusal` (types/workflow-definition.ts, beside
|
|
989
|
+
// `classifyStepTrust`). Three prior rounds each hand-composed this text independently on
|
|
990
|
+
// this surface, execution-loop.ts's dispatch refusal, run-health.ts's finding, and the
|
|
991
|
+
// protocol generator's briefing — and every defect those rounds found (arm divergence, a
|
|
992
|
+
// String()-rendered array printing as its own first element, a grammar seam) fell out of
|
|
993
|
+
// that duplication. No site chooses an arm or renders a value on its own again; see the
|
|
994
|
+
// composer's own doc for the arm/mood/rendering contract in full.
|
|
995
|
+
if ((stepKind === 'auto' || stepKind === 'agent') &&
|
|
996
|
+
'trust' in step &&
|
|
997
|
+
classifyStepTrust(stepKind, step['trust']) === 'refuse') {
|
|
998
|
+
errors.push(withKeyLine(stepName, 'trust', buildTrustRefusal({
|
|
999
|
+
kind: stepKind,
|
|
1000
|
+
value: step['trust'],
|
|
1001
|
+
step: stepName,
|
|
1002
|
+
surface: 'load',
|
|
1003
|
+
})));
|
|
805
1004
|
}
|
|
806
|
-
// Guard step constraints
|
|
1005
|
+
// Guard step constraints (the guard kind-prohibitions, including `preconditions` — issue
|
|
1006
|
+
// #369's own bespoke message, message_data-preserved — are minted by the #517 walk above).
|
|
807
1007
|
if (step['execution'] === 'guard') {
|
|
808
|
-
const prohibited = [
|
|
809
|
-
'uses_service',
|
|
810
|
-
'handler',
|
|
811
|
-
'input_schema',
|
|
812
|
-
'output_schema',
|
|
813
|
-
'trust',
|
|
814
|
-
'agent_profile',
|
|
815
|
-
'trigger_rule',
|
|
816
|
-
'timeout_seconds',
|
|
817
|
-
'service_method',
|
|
818
|
-
'operation',
|
|
819
|
-
'input_map',
|
|
820
|
-
'tools',
|
|
821
|
-
];
|
|
822
|
-
for (const field of prohibited) {
|
|
823
|
-
if (step[field] !== undefined) {
|
|
824
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': '${field}' is not valid on execution: guard steps`));
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
1008
|
if (step['abort_unless'] === undefined) {
|
|
828
1009
|
errors.push(withStepLine(stepName, `Step '${stepName}': execution: guard requires 'abort_unless'`));
|
|
829
1010
|
}
|
|
830
|
-
// issue #369: `preconditions` gets its OWN error rather than joining `prohibited` above,
|
|
831
|
-
// because the generic message ("'x' is not valid on execution: guard steps") would not say
|
|
832
|
-
// the thing that matters — this field was ACCEPTED and INERT before this check existed, so
|
|
833
|
-
// an author who wrote one has a workflow that looks guarded and never was. The generic
|
|
834
|
-
// list's own message style is issue #366's territory; the other twelve are left alone.
|
|
835
|
-
//
|
|
836
|
-
// The claim "never evaluates it there" rests on `checkPreconditions` having exactly one
|
|
837
|
-
// engine call site (execution-loop.ts:1380, inside `executeStep`), which `executeGuardStep`
|
|
838
|
-
// never reaches. A test pins that count so a second call site reds this message.
|
|
839
|
-
if (step['preconditions'] !== undefined) {
|
|
840
|
-
errors.push(withKeyLine(stepName, 'preconditions', `Step '${stepName}': 'preconditions' is not valid on execution: guard steps — the ` +
|
|
841
|
-
`engine never evaluates it there (a guard's execution evaluates only 'abort_unless'), ` +
|
|
842
|
-
`so the run would LOOK guarded while the declared check never ran. Move the condition ` +
|
|
843
|
-
`into 'abort_unless'. Whether guards gain a live condition surface is an open design ` +
|
|
844
|
-
`question (issue #366) — if admitted later, existing workflows are unaffected.`));
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
// abort_unless and abort_message are only valid on execution: guard steps.
|
|
848
|
-
if (step['abort_unless'] !== undefined && step['execution'] !== 'guard') {
|
|
849
|
-
errors.push(
|
|
850
|
-
// Consumer: execution-loop.ts:4828 — the condition list a guard evaluates before the
|
|
851
|
-
// run is allowed to continue.
|
|
852
|
-
withKeyLine(stepName, 'abort_unless', `Step '${stepName}': 'abort_unless' is only valid on execution: guard steps — it is ` +
|
|
853
|
-
'the condition list a guard evaluates before letting the run continue, and only ' +
|
|
854
|
-
'guard steps are evaluated that way, so here it would gate nothing. Put the check ' +
|
|
855
|
-
'on a guard step, or remove it.'));
|
|
856
|
-
}
|
|
857
|
-
if (step['abort_message'] !== undefined && step['execution'] !== 'guard') {
|
|
858
|
-
errors.push(
|
|
859
|
-
// Consumer: execution-loop.ts:4943 — the text reported when a guard aborts the run.
|
|
860
|
-
// The clause is about READERSHIP, not about who aborts: `handler_abort` and
|
|
861
|
-
// `gate_expiry_abort` are seal arms too (types/run-record.ts:603-617), so "only a guard
|
|
862
|
-
// aborts" would be false. What is true is that every reader of this key is a guard path.
|
|
863
|
-
withKeyLine(stepName, 'abort_message', `Step '${stepName}': 'abort_message' is only valid on execution: guard steps — it is ` +
|
|
864
|
-
'the text reported when a guard aborts the run, and nothing but a guard reads it, ' +
|
|
865
|
-
'so here it would never be read. Move it to the guard that performs the abort, or ' +
|
|
866
|
-
'remove it.'));
|
|
867
1011
|
}
|
|
868
|
-
//
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
'content is resolved into the model prompt, and only an agent step makes a model ' +
|
|
874
|
-
'request, so here it would reach no model. Move it to the agent step whose prompt ' +
|
|
875
|
-
'it should shape, or remove it.'));
|
|
876
|
-
}
|
|
877
|
-
// llm_timeout_seconds (issue #401) is only valid on agent steps — no other execution kind
|
|
878
|
-
// makes a model request, so the key would be silently inert anywhere else. One `!== 'agent'`
|
|
879
|
-
// check covers auto/guard/finalizer.
|
|
880
|
-
if (step['llm_timeout_seconds'] !== undefined && step['execution'] !== 'agent') {
|
|
881
|
-
errors.push(
|
|
882
|
-
// Consumer: run-agent.ts:501-507 — the per-step clock resolution, which is the
|
|
883
|
-
// per-attempt bound on the step's model request. The range names the resolution rather
|
|
884
|
-
// than each read: :501 and :507 read the KEY, :503 reads the CLI flag it overrides.
|
|
885
|
-
withKeyLine(stepName, 'llm_timeout_seconds', `Step '${stepName}': 'llm_timeout_seconds' is only valid on execution: agent steps — ` +
|
|
886
|
-
'it bounds one model request, and no other kind makes one, so here it would bound ' +
|
|
887
|
-
'nothing. Move it to the agent step whose request it should bound, or remove it. ' +
|
|
888
|
-
"An auto step's dispatch is bounded by 'timeout_seconds', and a " +
|
|
889
|
-
"finalizer's handler by its own 'timeout_seconds'."));
|
|
890
|
-
}
|
|
891
|
-
// ...and when present it must be a positive integer (the same convention as
|
|
892
|
-
// retry.total_timeout_seconds and gate.timeout_seconds).
|
|
1012
|
+
// llm_timeout_seconds must be a positive integer when present (the same convention as
|
|
1013
|
+
// retry.total_timeout_seconds and gate.timeout_seconds). Deliberately kind-BLIND, which
|
|
1014
|
+
// makes it the pinned DOUBLE-fire control for #517: on a wrong-kind step BOTH the minted
|
|
1015
|
+
// prohibition and this shape error fire — this check was never else-if-suppressed, unlike
|
|
1016
|
+
// structured_output/validation_exhaustion/input_map's value checks below.
|
|
893
1017
|
if (step['llm_timeout_seconds'] !== undefined &&
|
|
894
1018
|
(!Number.isInteger(step['llm_timeout_seconds']) ||
|
|
895
1019
|
step['llm_timeout_seconds'] <= 0)) {
|
|
896
1020
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'llm_timeout_seconds' must be a positive integer`));
|
|
897
1021
|
}
|
|
898
|
-
// timeout_seconds is NOT valid on an agent step (issue #402). Nothing enforces it there:
|
|
899
|
-
// `shouldEnforceTimeout` is `execution === 'auto'`, and agent dispatch is never wrapped in
|
|
900
|
-
// `withTimeout` at all. The key is now inert as well as unenforced — issue #412 deleted the
|
|
901
|
-
// `expected_timeout` display that used to render it into the NextAction, which is what made
|
|
902
|
-
// it actively misleading rather than merely useless. The error stays: an author who writes a
|
|
903
|
-
// bound should be told it does nothing, not left to find out. The message names both bounds
|
|
904
|
-
// that DO exist, scoped to realm's own drive (an externally driven step gets neither), on
|
|
905
|
-
// the RETRY_INERT_NON_AUTO precedent below.
|
|
906
|
-
//
|
|
907
|
-
// `=== 'agent'` EXACTLY, never `!== 'auto'`: finalizers consume this key twice — the drain
|
|
908
|
-
// lease (execution-loop.ts:5226) and the handler's own bound (:5030) — and guards already
|
|
909
|
-
// reject it in the prohibited-fields list above.
|
|
910
|
-
if (step['timeout_seconds'] !== undefined && step['execution'] === 'agent') {
|
|
911
|
-
errors.push(withKeyLine(stepName, 'timeout_seconds', `Step '${stepName}': 'timeout_seconds' is not valid on execution: agent steps — ` +
|
|
912
|
-
'the engine never enforces it there (agent dispatch is never wrapped in a timeout), ' +
|
|
913
|
-
'so the step would LOOK time-bounded while nothing enforced the bound. ' +
|
|
914
|
-
"In realm's own drive the model request is bounded by 'llm_timeout_seconds' " +
|
|
915
|
-
"(or --llm-timeout) and tool calls by 'tool_timeout'."));
|
|
916
|
-
}
|
|
917
|
-
// idempotent (issue #101 Phase 2) is only valid on execution: auto steps — the reliably
|
|
918
|
-
// time-boundable, deadline-carrying class. It is inert (no concrete deadline is ever written)
|
|
919
|
-
// on agent/guard/finalizer, so it is rejected there rather than silently ignored.
|
|
920
|
-
if (step['idempotent'] !== undefined && step['execution'] !== 'auto') {
|
|
921
|
-
errors.push(
|
|
922
|
-
// Consumers: execution-loop.ts:2526 (the `willRetry` conjunct gating `retry.on_timeout`;
|
|
923
|
-
// the :2115 advisory mirrors the rule for loader-bypassing definitions and, by its own
|
|
924
|
-
// header, never gates) and reclaim.ts:73 (reclaim eligibility) — both act on auto
|
|
925
|
-
// dispatch.
|
|
926
|
-
withKeyLine(stepName, 'idempotent', `Step '${stepName}': 'idempotent' is only valid on execution: auto steps — it gates ` +
|
|
927
|
-
"'retry.on_timeout' and reclaim eligibility, and both act on auto dispatch, so here " +
|
|
928
|
-
'it would gate nothing. Remove it, or move the work to an auto step if you need ' +
|
|
929
|
-
'either.'));
|
|
930
|
-
}
|
|
931
1022
|
// WARN (do not reject): an idempotent auto step in a finalizer-bearing workflow gets
|
|
932
1023
|
// `deadline: null` (issue #101), so the RECLAIM function is inert — `realm run reclaim --all`
|
|
933
1024
|
// can never select it. The author should know it stays per-step-manual-reclaim-only.
|
|
@@ -956,21 +1047,16 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
956
1047
|
: ''),
|
|
957
1048
|
});
|
|
958
1049
|
}
|
|
959
|
-
//
|
|
960
|
-
if (step['output_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
961
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'output_schema' is only valid on execution: agent steps`));
|
|
962
|
-
}
|
|
963
|
-
// issue #236 (L0 prevention layer): structured_output is only valid on execution: agent
|
|
964
|
-
// steps (mirrors output_schema's rule above), and its only legal value is the literal
|
|
1050
|
+
// issue #236 (L0 prevention layer): structured_output's only legal value is the literal
|
|
965
1051
|
// 'strict'. On an opted-in step, Phase A REJECTS an ineligible verdict at load time — the
|
|
966
1052
|
// API provably rejects some legal schemas and silently weakens others, so authoring never
|
|
967
1053
|
// ships a schema the gate already knows is unsafe. Caveats are NOT rejected (informational
|
|
968
1054
|
// only, surfaced by validate's nudge — Deliverable 7); this loader block only ever REJECTS.
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1055
|
+
// #517 re-gate: the kind half is minted by the registry walk above; the value checks
|
|
1056
|
+
// below keep their old else-branch semantics via an explicit valid-kind conjunct — a
|
|
1057
|
+
// wrong-kind step gets ONLY the minted refusal, never the value noise.
|
|
1058
|
+
if (step['structured_output'] !== undefined && step['execution'] === 'agent') {
|
|
1059
|
+
if (step['structured_output'] !== 'strict') {
|
|
974
1060
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'structured_output' must be the literal string 'strict' (got ${JSON.stringify(step['structured_output'])})`));
|
|
975
1061
|
}
|
|
976
1062
|
else {
|
|
@@ -998,11 +1084,9 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
998
1084
|
// then AJV-proven AT LOAD TIME (REFUSE — B10, reusing the runtime validator so load-time and
|
|
999
1085
|
// runtime verdicts can never diverge); `default_output` present without `mode: 'default'` WARNS
|
|
1000
1086
|
// as dead config (never rejects — it's simply inert); an unknown sub-key WARNS.
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
}
|
|
1005
|
-
else if (typeof step['validation_exhaustion'] !== 'object' ||
|
|
1087
|
+
// #517 re-gate: kind half minted above; else-semantics preserved by the explicit conjunct.
|
|
1088
|
+
if (step['validation_exhaustion'] !== undefined && step['execution'] === 'agent') {
|
|
1089
|
+
if (typeof step['validation_exhaustion'] !== 'object' ||
|
|
1006
1090
|
step['validation_exhaustion'] === null) {
|
|
1007
1091
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'validation_exhaustion' must be an object`));
|
|
1008
1092
|
}
|
|
@@ -1097,26 +1181,85 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1097
1181
|
`output is validated against both — prefer one to avoid divergence.`,
|
|
1098
1182
|
});
|
|
1099
1183
|
}
|
|
1100
|
-
// trace_schema is only valid on execution: agent steps.
|
|
1101
|
-
if (step['trace_schema'] !== undefined && step['execution'] !== 'agent') {
|
|
1102
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_schema' is only valid on execution: agent steps`));
|
|
1103
|
-
}
|
|
1104
|
-
// trace_validation_mode is only valid on execution: agent steps.
|
|
1105
|
-
if (step['trace_validation_mode'] !== undefined && step['execution'] !== 'agent') {
|
|
1106
|
-
errors.push(withStepLine(stepName, `Step '${stepName}': 'trace_validation_mode' is only valid on execution: agent steps`));
|
|
1107
|
-
}
|
|
1108
1184
|
// trace_validation_mode must be 'warn' or 'enforce' when provided.
|
|
1109
1185
|
if (step['trace_validation_mode'] !== undefined &&
|
|
1110
1186
|
step['trace_validation_mode'] !== 'warn' &&
|
|
1111
1187
|
step['trace_validation_mode'] !== 'enforce') {
|
|
1112
1188
|
errors.push(withStepLine(stepName, `Step '${stepName}': invalid trace_validation_mode '${String(step['trace_validation_mode'])}'; must be 'warn' or 'enforce'`));
|
|
1113
1189
|
}
|
|
1190
|
+
// issue #433: the effective STATIC gate choice source, hoisted per-step BEFORE the
|
|
1191
|
+
// `gate:`-block-only region below. Member (b) below must fire even when there is NO `gate:`
|
|
1192
|
+
// key at all (the executed g433b shape: a gate-trusted step with no gate block but an
|
|
1193
|
+
// empty `input_schema.properties.choice.enum`), and the entire #291 region beneath this one
|
|
1194
|
+
// is gated on `step['gate'] !== undefined` — it cannot host a check that must fire without
|
|
1195
|
+
// one. Pure reads; the existing E2/membership cells (below) pin messages, not evaluation
|
|
1196
|
+
// order, so hoisting these three declarations ahead of them is safe.
|
|
1197
|
+
const gateObj = typeof step['gate'] === 'object' && step['gate'] !== null
|
|
1198
|
+
? step['gate']
|
|
1199
|
+
: undefined;
|
|
1200
|
+
const declaredGateChoices = gateObj?.['choices'];
|
|
1201
|
+
const declaredChoiceEnum = step['input_schema']?.properties?.['choice']?.enum;
|
|
1202
|
+
// Renders the OFFENDING KEY's own line via its full nested path, falling back to the
|
|
1203
|
+
// step's line and then to no position — the same two-rung univocal vocabulary
|
|
1204
|
+
// `withKeyLine` documents above (issue #420: `(line N)` for the key, `(step at line N)`
|
|
1205
|
+
// for the step; never conflated). `withKeyLine` itself is single-segment
|
|
1206
|
+
// (`['steps', stepName, key]`) and cannot express a nested path like
|
|
1207
|
+
// `['steps', stepName, 'gate', 'choices']`, so this is a local sibling rather than a call
|
|
1208
|
+
// to it — no existing `withKeyLine` call site is touched.
|
|
1209
|
+
const withPathLine = (path, message) => {
|
|
1210
|
+
const keyLine = sourceMap.posOf(path)?.line;
|
|
1211
|
+
if (keyLine !== undefined)
|
|
1212
|
+
return `${message} (line ${keyLine})`;
|
|
1213
|
+
const stepLine = sourceMap.posOf(['steps', stepName])?.line;
|
|
1214
|
+
if (stepLine !== undefined)
|
|
1215
|
+
return `${message} (step at line ${stepLine})`;
|
|
1216
|
+
return message;
|
|
1217
|
+
};
|
|
1218
|
+
// Member (a) (issue #433): a DECLARED `gate.choices` list that is empty is never right, on
|
|
1219
|
+
// ANY step — gate-trusted or not (the #291 block's own posture just below: a `gate:` key
|
|
1220
|
+
// is validated "regardless of trust"; the #417 strict-on-known-key policy agrees). An empty
|
|
1221
|
+
// list on a gate-trusted step mints an unanswerable gate (every response is refused against
|
|
1222
|
+
// an empty expected set) with no disposal path short of an authored expiry — on an ungated
|
|
1223
|
+
// step it is dead weight either way, so the message is deliberately population-invariant
|
|
1224
|
+
// rather than false for the ungated population.
|
|
1225
|
+
if (Array.isArray(declaredGateChoices) && declaredGateChoices.length === 0) {
|
|
1226
|
+
errors.push(withPathLine(['steps', stepName, 'gate', 'choices'], `Step '${stepName}': 'gate.choices', when declared, must be non-empty — an empty ` +
|
|
1227
|
+
'list is never right: on a gate-trusted step (trust: human_confirmed/human_reviewed) ' +
|
|
1228
|
+
'it mints a gate NO response can ever resolve (every submission is refused against ' +
|
|
1229
|
+
'an empty expected list, and the live run wedges with no disposal path: abandon ' +
|
|
1230
|
+
'refuses a pending gate; purge and drain refuse a live run; only an authored ' +
|
|
1231
|
+
"'gate.timeout_seconds' + 'on_expiry' expiry could ever clear it). Declare at least " +
|
|
1232
|
+
"one choice, or remove the key to fall back to 'input_schema.properties.choice.enum' " +
|
|
1233
|
+
'or the default pair (approve/reject).'));
|
|
1234
|
+
}
|
|
1235
|
+
// Member (b) (issue #433): for a GATE-TRUSTED step with no `gate.choices` list declared
|
|
1236
|
+
// (NULLISH — the mint's own `??` semantics; `gate: {choices:}` with a YAML-null value is
|
|
1237
|
+
// the third executed wedge shape, and presence-keying would let it escape this check), a
|
|
1238
|
+
// DECLARED-and-empty `input_schema.properties.choice.enum` is the effective choice source
|
|
1239
|
+
// and the same class of error, under its own key. `choices: null` with no `enum` at all
|
|
1240
|
+
// stays legal — the mint defaults to ['approve', 'reject'].
|
|
1241
|
+
if (isGateTrust(step['trust']) &&
|
|
1242
|
+
declaredGateChoices == null &&
|
|
1243
|
+
Array.isArray(declaredChoiceEnum) &&
|
|
1244
|
+
declaredChoiceEnum.length === 0) {
|
|
1245
|
+
errors.push(withPathLine(['steps', stepName, 'input_schema', 'properties', 'choice', 'enum'], `Step '${stepName}': 'input_schema.properties.choice.enum' is this gate's effective ` +
|
|
1246
|
+
"choice source (no 'gate.choices' list declared) and, when declared, must be " +
|
|
1247
|
+
'non-empty — an empty list mints a gate NO response can ever resolve (every ' +
|
|
1248
|
+
'submission is refused against an empty expected list, and the live run wedges ' +
|
|
1249
|
+
'with no disposal path: abandon refuses a pending gate; purge and drain refuse a ' +
|
|
1250
|
+
"live run; only an authored 'gate.timeout_seconds' + 'on_expiry' expiry could ever " +
|
|
1251
|
+
"clear it). Declare at least one enum value, or remove 'enum' to get the default " +
|
|
1252
|
+
'pair (approve/reject).'));
|
|
1253
|
+
}
|
|
1114
1254
|
// issue #291 (authorable gate timeout — the FIRST validation the `gate:` block has ever had):
|
|
1115
1255
|
// the E2 positive-integer checks on timeout_seconds/reminder_seconds/reminder_max, the
|
|
1116
1256
|
// on_expiry enum, default_choice's required-iff + choice-set validation, and the dead-config
|
|
1117
|
-
// warn cells.
|
|
1118
|
-
//
|
|
1119
|
-
// trust
|
|
1257
|
+
// warn cells. The hard-error checks run regardless of `trust` (a shape/enum mistake is a
|
|
1258
|
+
// mistake whether or not this step can ever gate). The dead-config ADVISORIES fork on
|
|
1259
|
+
// `isGateTrust` (issue #524): the engine mints a gate only where trust requires human
|
|
1260
|
+
// confirmation (Step 5b, `execution-loop.ts`) — on any other step the WHOLE block is inert,
|
|
1261
|
+
// so a per-member remedy ("set a timeout") would be false: following it never makes the key
|
|
1262
|
+
// live, it only silences the one diagnostic that said so.
|
|
1120
1263
|
if (step['gate'] !== undefined) {
|
|
1121
1264
|
if (typeof step['gate'] !== 'object' || step['gate'] === null) {
|
|
1122
1265
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate' must be an object`));
|
|
@@ -1156,17 +1299,22 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1156
1299
|
// mirroring validation_exhaustion.mode:'default' requiring default_output); validated
|
|
1157
1300
|
// against the step's own EFFECTIVE STATIC choice set — the EXACT same three-source
|
|
1158
1301
|
// derivation the engine mints PendingGate.choices from (execution-loop.ts's gate-open
|
|
1159
|
-
// site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject'])
|
|
1160
|
-
//
|
|
1302
|
+
// site: gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']),
|
|
1303
|
+
// sourced from the issue #433 hoist above (`declaredGateChoices ?? declaredChoiceEnum`
|
|
1304
|
+
// — one chain, so this can never drift from the mint's) — so a load-time-legal
|
|
1305
|
+
// default_choice can NEVER fail at enactment time.
|
|
1161
1306
|
const hasDefaultChoice = 'default_choice' in gate;
|
|
1307
|
+
// issue #524: the one place this block's dead-config ADVISORIES fork. The mint only
|
|
1308
|
+
// ever reads `gate.*` where `isGateTrust(trust)` holds (execution-loop.ts:3302,
|
|
1309
|
+
// `W_GATE_MINT_TRUST`) — computed once so the three sites below can never disagree.
|
|
1310
|
+
const gateTrusted = isGateTrust(step['trust']);
|
|
1162
1311
|
if (onExpiry === 'settle_default') {
|
|
1163
1312
|
if (!hasDefaultChoice) {
|
|
1164
1313
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'gate.on_expiry: settle_default' requires 'gate.default_choice' ` +
|
|
1165
1314
|
`(nothing to resolve the gate with on expiry)`));
|
|
1166
1315
|
}
|
|
1167
1316
|
else {
|
|
1168
|
-
const choicesRaw =
|
|
1169
|
-
step['input_schema']?.properties?.['choice']?.enum;
|
|
1317
|
+
const choicesRaw = declaredGateChoices ?? declaredChoiceEnum;
|
|
1170
1318
|
const effectiveChoices = Array.isArray(choicesRaw)
|
|
1171
1319
|
? choicesRaw
|
|
1172
1320
|
: ['approve', 'reject'];
|
|
@@ -1176,9 +1324,10 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1176
1324
|
}
|
|
1177
1325
|
}
|
|
1178
1326
|
}
|
|
1179
|
-
else if (hasDefaultChoice) {
|
|
1327
|
+
else if (hasDefaultChoice && gateTrusted) {
|
|
1180
1328
|
// default_choice with on_expiry:'abort' or with no on_expiry at all — inert, not an
|
|
1181
1329
|
// error: WARN as dead config (the #220 DEAD_VALIDATION_EXHAUSTION_CONFIG precedent).
|
|
1330
|
+
// Gate-trusted only (issue #524) — off gate trust the block advisory below covers it.
|
|
1182
1331
|
warnings.push({
|
|
1183
1332
|
code: 'DEAD_GATE_CONFIG',
|
|
1184
1333
|
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
@@ -1188,31 +1337,69 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1188
1337
|
`'gate.on_expiry: settle_default' — set it, or remove 'gate.default_choice'.`,
|
|
1189
1338
|
});
|
|
1190
1339
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1340
|
+
if (gateTrusted) {
|
|
1341
|
+
// Dead config: on_expiry declared but no timeout_seconds — nothing will ever trigger
|
|
1342
|
+
// the enforce clock, so the declared disposition can never enact. Gate-trusted only
|
|
1343
|
+
// (issue #524): off gate trust the block advisory below covers it.
|
|
1344
|
+
if (onExpiry !== undefined && gate['timeout_seconds'] === undefined) {
|
|
1345
|
+
warnings.push({
|
|
1346
|
+
code: 'DEAD_GATE_CONFIG',
|
|
1347
|
+
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1348
|
+
scope: 'step',
|
|
1349
|
+
step: stepName,
|
|
1350
|
+
message: `Step '${stepName}': 'gate.on_expiry' is ignored without 'gate.timeout_seconds' ` +
|
|
1351
|
+
`— set a timeout, or remove 'gate.on_expiry'.`,
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
// Dead notification ([F-A2-5]): reminder_seconds >= timeout_seconds means the FIRST
|
|
1355
|
+
// reminder occurrence would never fire before the enforce clock expires. Gate-trusted
|
|
1356
|
+
// only (issue #524): off gate trust the block advisory below covers it.
|
|
1357
|
+
if (typeof gate['reminder_seconds'] === 'number' &&
|
|
1358
|
+
typeof gate['timeout_seconds'] === 'number' &&
|
|
1359
|
+
gate['reminder_seconds'] >= gate['timeout_seconds']) {
|
|
1360
|
+
warnings.push({
|
|
1361
|
+
code: 'DEAD_GATE_CONFIG',
|
|
1362
|
+
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1363
|
+
scope: 'step',
|
|
1364
|
+
step: stepName,
|
|
1365
|
+
message: `Step '${stepName}': 'gate.reminder_seconds' (${String(gate['reminder_seconds'])}) ` +
|
|
1366
|
+
`>= 'gate.timeout_seconds' (${String(gate['timeout_seconds'])}) — the first ` +
|
|
1367
|
+
`reminder would never fire before the gate expires.`,
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1202
1370
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1371
|
+
else {
|
|
1372
|
+
// issue #524 (the gate-remedy silence): without gate trust the mint never reads ANY
|
|
1373
|
+
// key in this block, so a per-member remedy ("set a timeout") is false — following it
|
|
1374
|
+
// would silence the diagnostic while the whole block stays exactly as dead. ONE
|
|
1375
|
+
// advisory naming the true cause, unconditional on which keys are set (the block is
|
|
1376
|
+
// equally inert whichever ones are). Position on the STRUCTURED channel only
|
|
1377
|
+
// (`withKeyLine`/`withPathLine` are the ERROR-string helpers, consumed only by
|
|
1378
|
+
// `errors.push` — no loader advisory carries a position today, and this one follows
|
|
1379
|
+
// that convention: `renderLoaderWarning` prints `⚠ ${message}` alone). The kind list
|
|
1380
|
+
// is DERIVED from the registry (`consumedKindsFor('trust')`, the #517
|
|
1381
|
+
// `consumed_home.kinds` pattern) so the remedy can never drift from the vocabulary
|
|
1382
|
+
// that actually gates it.
|
|
1383
|
+
const gatePos = sourceMap.posOf(['steps', stepName, 'gate']);
|
|
1384
|
+
const trustKinds = consumedKindsFor('trust');
|
|
1208
1385
|
warnings.push({
|
|
1209
1386
|
code: 'DEAD_GATE_CONFIG',
|
|
1210
1387
|
severity: resolveSeverity('DEAD_GATE_CONFIG'),
|
|
1211
1388
|
scope: 'step',
|
|
1212
1389
|
step: stepName,
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1390
|
+
key: 'gate',
|
|
1391
|
+
...(gatePos !== undefined
|
|
1392
|
+
? {
|
|
1393
|
+
line: gatePos.line,
|
|
1394
|
+
column: gatePos.column,
|
|
1395
|
+
endLine: gatePos.endLine,
|
|
1396
|
+
endColumn: gatePos.endColumn,
|
|
1397
|
+
}
|
|
1398
|
+
: {}),
|
|
1399
|
+
message: `Step '${stepName}': the 'gate:' block is inert — this step declares no gate ` +
|
|
1400
|
+
`trust ('trust: human_confirmed' or 'trust: human_reviewed'), so no gate is ever ` +
|
|
1401
|
+
`minted and none of its keys are read. Remove the block, or (on an ` +
|
|
1402
|
+
`${trustKinds.join(' or ')} step) declare that trust.`,
|
|
1216
1403
|
});
|
|
1217
1404
|
}
|
|
1218
1405
|
}
|
|
@@ -1232,6 +1419,13 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1232
1419
|
}
|
|
1233
1420
|
else {
|
|
1234
1421
|
const retry = step['retry'];
|
|
1422
|
+
// The one population gate both retry advisories (W5 + RETRY_INERT_NON_AUTO) share:
|
|
1423
|
+
// the registry's own retry cell for this step's kind. `inert` = admitted-but-unread
|
|
1424
|
+
// (agent/guard). On `consumed` (auto) neither advisory applies; on `prohibited`
|
|
1425
|
+
// (finalizer) the #517 refusal above is the whole story and an advisory beside it
|
|
1426
|
+
// would contradict it; on a malformed kind the invalid-execution error is the verdict.
|
|
1427
|
+
const retryCellIsInert = VALID_EXECUTIONS.has(step['execution']) &&
|
|
1428
|
+
STEP_KEY_REGISTRY.retry[step['execution']].c === 'inert';
|
|
1235
1429
|
// WARN (do not reject) on an unknown retry-block key — same non-breaking posture as the
|
|
1236
1430
|
// step/workflow-level checks (issue #140). Noun overridden to 'retry' (not 'step') since
|
|
1237
1431
|
// this is a nested block, not the step itself.
|
|
@@ -1284,8 +1478,11 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1284
1478
|
}
|
|
1285
1479
|
// W5 (CAP-ONLY advisory — the on_timeout half of this is already an E1 hard error, so
|
|
1286
1480
|
// it never reaches here as a warning): the total-time cap only bounds `execution: 'auto'`
|
|
1287
|
-
// dispatch — inert on any other step type that
|
|
1288
|
-
|
|
1481
|
+
// dispatch — inert on any other step type that LEGALLY declares `retry:`. The gate is
|
|
1482
|
+
// the registry's own retry cell: the advisory fires only where retry is admitted-but-
|
|
1483
|
+
// inert (agent/guard), never beside the finalizer refusal it would contradict, and
|
|
1484
|
+
// never on a malformed kind (already refused by the invalid-execution error).
|
|
1485
|
+
if (retryCellIsInert && retry['total_timeout_seconds'] !== undefined) {
|
|
1289
1486
|
warnings.push({
|
|
1290
1487
|
code: 'TOTAL_TIMEOUT_NON_AUTO',
|
|
1291
1488
|
severity: resolveSeverity('TOTAL_TIMEOUT_NON_AUTO'),
|
|
@@ -1293,19 +1490,21 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1293
1490
|
step: stepName,
|
|
1294
1491
|
message: `Step '${stepName}': 'retry.total_timeout_seconds' is inert on execution: ` +
|
|
1295
1492
|
`'${String(step['execution'])}' steps — the cap only bounds 'execution: auto' ` +
|
|
1296
|
-
`dispatch
|
|
1493
|
+
`dispatch; no other kind's dispatch ever consumes it.`,
|
|
1297
1494
|
});
|
|
1298
1495
|
}
|
|
1299
1496
|
// issue #218 (extends the W5 family): the BARE-KEYS advisory — no explicit
|
|
1300
|
-
// total_timeout_seconds (that shape is W5's, above), but retry: is present on a step
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
|
|
1497
|
+
// total_timeout_seconds (that shape is W5's, above), but retry: is present on a step no
|
|
1498
|
+
// dispatching retry loop ever consumes it on. Complementary to W5's own `!== undefined`
|
|
1499
|
+
// conjunct on the SAME registry-derived inert gate, so for any admitted-but-inert retry
|
|
1500
|
+
// block exactly ONE of {W5, RETRY_INERT_NON_AUTO} ever fires — never both, never
|
|
1501
|
+
// neither. The gate EXCLUDES the prohibited kind (finalizer): errors accumulate rather
|
|
1502
|
+
// than halt, so the old `!== 'auto'` gate leaked this advisory beside the finalizer
|
|
1503
|
+
// refusal, where every clause of it was false ("never throws" — the drain throws
|
|
1504
|
+
// routinely; "may still consume" — no dispatcher can reach a finalizer's retry;
|
|
1505
|
+
// "not an invalid one" — the co-fired error says it IS invalid). Registry-derived:
|
|
1506
|
+
// it fires exactly where the retry cell is inert (agent/guard).
|
|
1507
|
+
if (retryCellIsInert && retry['total_timeout_seconds'] === undefined) {
|
|
1309
1508
|
const isAgent = step['execution'] === 'agent';
|
|
1310
1509
|
const message = isAgent
|
|
1311
1510
|
? `Step '${stepName}': 'retry' is inert on execution: 'agent' steps — the built-in ` +
|
|
@@ -1314,9 +1513,9 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1314
1513
|
`flag instead). An embedder-supplied throwing dispatcher may still consume this ` +
|
|
1315
1514
|
`config — a deliberate public-API capability, not an invalid one.`
|
|
1316
1515
|
: `Step '${stepName}': 'retry' is inert on execution: '${String(step['execution'])}' ` +
|
|
1317
|
-
`steps —
|
|
1318
|
-
`
|
|
1319
|
-
`
|
|
1516
|
+
`steps — a guard's evaluation never traverses the dispatch path (its conditions ` +
|
|
1517
|
+
`are evaluated inline, with no dispatcher and no retry read), so this block can ` +
|
|
1518
|
+
`never mint a second attempt here.`;
|
|
1320
1519
|
warnings.push({
|
|
1321
1520
|
code: 'RETRY_INERT_NON_AUTO',
|
|
1322
1521
|
severity: resolveSeverity('RETRY_INERT_NON_AUTO'),
|
|
@@ -1339,13 +1538,16 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1339
1538
|
`'max_attempts' of 1 — there is no second attempt to retry into.`,
|
|
1340
1539
|
});
|
|
1341
1540
|
}
|
|
1342
|
-
// W2: the cap
|
|
1343
|
-
// below an EXPLICIT timeout_seconds, or (b) on_timeout: true with a
|
|
1344
|
-
// effective per-attempt timeout
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1541
|
+
// W2 (issue #524 correction): the declared cap is at or below the per-attempt timeout —
|
|
1542
|
+
// (a) an EXPLICIT cap below an EXPLICIT timeout_seconds, or (b) on_timeout: true with a
|
|
1543
|
+
// cap at-or-below the effective per-attempt timeout. This does NOT mean "no retry can
|
|
1544
|
+
// ever occur": `willRetry`'s first disjunct (execution-loop.ts) has no cap conjunct, so
|
|
1545
|
+
// a retryable failure that returns faster than the (clipped) attempt bound still retries
|
|
1546
|
+
// while 'max_attempts' allows another attempt — only an attempt that runs OUT its full
|
|
1547
|
+
// bound exhausts the cap with nothing left for a retry. Both arms require an EXPLICIT
|
|
1548
|
+
// total_timeout_seconds — the AMENDED default cap (the worst-case schedule) is, by
|
|
1549
|
+
// construction, never below a single attempt for max_attempts ≥ 2, so this never fires
|
|
1550
|
+
// on the bare 3600s-default population.
|
|
1349
1551
|
const explicitCapSeconds = typeof retry['total_timeout_seconds'] === 'number'
|
|
1350
1552
|
? retry['total_timeout_seconds']
|
|
1351
1553
|
: undefined;
|
|
@@ -1361,9 +1563,11 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1361
1563
|
scope: 'step',
|
|
1362
1564
|
step: stepName,
|
|
1363
1565
|
message: `Step '${stepName}': 'retry.total_timeout_seconds: ${explicitCapSeconds}' is at ` +
|
|
1364
|
-
`or below its
|
|
1365
|
-
|
|
1366
|
-
`
|
|
1566
|
+
`or below its per-attempt timeout (${effectivePerAttemptSeconds}s` +
|
|
1567
|
+
`${explicitTimeoutSeconds === undefined ? ', the default' : ''}) — each attempt ` +
|
|
1568
|
+
`is bounded by what remains of the cap, so an attempt that runs to its bound ` +
|
|
1569
|
+
`exhausts the cap with no retry; a faster failure still retries while ` +
|
|
1570
|
+
`'max_attempts' allows another attempt and its backoff wait fits the remaining cap.`,
|
|
1367
1571
|
});
|
|
1368
1572
|
}
|
|
1369
1573
|
}
|
|
@@ -1373,21 +1577,18 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1373
1577
|
!VALID_SERVICE_METHODS.has(step['service_method'])) {
|
|
1374
1578
|
errors.push(withStepLine(stepName, `Step '${stepName}': invalid service_method '${String(step['service_method'])}'; must be 'fetch', 'create', 'update', or 'delete'`));
|
|
1375
1579
|
}
|
|
1376
|
-
// Validate input_map
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
|
|
1389
|
-
errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
|
|
1390
|
-
}
|
|
1580
|
+
// Validate input_map VALUES (auto only — the kind half is minted by the #517 walk; the
|
|
1581
|
+
// explicit conjunct preserves the old else-branch: a wrong-kind step gets only the minted
|
|
1582
|
+
// refusal, never the value noise).
|
|
1583
|
+
if (step['input_map'] !== undefined && step['execution'] === 'auto') {
|
|
1584
|
+
// issue #392: input_map's errors are minted deep inside a recursive walk that knows only
|
|
1585
|
+
// its path string, not the step's position. Collected here and suffixed on the way out,
|
|
1586
|
+
// so ONE step's error list never mixes positioned and bare messages — a reader seeing
|
|
1587
|
+
// "(step at line 12)" on three of five errors would reasonably wonder what is different about
|
|
1588
|
+
// the other two, and nothing is.
|
|
1589
|
+
const inputMapErrors = [];
|
|
1590
|
+
validateInputMapNode(step['input_map'], `Step '${stepName}': input_map`, inputMapErrors, 0);
|
|
1591
|
+
errors.push(...inputMapErrors.map((e) => withStepLine(stepName, e)));
|
|
1391
1592
|
}
|
|
1392
1593
|
// Step config may hold any JSON value (scalars, arrays, nested objects). It is passed through
|
|
1393
1594
|
// opaquely to handlers (context.config) and merged into adapter config for uses_service steps;
|
|
@@ -1464,7 +1665,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1464
1665
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' must be a non-empty string`));
|
|
1465
1666
|
}
|
|
1466
1667
|
else {
|
|
1467
|
-
validateConditionLeaf('when', rawWhen, stepName, dependsOn, errors, withStepLine);
|
|
1668
|
+
validateConditionLeaf('when', rawWhen, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1468
1669
|
}
|
|
1469
1670
|
}
|
|
1470
1671
|
else if (Array.isArray(rawWhen)) {
|
|
@@ -1477,7 +1678,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1477
1678
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'when' array entries must be non-empty strings`));
|
|
1478
1679
|
}
|
|
1479
1680
|
else {
|
|
1480
|
-
validateConditionLeaf('when', leaf, stepName, dependsOn, errors, withStepLine);
|
|
1681
|
+
validateConditionLeaf('when', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1481
1682
|
}
|
|
1482
1683
|
}
|
|
1483
1684
|
}
|
|
@@ -1496,7 +1697,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1496
1697
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' must be a non-empty string`));
|
|
1497
1698
|
}
|
|
1498
1699
|
else {
|
|
1499
|
-
validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, errors, withStepLine);
|
|
1700
|
+
validateConditionLeaf('abort_unless', rawAbort, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1500
1701
|
}
|
|
1501
1702
|
}
|
|
1502
1703
|
else if (Array.isArray(rawAbort)) {
|
|
@@ -1509,7 +1710,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1509
1710
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'abort_unless' array entries must be non-empty strings`));
|
|
1510
1711
|
}
|
|
1511
1712
|
else {
|
|
1512
|
-
validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, errors, withStepLine);
|
|
1713
|
+
validateConditionLeaf('abort_unless', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1513
1714
|
}
|
|
1514
1715
|
}
|
|
1515
1716
|
}
|
|
@@ -1532,7 +1733,7 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1532
1733
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'preconditions' entries must be non-empty strings`));
|
|
1533
1734
|
}
|
|
1534
1735
|
else {
|
|
1535
|
-
validateConditionLeaf('preconditions', leaf, stepName, dependsOn, errors, withStepLine);
|
|
1736
|
+
validateConditionLeaf('preconditions', leaf, stepName, dependsOn, stepKind, errors, withStepLine);
|
|
1536
1737
|
}
|
|
1537
1738
|
}
|
|
1538
1739
|
}
|
|
@@ -1578,9 +1779,9 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1578
1779
|
// guard arm's consequence below is forked — collapsing it back into one shared string
|
|
1579
1780
|
// would make the error claim a wedge that cannot happen.
|
|
1580
1781
|
//
|
|
1581
|
-
// Post-#369 a guard declaring `preconditions` is REFUSED outright
|
|
1582
|
-
//
|
|
1583
|
-
// than short-circuit, and the
|
|
1782
|
+
// Post-#369 a guard declaring `preconditions` is REFUSED outright — since #517, by
|
|
1783
|
+
// the registry mint near the top of Step 3 — so this arm now only ever fires ALONGSIDE
|
|
1784
|
+
// that refusal: errors accumulate rather than short-circuit, and the mint runs first, so both messages reach the author
|
|
1584
1785
|
// with the prohibition printed above this one. The arm is kept, not deleted — it is what
|
|
1585
1786
|
// stops the dead-condition message from claiming a wedge that a guard cannot have, and a
|
|
1586
1787
|
// definition reaching this code by any path other than a fresh YAML load (a
|
|
@@ -1656,9 +1857,14 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1656
1857
|
}
|
|
1657
1858
|
}
|
|
1658
1859
|
}
|
|
1659
|
-
// Validate tools
|
|
1860
|
+
// Validate tools × handler (the COMPOUND half of the old tools rule — #517 split it: the
|
|
1861
|
+
// non-agent kinds are minted from the registry above; this hand-written check keeps ONLY
|
|
1862
|
+
// the agent-with-handler arm, whose predicate is a companion conflict, not a kind rule).
|
|
1863
|
+
// Populations are disjoint by construction (this fires only on execution: 'agent'; the
|
|
1864
|
+
// mint only on non-agent kinds), so the old multi-fire cannot re-appear.
|
|
1660
1865
|
if (step['tools'] !== undefined &&
|
|
1661
|
-
|
|
1866
|
+
step['execution'] === 'agent' &&
|
|
1867
|
+
step['handler'] !== undefined) {
|
|
1662
1868
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'tools' is only valid on execution: agent steps without 'handler' defined`));
|
|
1663
1869
|
}
|
|
1664
1870
|
// issue #413: `tool_timeout` requires `tools`. It bounds ONE tool call inside the agentic
|
|
@@ -1741,12 +1947,11 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1741
1947
|
errors.push(withStepLine(stepName, `Step '${stepName}': 'tool_timeout' must be a positive integer`));
|
|
1742
1948
|
}
|
|
1743
1949
|
// Validate timeout_seconds: must be a positive integer (issue A3). Skipped on
|
|
1744
|
-
// execution: guard — the
|
|
1745
|
-
// 'timeout_seconds'
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
//
|
|
1749
|
-
// pointed at the shape, which is not the problem.
|
|
1950
|
+
// execution: guard and agent — the #517 registry mint already flatly rejects
|
|
1951
|
+
// 'timeout_seconds' on both kinds; re-checking its shape here would double-report the
|
|
1952
|
+
// same root cause under a second, confusing message (an author told BOTH that the key is
|
|
1953
|
+
// invalid here and that its value has the wrong shape is being pointed at the shape,
|
|
1954
|
+
// which is not the problem).
|
|
1750
1955
|
if (step['timeout_seconds'] !== undefined &&
|
|
1751
1956
|
step['execution'] !== 'guard' &&
|
|
1752
1957
|
step['execution'] !== 'agent' &&
|
|
@@ -1864,6 +2069,37 @@ function parseWorkflowString(content, registry, opts) {
|
|
|
1864
2069
|
normalizeTriggerFilter(triggerRaw); // canonicalise shorthand BEFORE validation
|
|
1865
2070
|
errors.push(...validateTriggerStructure(triggerRaw));
|
|
1866
2071
|
}
|
|
2072
|
+
// Step 3c: workflow-level context blocks (issue #553). These four rules need nothing but the
|
|
2073
|
+
// text, so they belong to every surface — file, string, `validate --registered`, the public
|
|
2074
|
+
// `loadWorkflowFromString`. They lived in the file loader until #553 and were therefore
|
|
2075
|
+
// invisible to `validate` (which parsed extension-free workflows from string) and to
|
|
2076
|
+
// `--registered`; the public string loader silently accepted all four shapes. Pushed, never
|
|
2077
|
+
// thrown: the accumulator mints `Invalid workflow:` once and the #425 per-line grammar composes.
|
|
2078
|
+
const contextWrapperRaw = doc['context_wrapper'];
|
|
2079
|
+
if (contextWrapperRaw !== undefined) {
|
|
2080
|
+
const VALID_WRAPPER_FORMATS = new Set(['xml', 'brackets', 'none']);
|
|
2081
|
+
if (!VALID_WRAPPER_FORMATS.has(contextWrapperRaw)) {
|
|
2082
|
+
errors.push(withTopLevelLine(['context_wrapper'], `'context_wrapper' must be 'xml', 'brackets', or 'none' (found: '${String(contextWrapperRaw)}')`));
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
const workflowContextRaw = doc['workflow_context'];
|
|
2086
|
+
if (workflowContextRaw !== undefined) {
|
|
2087
|
+
for (const [name, entry] of Object.entries(workflowContextRaw)) {
|
|
2088
|
+
if (name.endsWith('.raw')) {
|
|
2089
|
+
errors.push(withTopLevelLine(['workflow_context', name], `workflow_context entry '${name}' must not end with '.raw'`));
|
|
2090
|
+
}
|
|
2091
|
+
if (!/^[\w.]+$/.test(name)) {
|
|
2092
|
+
errors.push(withTopLevelLine(['workflow_context', name], `workflow_context entry '${name}' must match [\\w.]+ (underscores and dots only — no hyphens)`));
|
|
2093
|
+
}
|
|
2094
|
+
const rawEntry = entry;
|
|
2095
|
+
const rawSource = rawEntry?.['source'];
|
|
2096
|
+
if (rawSource === undefined || typeof rawSource['path'] !== 'string') {
|
|
2097
|
+
// The ENTRY's line: the missing key has no line, and a cite must never name an absent
|
|
2098
|
+
// key.
|
|
2099
|
+
errors.push(withTopLevelLine(['workflow_context', name], `workflow_context.${name}.source.path is required`));
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
1867
2103
|
if (errors.length > 0) {
|
|
1868
2104
|
throw new WorkflowError(`Invalid workflow: ${errors.join('; ')}`, {
|
|
1869
2105
|
// issue #425: the pre-join strings — see the profile collector above.
|