@sensigo/realm 0.39.0 → 0.40.0

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