@oxygen-agent/cli 1.739.1 → 1.750.4

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.
@@ -14,6 +14,69 @@
14
14
  // only, and the segments that reach JavaScript object internals
15
15
  // (__proto__ / constructor / prototype) are rejected outright.
16
16
  import { analyzeFormulaDependencies, evaluateFormulaInScope, formulaExpressionError, } from "@oxygen/formula";
17
+ /** A deterministic authoring/data error; retrying the same revision cannot repair it. */
18
+ export class WorkflowValueCoercionError extends Error {
19
+ coercion;
20
+ code = "workflow_value_coercion_failed";
21
+ constructor(coercion) {
22
+ super(`Mapped value could not be converted to ${coercion}.`);
23
+ this.coercion = coercion;
24
+ this.name = "WorkflowValueCoercionError";
25
+ }
26
+ }
27
+ /** A direct mapping named data the run does not carry; sending undefined is never intentional. */
28
+ export class WorkflowValueRefUnresolvedError extends Error {
29
+ path;
30
+ code = "workflow_value_ref_unresolved";
31
+ constructor(path) {
32
+ super(`Mapped field '${path}' did not resolve in this run.`);
33
+ this.path = path;
34
+ this.name = "WorkflowValueRefUnresolvedError";
35
+ }
36
+ }
37
+ /** Apply one explicit scalar conversion without lossy truthiness or truncation. */
38
+ export function coerceWorkflowValue(value, coercion) {
39
+ switch (coercion) {
40
+ case "string":
41
+ if (typeof value === "string")
42
+ return value;
43
+ if (typeof value === "number" && Number.isFinite(value))
44
+ return String(value);
45
+ if (typeof value === "boolean")
46
+ return value ? "true" : "false";
47
+ break;
48
+ case "number":
49
+ if (typeof value === "number" && Number.isFinite(value))
50
+ return value;
51
+ if (typeof value === "string" && value.trim() !== "") {
52
+ const parsed = Number(value);
53
+ if (Number.isFinite(parsed))
54
+ return parsed;
55
+ }
56
+ break;
57
+ case "integer":
58
+ if (typeof value === "number" && Number.isInteger(value))
59
+ return value;
60
+ if (typeof value === "string" && value.trim() !== "") {
61
+ const parsed = Number(value);
62
+ if (Number.isInteger(parsed))
63
+ return parsed;
64
+ }
65
+ break;
66
+ case "boolean":
67
+ if (typeof value === "boolean")
68
+ return value;
69
+ if (typeof value === "string") {
70
+ const normalized = value.trim().toLowerCase();
71
+ if (normalized === "true")
72
+ return true;
73
+ if (normalized === "false")
74
+ return false;
75
+ }
76
+ break;
77
+ }
78
+ throw new WorkflowValueCoercionError(coercion);
79
+ }
17
80
  /** The roots a `ref` path may start from. Anything else is not a scope path. */
18
81
  export const SCOPE_PATH_ROOTS = new Set(["trigger", "steps", "loop"]);
19
82
  /**
@@ -68,71 +131,123 @@ export function parseScopePath(path) {
68
131
  if (!trimmed || trimmed.startsWith(".") || trimmed.endsWith("."))
69
132
  return null;
70
133
  const segments = [];
71
- let current = "";
72
134
  let index = 0;
73
- const flush = () => {
74
- const segment = current.trim();
75
- current = "";
76
- if (!segment)
135
+ const push = (segment, allowEmpty = false) => {
136
+ if ((!allowEmpty && !segment) || BLOCKED_PATH_SEGMENTS.has(segment))
77
137
  return false;
78
138
  segments.push(segment);
79
139
  return true;
80
140
  };
141
+ const readBare = () => {
142
+ const start = index;
143
+ while (index < trimmed.length) {
144
+ const char = trimmed[index];
145
+ if (char === "." || char === "[")
146
+ break;
147
+ if (char === "]")
148
+ return false;
149
+ index += 1;
150
+ }
151
+ const raw = trimmed.slice(start, index);
152
+ return raw === raw.trim() && push(raw);
153
+ };
154
+ if (!readBare())
155
+ return null;
81
156
  while (index < trimmed.length) {
82
- const char = trimmed[index] ?? "";
157
+ const char = trimmed[index];
83
158
  if (char === ".") {
84
- if (!flush())
85
- return null;
86
159
  index += 1;
160
+ if (index >= trimmed.length || trimmed[index] === "." || trimmed[index] === "[")
161
+ return null;
162
+ if (!readBare())
163
+ return null;
87
164
  continue;
88
165
  }
89
- if (char === "[") {
90
- // An index may follow a key ("items[0]") or another index ("matrix[0][1]"),
91
- // but never open the expression ("[0].x" has no root).
92
- if (current.trim()) {
93
- if (!flush())
166
+ if (char !== "[")
167
+ return null;
168
+ index += 1;
169
+ while (trimmed[index] === " ")
170
+ index += 1;
171
+ let segment = null;
172
+ if (trimmed[index] === "\"") {
173
+ const start = index;
174
+ index += 1;
175
+ let escaped = false;
176
+ let closed = false;
177
+ while (index < trimmed.length) {
178
+ const next = trimmed[index] ?? "";
179
+ if (escaped)
180
+ escaped = false;
181
+ else if (next === "\\")
182
+ escaped = true;
183
+ else if (next === "\"") {
184
+ index += 1;
185
+ closed = true;
186
+ break;
187
+ }
188
+ index += 1;
189
+ }
190
+ if (!closed)
191
+ return null;
192
+ try {
193
+ const decoded = JSON.parse(trimmed.slice(start, index));
194
+ if (typeof decoded !== "string")
94
195
  return null;
196
+ segment = decoded;
95
197
  }
96
- else if (segments.length === 0) {
198
+ catch {
97
199
  return null;
98
200
  }
99
- const close = trimmed.indexOf("]", index);
100
- if (close < 0)
201
+ }
202
+ else {
203
+ const start = index;
204
+ while (index < trimmed.length && trimmed[index] !== "]")
205
+ index += 1;
206
+ const numeric = trimmed.slice(start, index).trim();
207
+ if (!/^(0|[1-9]\d*)$/.test(numeric))
101
208
  return null;
102
- const inner = trimmed.slice(index + 1, close).trim();
103
- const quoted = readQuotedSegment(inner);
104
- if (quoted !== null)
105
- segments.push(quoted);
106
- else if (/^\d+$/.test(inner))
107
- segments.push(inner);
108
- else
209
+ const parsed = Number(numeric);
210
+ // ECMAScript array indices stop before 2^32-1. Larger numeric object keys
211
+ // remain addressable through JSON-quoted property syntax.
212
+ if (!Number.isSafeInteger(parsed) || parsed >= 0xFFFF_FFFF)
109
213
  return null;
110
- index = close + 1;
111
- if (trimmed[index] === ".")
112
- index += 1;
113
- continue;
214
+ segment = numeric;
114
215
  }
115
- if (char === "]")
216
+ while (trimmed[index] === " ")
217
+ index += 1;
218
+ if (trimmed[index] !== "]" || segment === null || !push(segment, true))
116
219
  return null;
117
- current += char;
118
220
  index += 1;
221
+ // Prevent adjacency such as `a[0]b`, which the old parser accidentally
222
+ // treated as another dotted segment.
223
+ if (index < trimmed.length && trimmed[index] !== "." && trimmed[index] !== "[")
224
+ return null;
119
225
  }
120
- if (current.trim() && !flush())
121
- return null;
122
226
  if (segments.length === 0)
123
227
  return null;
124
- if (segments.some((segment) => BLOCKED_PATH_SEGMENTS.has(segment)))
125
- return null;
126
228
  return segments;
127
229
  }
128
- function readQuotedSegment(inner) {
129
- if (inner.length < 2)
130
- return null;
131
- const quote = inner[0];
132
- if ((quote !== "\"" && quote !== "'") || !inner.endsWith(quote))
230
+ /** Canonical path formatter: dots for simple keys, JSON brackets for every other key. */
231
+ export function formatScopePath(segments) {
232
+ const [root, ...rest] = segments;
233
+ if (!root || /[.\[\]]/.test(root) || root !== root.trim() || BLOCKED_PATH_SEGMENTS.has(root)) {
133
234
  return null;
134
- const value = inner.slice(1, -1);
135
- return value.length > 0 ? value : null;
235
+ }
236
+ let path = root;
237
+ for (const segment of rest) {
238
+ if (BLOCKED_PATH_SEGMENTS.has(segment))
239
+ return null;
240
+ if (/^(0|[1-9]\d*)$/.test(segment) && Number(segment) < 0xFFFF_FFFF) {
241
+ path += `[${segment}]`;
242
+ }
243
+ else if (/^[A-Za-z_$][A-Za-z0-9_$-]*$/.test(segment)) {
244
+ path += `.${segment}`;
245
+ }
246
+ else {
247
+ path += `[${JSON.stringify(segment)}]`;
248
+ }
249
+ }
250
+ return path;
136
251
  }
137
252
  /** Read one segment off a container. Own properties only; never throws. */
138
253
  function readSegment(container, segment) {
@@ -308,8 +423,12 @@ export function resolveValueRef(ref, scope) {
308
423
  : ref.value;
309
424
  case "template":
310
425
  return renderTemplate(ref.value, scope);
311
- case "ref":
312
- return resolvePath(ref.path, scope);
426
+ case "ref": {
427
+ const resolved = resolvePath(ref.path, scope);
428
+ return ref.coerce === undefined
429
+ ? resolved
430
+ : coerceWorkflowValue(resolved, ref.coerce);
431
+ }
313
432
  case "formula":
314
433
  return evaluateFormulaInScope(ref.expression, formulaScopeFor(scope), { paths: true });
315
434
  case "context_profile":
@@ -322,6 +441,73 @@ export function resolveValueRef(ref, scope) {
322
441
  }
323
442
  }
324
443
  }
444
+ /**
445
+ * Resolve a binding that is about to cross an execution boundary.
446
+ *
447
+ * Conditions and optional control-flow refs deliberately keep total lookup
448
+ * semantics: a missing path is `undefined`, so `is_empty` remains useful. Tool
449
+ * requests are different — silently handing `undefined` to a provider makes a
450
+ * stale mapping look like a provider bug. Call this stricter adapter at that
451
+ * boundary so the failure names the mapped path before any provider executes.
452
+ */
453
+ export function resolveRequiredValueRef(ref, scope) {
454
+ const missingToken = firstMissingScopeToken(ref, scope);
455
+ if (missingToken !== null)
456
+ throw new WorkflowValueRefUnresolvedError(missingToken);
457
+ const resolved = resolveValueRef(ref, scope);
458
+ if (resolved === undefined) {
459
+ const path = ref.type === "ref"
460
+ ? ref.path
461
+ : ref.type === "context_profile"
462
+ ? `company_profile${ref.path ? `.${ref.path}` : ""}`
463
+ : ref.type === "context_asset"
464
+ ? `knowledge.${ref.assetId}${ref.path ? `.${ref.path}` : ""}`
465
+ : "computed mapping";
466
+ throw new WorkflowValueRefUnresolvedError(path);
467
+ }
468
+ return resolved;
469
+ }
470
+ /** Find the first Oxygen-owned token that the current run scope cannot satisfy. */
471
+ function firstMissingScopeToken(ref, scope) {
472
+ if (ref.type === "ref")
473
+ return resolvePath(ref.path, scope) === undefined ? ref.path : null;
474
+ if (ref.type === "template")
475
+ return firstMissingTokenInValue(ref.value, scope, 0, true);
476
+ if (ref.type === "literal" && isStructured(ref.value)) {
477
+ return firstMissingTokenInValue(ref.value, scope, 0, false);
478
+ }
479
+ return null;
480
+ }
481
+ function firstMissingTokenInValue(value, scope, depth, everyTokenIsOurs) {
482
+ if (depth > 12)
483
+ return null;
484
+ if (typeof value === "string") {
485
+ for (const match of value.matchAll(/\{\{([^{}]*)\}\}/g)) {
486
+ const path = (match[1] ?? "").trim();
487
+ if (!path || (!everyTokenIsOurs && !isScopeToken(path)))
488
+ continue;
489
+ if (resolvePath(path, scope) === undefined)
490
+ return path;
491
+ }
492
+ return null;
493
+ }
494
+ if (Array.isArray(value)) {
495
+ for (const entry of value) {
496
+ const missing = firstMissingTokenInValue(entry, scope, depth + 1, everyTokenIsOurs);
497
+ if (missing !== null)
498
+ return missing;
499
+ }
500
+ return null;
501
+ }
502
+ if (isStructured(value)) {
503
+ for (const entry of Object.values(value)) {
504
+ const missing = firstMissingTokenInValue(entry, scope, depth + 1, everyTokenIsOurs);
505
+ if (missing !== null)
506
+ return missing;
507
+ }
508
+ }
509
+ return null;
510
+ }
325
511
  /**
326
512
  * Bind the formula language's identifiers to the run scope.
327
513
  *
@@ -643,6 +829,85 @@ export function referencedNodeIds(ref) {
643
829
  collectNodeIds(ref, ids);
644
830
  return [...new Set(ids)];
645
831
  }
832
+ /** Exact rooted scope paths a ref/condition can read, excluding ambiguous bare formula names. */
833
+ export function referencedScopePaths(ref) {
834
+ const paths = [];
835
+ collectScopePaths(ref, paths, 0);
836
+ return [...new Set(paths)];
837
+ }
838
+ function collectScopePaths(entry, into, depth) {
839
+ if (depth > 12)
840
+ return;
841
+ switch (entry.type) {
842
+ case "group":
843
+ for (const child of entry.children)
844
+ collectScopePaths(child, into, depth + 1);
845
+ return;
846
+ case "compare":
847
+ collectScopePaths(entry.left, into, depth + 1);
848
+ if (entry.right !== undefined)
849
+ collectScopePaths(entry.right, into, depth + 1);
850
+ return;
851
+ case "ref":
852
+ if (isRootedScopePath(entry.path))
853
+ into.push(entry.path);
854
+ return;
855
+ case "template":
856
+ collectTemplateScopePaths(entry.value, into);
857
+ return;
858
+ case "literal":
859
+ if (isStructured(entry.value))
860
+ collectStructuredScopePaths(entry.value, into, depth + 1);
861
+ return;
862
+ case "formula":
863
+ try {
864
+ for (const identifier of analyzeFormulaDependencies(entry.expression, { paths: true }).identifiers) {
865
+ if ((identifier.includes(".") || identifier.includes("[")) && isRootedScopePath(identifier)) {
866
+ into.push(identifier);
867
+ }
868
+ }
869
+ }
870
+ catch {
871
+ // Syntax validation owns the parse error; it has no reliable paths.
872
+ }
873
+ return;
874
+ default:
875
+ return;
876
+ }
877
+ }
878
+ function isRootedScopePath(path) {
879
+ const segments = parseScopePath(path);
880
+ return segments !== null && SCOPE_PATH_ROOTS.has(segments[0] ?? "");
881
+ }
882
+ function collectTemplateScopePaths(value, into) {
883
+ for (const match of value.matchAll(/\{\{([^{}]*)\}\}/g)) {
884
+ const path = (match[1] ?? "").trim();
885
+ if (path && isRootedScopePath(path))
886
+ into.push(path);
887
+ }
888
+ }
889
+ function collectStructuredScopePaths(value, into, depth) {
890
+ if (depth > 12)
891
+ return;
892
+ if (typeof value === "string") {
893
+ for (const match of value.matchAll(/\{\{([^{}]*)\}\}/g)) {
894
+ const path = (match[1] ?? "").trim();
895
+ if (isScopeToken(path) && isRootedScopePath(path))
896
+ into.push(path);
897
+ }
898
+ return;
899
+ }
900
+ if (Array.isArray(value)) {
901
+ for (const entry of value)
902
+ collectStructuredScopePaths(entry, into, depth + 1);
903
+ return;
904
+ }
905
+ if (isStructured(value)) {
906
+ for (const entry of Object.values(value)) {
907
+ collectStructuredScopePaths(entry, into, depth + 1);
908
+ }
909
+ }
910
+ }
646
911
  function collectNodeIds(entry, into) {
647
912
  switch (entry.type) {
648
913
  case "group":
@@ -18,3 +18,4 @@ export * from "./expression.js";
18
18
  export * from "./params.js";
19
19
  export * from "./remap.js";
20
20
  export * from "./manifest-schema.js";
21
+ export * from "./mapping.js";
@@ -18,3 +18,4 @@ export * from "./expression.js";
18
18
  export * from "./params.js";
19
19
  export * from "./remap.js";
20
20
  export * from "./manifest-schema.js";
21
+ export * from "./mapping.js";
@@ -9,10 +9,10 @@
9
9
  import * as vm from "node:vm";
10
10
  import { validateFormulaExpression } from "@oxygen/formula";
11
11
  import { isWorkflowToolRejected, lintRecipeManifest, lintWorkflowCodeSourceSafety, validateOptionalMaxCredits, validatePureFunctionSource, validateTrigger, } from "../index.js";
12
- import { UNARY_COMPARE_OPS, WORKFLOW_COMPARE_OPS, compileConditionPattern, parseScopePath, SCOPE_PATH_ROOTS, } from "./expression.js";
12
+ import { UNARY_COMPARE_OPS, WORKFLOW_COMPARE_OPS, compileConditionPattern, parseScopePath, referencedScopePaths, SCOPE_PATH_ROOTS, } from "./expression.js";
13
13
  import { missingToolParams, unknownToolParams } from "./params.js";
14
- import { danglingEdges, findIllegalCycles, loopBodyNodeIds, reachableNodeIds, workflowCodeSandboxInvocationUpperBound, } from "./topology.js";
15
- import { MAX_WORKFLOW_LOOP_ITERATIONS, MAX_WORKFLOW_NODE_RETRY_ATTEMPTS, MAX_WORKFLOW_NODE_RETRY_WAIT_SECONDS, RESERVED_EDGE_HANDLES, RESERVED_NODE_IDS, isWorkflowCodeFieldName, WORKFLOW_CODE_V1_LANGUAGE, WORKFLOW_CODE_V1_MAX_INPUT_BYTES, WORKFLOW_CODE_V1_MAX_INPUTS, WORKFLOW_CODE_V1_MAX_OUTPUT_BYTES, WORKFLOW_CODE_V1_MAX_SCHEMA_BYTES, WORKFLOW_CODE_V1_MAX_SCHEMA_DEPTH, WORKFLOW_CODE_V1_MAX_SCHEMA_NODES, WORKFLOW_CODE_V1_MAX_SCHEMA_PROPERTIES, WORKFLOW_CODE_V1_MAX_SANDBOX_INVOCATIONS_PER_RUN, WORKFLOW_CODE_V1_MAX_SOURCE_BYTES, WORKFLOW_CODE_V1_MAX_TIMEOUT_MS, WORKFLOW_CODE_V1_MIN_INPUT_BYTES, WORKFLOW_CODE_V1_MIN_OUTPUT_BYTES, WORKFLOW_CODE_V1_MIN_TIMEOUT_MS, WORKFLOW_CODE_V1_RUNTIME, WORKFLOW_CODE_V1_VERSION, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, LOOP_BODY_HANDLE, LOOP_DONE_HANDLE, } from "./types.js";
14
+ import { danglingEdges, dominatingNodeIds, findIllegalCycles, loopBodyNodeIds, reachableNodeIds, workflowCodeSandboxInvocationUpperBound, } from "./topology.js";
15
+ import { MAX_WORKFLOW_LOOP_ITERATIONS, MAX_WORKFLOW_NODE_RETRY_ATTEMPTS, MAX_WORKFLOW_NODE_RETRY_WAIT_SECONDS, RESERVED_EDGE_HANDLES, RESERVED_NODE_IDS, isWorkflowCodeFieldName, WORKFLOW_CODE_V1_LANGUAGE, WORKFLOW_CODE_V1_MAX_INPUT_BYTES, WORKFLOW_CODE_V1_MAX_INPUTS, WORKFLOW_CODE_V1_MAX_OUTPUT_BYTES, WORKFLOW_CODE_V1_MAX_SCHEMA_BYTES, WORKFLOW_CODE_V1_MAX_SCHEMA_DEPTH, WORKFLOW_CODE_V1_MAX_SCHEMA_NODES, WORKFLOW_CODE_V1_MAX_SCHEMA_PROPERTIES, WORKFLOW_CODE_V1_MAX_SANDBOX_INVOCATIONS_PER_RUN, WORKFLOW_CODE_V1_MAX_SOURCE_BYTES, WORKFLOW_CODE_V1_MAX_TIMEOUT_MS, WORKFLOW_CODE_V1_MIN_INPUT_BYTES, WORKFLOW_CODE_V1_MIN_OUTPUT_BYTES, WORKFLOW_CODE_V1_MIN_TIMEOUT_MS, WORKFLOW_CODE_V1_RUNTIME, WORKFLOW_CODE_V1_VERSION, WORKFLOW_VALUE_COERCIONS, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, LOOP_BODY_HANDLE, LOOP_DONE_HANDLE, } from "./types.js";
16
16
  const NODE_KINDS = new Set([
17
17
  "trigger",
18
18
  "tool",
@@ -125,7 +125,12 @@ export function lintWorkflowGraphManifest(value, options = {}) {
125
125
  // the element shapes are known good. Reporting "unreachable" on a manifest
126
126
  // whose nodes are half-formed would bury the real error under cascades.
127
127
  if (nodesSound && edgesSound) {
128
- lintGraphTopology(value, add);
128
+ const graph = value;
129
+ // Availability dereferences typed ref-bearing fields. Run it only once the
130
+ // focused validators found no malformed node payload to cascade through.
131
+ if (issues.length === 0)
132
+ lintGraphReferenceAvailability(graph, add);
133
+ lintGraphTopology(graph, add);
129
134
  }
130
135
  return { ok: issues.length === 0, issues };
131
136
  }
@@ -265,15 +270,16 @@ function lintNodeIdentity(node, path, seenIds, add) {
265
270
  * A node's own retry budget.
266
271
  *
267
272
  * Bounded on both axes because both are a run's wall-clock: attempts multiply a
268
- * provider call and the wait parks the run. Note what is NOT checked here —
269
- * whether this node's effect may be retried at all. That decision belongs to the
270
- * worker's isRetryableWorkflowStepError, which the budget is consulted after; a
271
- * lint rule refusing `retry` on a live external write would only be a second,
272
- * drifting opinion about the same safety rule.
273
+ * provider call and the wait parks the run. This setting can only NARROW the
274
+ * run's retry policy; it never grants retry authority. Tool/approval-specific
275
+ * rules below reject configurations the worker categorically cannot honour.
273
276
  */
274
277
  function lintNodeRetry(node, path, add) {
275
278
  if (node.retry === undefined)
276
279
  return;
280
+ if (node.continue_on_error === true) {
281
+ add(path, "ambiguous_node_resilience", "A node cannot combine continue_on_error with a retry limit. Choose one failure policy.");
282
+ }
277
283
  if (!isRecord(node.retry)) {
278
284
  add(`${path}.retry`, "invalid_node_retry", "Node retry must be { max_attempts: number, wait_seconds?: number }.");
279
285
  return;
@@ -769,7 +775,8 @@ function lintToolNode(node, path, add, scope, options) {
769
775
  add(`${path}.params.${unknown}`, "unknown_tool_param", `${node.tool} has no input called '${unknown}'. Remove it or check the spelling.`);
770
776
  }
771
777
  for (const missing of missingToolParams(shared)) {
772
- add(`${path}.params`, "incomplete_tool_params", `${node.tool} still needs ${missing}.`);
778
+ const exactField = fields.find((field) => (field.title ?? field.name) === missing);
779
+ add(exactField ? `${path}.params.${exactField.name}` : `${path}.params`, "incomplete_tool_params", `${node.tool} still needs ${missing}.`);
773
780
  }
774
781
  }
775
782
  }
@@ -796,6 +803,12 @@ function lintToolNode(node, path, add, scope, options) {
796
803
  if (node.mode !== undefined && (typeof node.mode !== "string" || !STEP_MODES.has(node.mode))) {
797
804
  add(`${path}.mode`, "invalid_step_mode", "Node mode is invalid.");
798
805
  }
806
+ else if (node.mode !== undefined && (node.effect !== "external_write" || node.mode !== "dry_run")) {
807
+ add(`${path}.mode`, "unsupported_step_mode", "A tool-node mode is only supported as 'dry_run' on an external write.");
808
+ }
809
+ if (node.effect === "external_write" && node.retry !== undefined) {
810
+ add(`${path}.retry`, "unsafe_external_write_retry", "External writes cannot have a node retry policy because an ambiguous failure may already have landed.");
811
+ }
799
812
  if (node.connection_id !== undefined && !isNonEmptyString(node.connection_id)) {
800
813
  add(`${path}.connection_id`, "invalid_connection_id", "Tool node connection_id must be a non-empty string.");
801
814
  }
@@ -896,6 +909,15 @@ function lintSetNode(node, path, add, scope) {
896
909
  });
897
910
  }
898
911
  function lintApprovalNode(node, path, add, scope) {
912
+ if (node.disabled === true) {
913
+ add(`${path}.disabled`, "unsafe_approval_behavior", "An approval gate cannot be skipped. Remove the node explicitly if the gate is no longer required.");
914
+ }
915
+ if (node.continue_on_error === true) {
916
+ add(`${path}.continue_on_error`, "unsafe_approval_behavior", "An approval gate cannot continue after rejection or expiry.");
917
+ }
918
+ if (node.retry !== undefined) {
919
+ add(`${path}.retry`, "unsafe_approval_behavior", "An approval gate cannot use a node retry policy.");
920
+ }
899
921
  // A blank `reason` is deliberately NOT refused here. It leaves the approver
900
922
  // guessing, but it is legal and every gate saved to date is missing one — so
901
923
  // the nudge lives in the editor's warning layer (graphWarnings in
@@ -1019,9 +1041,116 @@ function lintEdgeHandle(input, add) {
1019
1041
  add(`${path}.source_handle`, "invalid_switch_case", `Edge handle '${handle}' does not name a case on switch node '${input.source}'.`);
1020
1042
  }
1021
1043
  }
1022
- // ---------------------------------------------------------------------------
1023
- // Graph-level rules
1024
- // ---------------------------------------------------------------------------
1044
+ function conditionValueRefs(condition, path) {
1045
+ if (condition.type === "group") {
1046
+ if (!Array.isArray(condition.children))
1047
+ return [];
1048
+ return condition.children.flatMap((child, index) => (conditionValueRefs(child, `${path}.children.${index}`)));
1049
+ }
1050
+ return [
1051
+ { ref: condition.left, path: `${path}.left` },
1052
+ ...(condition.right === undefined ? [] : [{ ref: condition.right, path: `${path}.right` }]),
1053
+ ];
1054
+ }
1055
+ /** Every executable value binding on one structurally valid node. */
1056
+ function graphNodeValueRefs(node, path) {
1057
+ switch (node.kind) {
1058
+ case "tool":
1059
+ return Object.entries(node.params).map(([name, ref]) => ({
1060
+ ref,
1061
+ path: `${path}.params.${name}`,
1062
+ }));
1063
+ case "filter":
1064
+ return conditionValueRefs(node.when, `${path}.when`);
1065
+ case "switch":
1066
+ return node.cases.flatMap((branch, index) => (conditionValueRefs(branch.when, `${path}.cases.${index}.when`)));
1067
+ case "loop":
1068
+ return [{ ref: node.over, path: `${path}.over` }];
1069
+ case "set":
1070
+ return node.fields.map((field, index) => ({
1071
+ ref: field.value,
1072
+ path: `${path}.fields.${index}.value`,
1073
+ }));
1074
+ case "wait":
1075
+ return node.until === undefined ? [] : [{ ref: node.until, path: `${path}.until` }];
1076
+ case "approval":
1077
+ return [
1078
+ ...(node.reason === undefined ? [] : [{ ref: node.reason, path: `${path}.reason` }]),
1079
+ ...Object.entries(node.request ?? {}).map(([name, ref]) => ({
1080
+ ref,
1081
+ path: `${path}.request.${name}`,
1082
+ })),
1083
+ ];
1084
+ case "code":
1085
+ if (node.code) {
1086
+ return Object.entries(node.code.inputs).map(([name, ref]) => ({
1087
+ ref,
1088
+ path: `${path}.code.inputs.${name}`,
1089
+ }));
1090
+ }
1091
+ return Object.entries(node.configuration ?? {}).map(([name, ref]) => ({
1092
+ ref,
1093
+ path: `${path}.configuration.${name}`,
1094
+ }));
1095
+ case "workflow":
1096
+ return Object.entries(node.input).map(([name, ref]) => ({
1097
+ ref,
1098
+ path: `${path}.input.${name}`,
1099
+ }));
1100
+ default:
1101
+ return [];
1102
+ }
1103
+ }
1104
+ /**
1105
+ * Ref availability is a graph invariant, not picker advice.
1106
+ *
1107
+ * A `steps.x` source must dominate its consumer and must not be disabled. A
1108
+ * `loop.x.item` source exists only inside that loop's body. This rejects the
1109
+ * two manifests that used to save successfully and then fail only on some
1110
+ * deliveries: a downstream/conditional branch ref and a post-loop item ref.
1111
+ */
1112
+ function lintGraphReferenceAvailability(graph, add) {
1113
+ const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
1114
+ const reachable = reachableNodeIds(graph);
1115
+ const loopBodies = new Map(graph.nodes
1116
+ .filter((node) => node.kind === "loop")
1117
+ .map((node) => [node.id, loopBodyNodeIds(graph, node.id)]));
1118
+ graph.nodes.forEach((node, index) => {
1119
+ // Unwired draft nodes already get one actionable topology issue. Their
1120
+ // internal ordering becomes meaningful only after the author connects them.
1121
+ if (!reachable.has(node.id))
1122
+ return;
1123
+ const dominators = dominatingNodeIds(graph, node.id);
1124
+ dominators.delete(node.id);
1125
+ for (const located of graphNodeValueRefs(node, `$.nodes.${index}`)) {
1126
+ for (const scopePath of referencedScopePaths(located.ref)) {
1127
+ const segments = parseScopePath(scopePath);
1128
+ const root = segments?.[0];
1129
+ const sourceId = segments?.[1];
1130
+ if (!sourceId || (root !== "steps" && root !== "loop"))
1131
+ continue;
1132
+ const source = nodes.get(sourceId);
1133
+ if (!source)
1134
+ continue; // Unknown ids are reported by validateScopeNodeId.
1135
+ if (root === "loop") {
1136
+ if (source.kind !== "loop")
1137
+ continue; // The focused ref validator reports this.
1138
+ if (!(loopBodies.get(sourceId)?.has(node.id) ?? false)) {
1139
+ add(located.path, "unavailable_loop_ref", `Path '${scopePath}' reads loop '${sourceId}' outside its body, where no current item exists.`);
1140
+ }
1141
+ continue;
1142
+ }
1143
+ if (source.disabled === true) {
1144
+ add(located.path, "disabled_node_ref", `Path '${scopePath}' reads disabled node '${sourceId}', which writes no output.`);
1145
+ continue;
1146
+ }
1147
+ if (!dominators.has(sourceId)) {
1148
+ add(located.path, "unavailable_node_ref", `Path '${scopePath}' reads node '${sourceId}', but that node is not guaranteed to run before '${node.id}'.`);
1149
+ }
1150
+ }
1151
+ }
1152
+ });
1153
+ }
1025
1154
  function lintGraphTopology(graph, add) {
1026
1155
  const edgeIndex = new Map();
1027
1156
  graph.edges.forEach((edge, index) => {
@@ -1099,6 +1228,9 @@ value, path, add, scope) {
1099
1228
  add(path, "invalid_value_ref", "Value ref must be an object.");
1100
1229
  return;
1101
1230
  }
1231
+ if (value.coerce !== undefined && value.type !== "ref") {
1232
+ add(`${path}.coerce`, "invalid_value_coercion", "Only a direct ref mapping may declare a coercion.");
1233
+ }
1102
1234
  switch (value.type) {
1103
1235
  case "literal":
1104
1236
  if (!("value" in value)) {
@@ -1128,6 +1260,10 @@ value, path, add, scope) {
1128
1260
  add(`${path}.path`, "invalid_value_ref", "Ref value ref requires a path.");
1129
1261
  return;
1130
1262
  }
1263
+ if (value.coerce !== undefined
1264
+ && !WORKFLOW_VALUE_COERCIONS.includes(value.coerce)) {
1265
+ add(`${path}.coerce`, "invalid_value_coercion", `Ref coercion must be one of ${WORKFLOW_VALUE_COERCIONS.join(", ")}.`);
1266
+ }
1131
1267
  validateScopePath(value.path, `${path}.path`, add, scope);
1132
1268
  return;
1133
1269
  case "formula":
@@ -1192,12 +1328,10 @@ function validateScopePath(path, issuePath, add, scope) {
1192
1328
  * is strictly worse than the silence this rule removes. The id is the part that
1193
1329
  * is always knowable, and it is where a typo is unrecoverable.
1194
1330
  *
1195
- * Upstream-ness is deliberately NOT checked either. A ref to a node that has not
1196
- * run resolves to `undefined` just as surely, but "has run" is not a static
1197
- * property of the graph: a body node's outputs survive its loop under a keyed
1198
- * step id, a `fallthrough` switch runs several branches, and an author normally
1199
- * configures a node before wiring it in. Every one of those is a legitimate
1200
- * graph that a reachability rule would refuse.
1331
+ * Availability is checked separately after nodes and edges are structurally
1332
+ * sound. That graph-level pass uses dominators (not mere ancestry), so it can
1333
+ * reject disabled/downstream/conditional sources without guessing here while a
1334
+ * half-authored edge list is still being validated.
1201
1335
  */
1202
1336
  function validateScopeNodeId(path, segments, issuePath, add, scope) {
1203
1337
  if (scope.validateNodeIds === false)