@oxygen-agent/cli 1.739.0 → 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.
- package/README.md +1 -1
- package/dist/command-manifest.js +6 -1
- package/dist/index.js +81 -31
- package/node_modules/@oxygen/shared/dist/index.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/index.js +1 -1
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +8 -2
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/version.js +9 -1
- package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +29 -1
- package/node_modules/@oxygen/workflows/dist/graph/expression.js +307 -42
- package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +1 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.js +1 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +153 -19
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +92 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +4 -0
- package/node_modules/@oxygen/workflows/dist/graph/mapping.d.ts +79 -0
- package/node_modules/@oxygen/workflows/dist/graph/mapping.js +436 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +9 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.js +52 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +11 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.js +14 -0
- package/package.json +1 -1
|
@@ -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
|
|
74
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
198
|
+
catch {
|
|
97
199
|
return null;
|
|
98
200
|
}
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
if (trimmed[index] === ".")
|
|
112
|
-
index += 1;
|
|
113
|
-
continue;
|
|
214
|
+
segment = numeric;
|
|
114
215
|
}
|
|
115
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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":
|
|
@@ -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
|
-
|
|
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.
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
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)
|