@oxygen-agent/cli 1.377.3 → 1.591.1

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 (96) hide show
  1. package/README.md +1 -1
  2. package/dist/column-run-notices.d.ts +11 -0
  3. package/dist/column-run-notices.js +37 -0
  4. package/dist/command-manifest.js +13 -8
  5. package/dist/help.js +79 -16
  6. package/dist/index.js +3812 -460
  7. package/dist/skills.js +106 -1
  8. package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
  9. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  10. package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
  11. package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
  12. package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
  13. package/node_modules/@oxygen/formula/dist/expression.js +428 -0
  14. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
  15. package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
  16. package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
  17. package/node_modules/@oxygen/formula/dist/index.js +17 -0
  18. package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
  19. package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
  20. package/node_modules/@oxygen/formula/package.json +26 -0
  21. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
  22. package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
  23. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
  24. package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
  25. package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -5
  26. package/node_modules/@oxygen/shared/dist/billing.js +185 -8
  27. package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
  28. package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
  29. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  30. package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
  31. package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
  32. package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
  33. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
  34. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
  35. package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
  36. package/node_modules/@oxygen/shared/dist/directory.js +1 -0
  37. package/node_modules/@oxygen/shared/dist/file-import.js +58 -11
  38. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +15 -0
  39. package/node_modules/@oxygen/shared/dist/hosted-ai.js +19 -0
  40. package/node_modules/@oxygen/shared/dist/index.d.ts +9 -0
  41. package/node_modules/@oxygen/shared/dist/index.js +9 -0
  42. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
  43. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
  44. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
  45. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
  46. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
  47. package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
  48. package/node_modules/@oxygen/shared/dist/log.js +41 -2
  49. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
  50. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
  51. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
  52. package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
  53. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
  54. package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
  55. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
  56. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
  57. package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
  58. package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
  59. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
  60. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  61. package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
  62. package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
  63. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
  64. package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
  65. package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
  66. package/node_modules/@oxygen/shared/dist/tags.js +122 -6
  67. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  68. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  69. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
  70. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
  71. package/node_modules/@oxygen/shared/package.json +95 -0
  72. package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
  73. package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
  74. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
  75. package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
  76. package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
  77. package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
  78. package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
  79. package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
  80. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
  81. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
  82. package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
  83. package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
  84. package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
  85. package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
  86. package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
  87. package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
  88. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
  89. package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
  90. package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
  91. package/node_modules/@oxygen/workflows/dist/index.js +179 -13
  92. package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
  93. package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
  94. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
  95. package/node_modules/@oxygen/workflows/package.json +4 -0
  96. package/package.json +7 -5
@@ -0,0 +1,700 @@
1
+ // Runtime binding for oxygen-workflows-v2: the one place that turns a declarative
2
+ // WorkflowValueRef or WorkflowCondition into a value, shared by the worker
3
+ // scheduler, the linter, and the visual editor's preview pane.
4
+ //
5
+ // This module must stay BROWSER-SAFE. Its only imports are @oxygen/formula (itself
6
+ // dependency-free and node-free) and ./types.js — never ../index.js, which pulls
7
+ // node:vm and node:crypto.
8
+ //
9
+ // Two invariants hold everywhere below:
10
+ // - Reading is total. A missing path, a wrong-typed container, or a malformed
11
+ // path expression resolves to `undefined`; it never throws. A half-drawn graph
12
+ // and a row with a null field must not crash a run.
13
+ // - Reading is prototype-safe. Path segments are matched against own properties
14
+ // only, and the segments that reach JavaScript object internals
15
+ // (__proto__ / constructor / prototype) are rejected outright.
16
+ import { analyzeFormulaDependencies, evaluateFormulaInScope, formulaExpressionError, } from "@oxygen/formula";
17
+ /** The roots a `ref` path may start from. Anything else is not a scope path. */
18
+ export const SCOPE_PATH_ROOTS = new Set(["trigger", "steps", "loop"]);
19
+ /**
20
+ * Segments that must never be traversed. They resolve to JavaScript object
21
+ * internals rather than workflow data, so allowing them would let an authored
22
+ * path reach (and, in a future writer, mutate) Object.prototype.
23
+ */
24
+ const BLOCKED_PATH_SEGMENTS = new Set([
25
+ "__proto__",
26
+ "constructor",
27
+ "prototype",
28
+ ]);
29
+ export const WORKFLOW_COMPARE_OPS = new Set([
30
+ "eq",
31
+ "neq",
32
+ "gt",
33
+ "gte",
34
+ "lt",
35
+ "lte",
36
+ "contains",
37
+ "not_contains",
38
+ "starts_with",
39
+ "ends_with",
40
+ "is_empty",
41
+ "is_not_empty",
42
+ "in",
43
+ "not_in",
44
+ "matches",
45
+ ]);
46
+ /** Operators that take no right operand. */
47
+ export const UNARY_COMPARE_OPS = new Set([
48
+ "is_empty",
49
+ "is_not_empty",
50
+ ]);
51
+ // ---------------------------------------------------------------------------
52
+ // Paths
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * Split a path expression into its segments, or return null when the expression
56
+ * is malformed or reaches a blocked segment.
57
+ *
58
+ * Grammar: dotted keys with optional index suffixes —
59
+ * steps.find_person.output.emails[0].address
60
+ * trigger.input["odd.key"]
61
+ *
62
+ * Returning null (rather than throwing) is what lets `resolvePath` stay total;
63
+ * the linter uses the same parser to reject the path at authoring time, so a
64
+ * typo surfaces in lint rather than as a silently empty value at run time.
65
+ */
66
+ export function parseScopePath(path) {
67
+ const trimmed = path.trim();
68
+ if (!trimmed || trimmed.startsWith(".") || trimmed.endsWith("."))
69
+ return null;
70
+ const segments = [];
71
+ let current = "";
72
+ let index = 0;
73
+ const flush = () => {
74
+ const segment = current.trim();
75
+ current = "";
76
+ if (!segment)
77
+ return false;
78
+ segments.push(segment);
79
+ return true;
80
+ };
81
+ while (index < trimmed.length) {
82
+ const char = trimmed[index] ?? "";
83
+ if (char === ".") {
84
+ if (!flush())
85
+ return null;
86
+ index += 1;
87
+ continue;
88
+ }
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())
94
+ return null;
95
+ }
96
+ else if (segments.length === 0) {
97
+ return null;
98
+ }
99
+ const close = trimmed.indexOf("]", index);
100
+ if (close < 0)
101
+ 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
109
+ return null;
110
+ index = close + 1;
111
+ if (trimmed[index] === ".")
112
+ index += 1;
113
+ continue;
114
+ }
115
+ if (char === "]")
116
+ return null;
117
+ current += char;
118
+ index += 1;
119
+ }
120
+ if (current.trim() && !flush())
121
+ return null;
122
+ if (segments.length === 0)
123
+ return null;
124
+ if (segments.some((segment) => BLOCKED_PATH_SEGMENTS.has(segment)))
125
+ return null;
126
+ return segments;
127
+ }
128
+ function readQuotedSegment(inner) {
129
+ if (inner.length < 2)
130
+ return null;
131
+ const quote = inner[0];
132
+ if ((quote !== "\"" && quote !== "'") || !inner.endsWith(quote))
133
+ return null;
134
+ const value = inner.slice(1, -1);
135
+ return value.length > 0 ? value : null;
136
+ }
137
+ /** Read one segment off a container. Own properties only; never throws. */
138
+ function readSegment(container, segment) {
139
+ if (container === null || container === undefined)
140
+ return undefined;
141
+ // Redundant with parseScopePath's rejection, kept as defence in depth: this is
142
+ // the only function that dereferences an authored key.
143
+ if (BLOCKED_PATH_SEGMENTS.has(segment))
144
+ return undefined;
145
+ if (Array.isArray(container)) {
146
+ if (segment === "length")
147
+ return container.length;
148
+ if (!/^\d+$/.test(segment))
149
+ return undefined;
150
+ return container[Number(segment)];
151
+ }
152
+ if (typeof container === "string") {
153
+ return segment === "length" ? container.length : undefined;
154
+ }
155
+ if (typeof container !== "object")
156
+ return undefined;
157
+ if (!Object.prototype.hasOwnProperty.call(container, segment))
158
+ return undefined;
159
+ return container[segment];
160
+ }
161
+ function walkPath(root, path) {
162
+ const segments = parseScopePath(path);
163
+ if (!segments)
164
+ return undefined;
165
+ let current = root;
166
+ for (const segment of segments) {
167
+ current = readSegment(current, segment);
168
+ if (current === undefined)
169
+ return undefined;
170
+ }
171
+ return current;
172
+ }
173
+ /**
174
+ * A null-prototype view of the run scope. Path lookups start here, so even a
175
+ * segment that slipped past the blocklist cannot climb into Object.prototype.
176
+ */
177
+ function scopeRoot(scope) {
178
+ const root = Object.create(null);
179
+ root.trigger = scope.trigger;
180
+ root.steps = scope.steps;
181
+ root.loop = scope.loop;
182
+ return root;
183
+ }
184
+ /** Resolve a dotted (+ `[n]`) scope path. Missing or malformed → undefined. */
185
+ export function resolvePath(path, scope) {
186
+ return walkPath(scopeRoot(scope), path);
187
+ }
188
+ // ---------------------------------------------------------------------------
189
+ // Templates
190
+ // ---------------------------------------------------------------------------
191
+ /**
192
+ * Substitute every `{{ path }}` placeholder. The result is always a string:
193
+ * blanks render as "", scalars as their text, objects/arrays as compact JSON.
194
+ * `{{}}` (no path) is literal text, not a binding.
195
+ */
196
+ export function renderTemplate(template, scope) {
197
+ return template.replace(/\{\{([^{}]*)\}\}/g, (match, rawPath) => {
198
+ const path = rawPath.trim();
199
+ if (!path)
200
+ return match;
201
+ return stringifyTemplateValue(resolvePath(path, scope));
202
+ });
203
+ }
204
+ function stringifyTemplateValue(value) {
205
+ if (value === null || value === undefined)
206
+ return "";
207
+ if (typeof value === "string")
208
+ return value;
209
+ if (typeof value === "number" || typeof value === "boolean")
210
+ return String(value);
211
+ try {
212
+ return JSON.stringify(value) ?? "";
213
+ }
214
+ catch {
215
+ // Cyclic structures: a template is presentation, so render nothing rather
216
+ // than failing the node that happened to reference one.
217
+ return "";
218
+ }
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // Value refs
222
+ // ---------------------------------------------------------------------------
223
+ /**
224
+ * Whether a `{{ ... }}` body is one of OUR scope paths, as opposed to somebody
225
+ * else's templating that happens to share the delimiters.
226
+ *
227
+ * This distinction is the whole safety of deep resolution. Oxygen's own
228
+ * documented merge-tag syntax uses the identical braces — `{{column}}`,
229
+ * `{{column|fallback}}`, spintax `{{RANDOM|a|b|c}}`, `{% if %}` — and those
230
+ * strings travel INSIDE structured parameters: a sequence step's subject and
231
+ * body, an AI column's prompt. Resolving them as scope paths deleted them,
232
+ * because an unresolvable path stringifies to "". A sequence subject of
233
+ * "{{RANDOM|Quick q|One question}} about {{company}}" came out as " about ",
234
+ * i.e. personalization silently stripped from outbound mail.
235
+ *
236
+ * So only a token rooted at `trigger`, `steps` or `loop` is ours. Everything
237
+ * else is passed through untouched for its real consumer to interpret.
238
+ */
239
+ function isScopeToken(body) {
240
+ const path = body.trim();
241
+ if (!path)
242
+ return false;
243
+ const root = path.split(/[.[]/, 1)[0];
244
+ return root !== undefined && SCOPE_PATH_ROOTS.has(root);
245
+ }
246
+ /** Render only OUR tokens in a string, leaving every foreign one verbatim. */
247
+ function renderScopeTokens(value, scope) {
248
+ return value.replace(/\{\{([^{}]*)\}\}/g, (match, rawBody) => (isScopeToken(rawBody)
249
+ ? stringifyTemplateValue(resolvePath(rawBody.trim(), scope))
250
+ : match));
251
+ }
252
+ /** An object or array — the shapes whose interiors are worth walking. */
253
+ function isStructured(value) {
254
+ return typeof value === "object" && value !== null;
255
+ }
256
+ /**
257
+ * Render every `{{ ... }}` token inside a structured literal, preserving shape.
258
+ *
259
+ * A token that is the ENTIRE string resolves to its real value — so
260
+ * `"{{ loop.each.item.score }}"` becomes the number 70, not `"70"`, which matters
261
+ * because a provider that declares a numeric field rejects the string. A token
262
+ * embedded in surrounding text renders as text, exactly as `template` does.
263
+ *
264
+ * Bounded in depth: a hostile or accidental deep structure must not recurse
265
+ * without limit, and no real tool parameter is nested more than a few levels.
266
+ */
267
+ function resolveTokensDeep(value, scope, depth = 0) {
268
+ if (depth > 12)
269
+ return value;
270
+ if (typeof value === "string") {
271
+ const whole = /^\s*\{\{([^{}]*)\}\}\s*$/.exec(value);
272
+ if (whole) {
273
+ const path = (whole[1] ?? "").trim();
274
+ return isScopeToken(path) ? resolvePath(path, scope) : value;
275
+ }
276
+ return value.includes("{{") ? renderScopeTokens(value, scope) : value;
277
+ }
278
+ if (Array.isArray(value)) {
279
+ return value.map((entry) => resolveTokensDeep(entry, scope, depth + 1));
280
+ }
281
+ if (isStructured(value)) {
282
+ const out = {};
283
+ for (const [key, entry] of Object.entries(value)) {
284
+ out[key] = resolveTokensDeep(entry, scope, depth + 1);
285
+ }
286
+ return out;
287
+ }
288
+ return value;
289
+ }
290
+ export function resolveValueRef(ref, scope) {
291
+ switch (ref.type) {
292
+ case "literal":
293
+ // A literal OBJECT or ARRAY may carry `{{ ... }}` tokens inside it, and
294
+ // they resolve. This is what makes a structured parameter bindable at all:
295
+ // `leads: [{ table_row_id: "{{ loop.each.item._row_id }}" }]` is the only
296
+ // way to hand a per-row value to a capability whose input is an array, and
297
+ // the editor renders such a parameter as a JSON editor. Without this the
298
+ // token reached the provider verbatim and the step failed, so the whole
299
+ // shape was unauthorable except by dropping into a `code` node.
300
+ //
301
+ // A top-level literal STRING is deliberately left alone: `template` is the
302
+ // ref type for that, and quietly interpolating a plain string would change
303
+ // what an existing manifest means.
304
+ return isStructured(ref.value)
305
+ ? resolveTokensDeep(ref.value, scope)
306
+ : ref.value;
307
+ case "template":
308
+ return renderTemplate(ref.value, scope);
309
+ case "ref":
310
+ return resolvePath(ref.path, scope);
311
+ case "formula":
312
+ return evaluateFormulaInScope(ref.expression, formulaScopeFor(scope), { paths: true });
313
+ case "context_profile":
314
+ return ref.path === undefined
315
+ ? scope.contextProfile
316
+ : walkPath(scope.contextProfile, ref.path);
317
+ case "context_asset": {
318
+ const asset = readSegment(scope.contextAssets, ref.assetId);
319
+ return ref.path === undefined ? asset : walkPath(asset, ref.path);
320
+ }
321
+ }
322
+ }
323
+ /**
324
+ * Bind the formula language's identifiers to the run scope.
325
+ *
326
+ * Two shapes resolve here, and the DOTTED one is tried first (v1.555.0):
327
+ *
328
+ * steps.enrich.output.email the same scope path a ref or template binding
329
+ * uses — so the three binding kinds finally agree
330
+ * about how to name one value
331
+ * enrich a bare name, looked up in a fixed order:
332
+ * trigger.input.<name> → steps.<name>.output
333
+ * → loop.<name>.item
334
+ *
335
+ * The bare form stays because it is what shipped and what `referencedNodeIds` and
336
+ * remap.ts already understand; dropping it would break every formula written before
337
+ * paths existed.
338
+ *
339
+ * `resolve` stays strict per the FormulaScope contract (an unresolvable
340
+ * identifier throws, so a typo fails loudly instead of evaluating to null);
341
+ * `resolveOptional` is the lenient read cross-row functions use.
342
+ */
343
+ function formulaScopeFor(scope) {
344
+ return {
345
+ resolve(identifier) {
346
+ const found = lookupFormulaIdentifier(scope, identifier);
347
+ if (found.found)
348
+ return found.value;
349
+ throw formulaExpressionError("Unknown identifier in workflow formula.", {
350
+ identifier,
351
+ hint: "Reference a workflow input, a node id, or a loop node id.",
352
+ });
353
+ },
354
+ resolveOptional(identifier) {
355
+ const found = lookupFormulaIdentifier(scope, identifier);
356
+ return found.found ? found.value : undefined;
357
+ },
358
+ };
359
+ }
360
+ function lookupFormulaIdentifier(scope, identifier) {
361
+ // A rooted dotted path is unambiguous, so it wins before any bare-name guessing.
362
+ // resolvePath owns the traversal (including its prototype-pollution refusals), so
363
+ // a formula reads exactly what a `ref` binding with the same path would.
364
+ if (identifier.includes(".") || identifier.includes("[")) {
365
+ const root = identifier.split(/[.[]/, 1)[0];
366
+ if (root !== undefined && SCOPE_PATH_ROOTS.has(root)) {
367
+ const value = resolvePath(identifier, scope);
368
+ return value === undefined ? { found: false } : { found: true, value };
369
+ }
370
+ return { found: false };
371
+ }
372
+ if (BLOCKED_PATH_SEGMENTS.has(identifier))
373
+ return { found: false };
374
+ const input = scope.trigger?.input;
375
+ if (isPlainObject(input) && Object.prototype.hasOwnProperty.call(input, identifier)) {
376
+ return { found: true, value: input[identifier] };
377
+ }
378
+ const node = readSegment(scope.steps, identifier);
379
+ if (node !== undefined)
380
+ return { found: true, value: readSegment(node, "output") };
381
+ const loop = readSegment(scope.loop, identifier);
382
+ if (loop !== undefined)
383
+ return { found: true, value: readSegment(loop, "item") };
384
+ return { found: false };
385
+ }
386
+ function isPlainObject(value) {
387
+ return typeof value === "object" && value !== null && !Array.isArray(value);
388
+ }
389
+ // ---------------------------------------------------------------------------
390
+ // Conditions
391
+ // ---------------------------------------------------------------------------
392
+ export function evaluateCondition(condition, scope) {
393
+ if (condition.type === "group") {
394
+ // An empty group is vacuous: `all` holds, `any` does not — the same identity
395
+ // Array.every / Array.some give, so no special case is needed at run time.
396
+ const result = condition.op === "all"
397
+ ? condition.children.every((child) => evaluateCondition(child, scope))
398
+ : condition.children.some((child) => evaluateCondition(child, scope));
399
+ return condition.not === true ? !result : result;
400
+ }
401
+ const left = resolveValueRef(condition.left, scope);
402
+ const right = condition.right === undefined ? undefined : resolveValueRef(condition.right, scope);
403
+ return compareValues(left, condition.op, right);
404
+ }
405
+ function compareValues(left, op, right) {
406
+ switch (op) {
407
+ case "eq":
408
+ return valuesEqual(left, right);
409
+ case "neq":
410
+ return !valuesEqual(left, right);
411
+ case "gt":
412
+ return orderingOf(left, right) > 0;
413
+ case "gte":
414
+ return orderingOf(left, right) >= 0;
415
+ case "lt":
416
+ return orderingOf(left, right) < 0;
417
+ case "lte":
418
+ return orderingOf(left, right) <= 0;
419
+ case "contains":
420
+ return containsValue(left, right);
421
+ case "not_contains":
422
+ return !containsValue(left, right);
423
+ case "starts_with":
424
+ return textOf(left).startsWith(textOf(right));
425
+ case "ends_with":
426
+ return textOf(left).endsWith(textOf(right));
427
+ case "is_empty":
428
+ return isEmptyValue(left);
429
+ case "is_not_empty":
430
+ return !isEmptyValue(left);
431
+ case "in":
432
+ return containsValue(right, left);
433
+ case "not_in":
434
+ return !containsValue(right, left);
435
+ case "matches":
436
+ return matchesPattern(left, right);
437
+ }
438
+ }
439
+ /**
440
+ * Numeric when both sides read as numbers, otherwise strict identity with
441
+ * null/undefined treated as one blank. Deliberately NOT the formula language's
442
+ * `formulaValuesEqual`, which throws on non-numeric input: a condition gates
443
+ * routing, and a run must not fail because a provider returned an object where
444
+ * the author expected a string.
445
+ */
446
+ function valuesEqual(left, right) {
447
+ if (left === right)
448
+ return true;
449
+ if (left === null || left === undefined)
450
+ return right === null || right === undefined;
451
+ if (right === null || right === undefined)
452
+ return false;
453
+ const leftNumber = numberOf(left);
454
+ const rightNumber = numberOf(right);
455
+ if (leftNumber !== null && rightNumber !== null)
456
+ return leftNumber === rightNumber;
457
+ if (Array.isArray(left) && Array.isArray(right)) {
458
+ return left.length === right.length
459
+ && left.every((entry, index) => valuesEqual(entry, right[index]));
460
+ }
461
+ return false;
462
+ }
463
+ /**
464
+ * Ordering for the relational operators. Incomparable operands sort as 0, so
465
+ * `gt`/`lt` are false and `gte`/`lte` are true — the same shape JavaScript gives
466
+ * for equal values, and never an exception.
467
+ */
468
+ function orderingOf(left, right) {
469
+ const leftNumber = numberOf(left);
470
+ const rightNumber = numberOf(right);
471
+ if (leftNumber !== null && rightNumber !== null) {
472
+ if (leftNumber === rightNumber)
473
+ return 0;
474
+ return leftNumber < rightNumber ? -1 : 1;
475
+ }
476
+ if (typeof left === "string" && typeof right === "string") {
477
+ return left.localeCompare(right);
478
+ }
479
+ return 0;
480
+ }
481
+ function numberOf(value) {
482
+ if (typeof value === "number")
483
+ return Number.isFinite(value) ? value : null;
484
+ if (typeof value === "string" && value.trim()) {
485
+ const parsed = Number(value);
486
+ return Number.isFinite(parsed) ? parsed : null;
487
+ }
488
+ return null;
489
+ }
490
+ /** Membership for arrays, case-sensitive substring for text. */
491
+ function containsValue(haystack, needle) {
492
+ if (Array.isArray(haystack))
493
+ return haystack.some((entry) => valuesEqual(entry, needle));
494
+ if (haystack === null || haystack === undefined)
495
+ return false;
496
+ if (needle === null || needle === undefined)
497
+ return false;
498
+ return textOf(haystack).includes(textOf(needle));
499
+ }
500
+ function isEmptyValue(value) {
501
+ if (value === null || value === undefined)
502
+ return true;
503
+ if (typeof value === "string")
504
+ return value.trim().length === 0;
505
+ if (Array.isArray(value))
506
+ return value.length === 0;
507
+ // 0 and false are values an author chose, not emptiness.
508
+ if (typeof value === "object")
509
+ return Object.keys(value).length === 0;
510
+ return false;
511
+ }
512
+ function textOf(value) {
513
+ if (value === null || value === undefined)
514
+ return "";
515
+ if (typeof value === "string")
516
+ return value;
517
+ if (typeof value === "number" || typeof value === "boolean")
518
+ return String(value);
519
+ try {
520
+ return JSON.stringify(value) ?? "";
521
+ }
522
+ catch {
523
+ return "";
524
+ }
525
+ }
526
+ // ---------------------------------------------------------------------------
527
+ // `matches`
528
+ // ---------------------------------------------------------------------------
529
+ const MAX_CONDITION_PATTERN_LENGTH = 200;
530
+ /**
531
+ * Compile a `matches` pattern, or return null when it is unsafe or invalid.
532
+ *
533
+ * Choice: conservative STATIC REJECTION rather than a linear (Thompson NFA)
534
+ * matcher. A linear engine would be several hundred lines of new, security-load-
535
+ * bearing code in a module the browser also bundles, whereas the patterns real
536
+ * GTM authors write (domain suffixes, job-title keywords, ID formats) never need
537
+ * the constructs rejected here. Rejected: sources over 200 characters,
538
+ * lookaround, backreferences, and a quantified group whose body itself contains
539
+ * an unbounded quantifier — the `(a+)+` family that drives catastrophic
540
+ * backtracking. Everything else compiles as a normal RegExp.
541
+ *
542
+ * The linter calls this at authoring time and reports `invalid_condition`, so a
543
+ * rejected pattern is a lint error the author sees, not a silent run-time false.
544
+ */
545
+ export function compileConditionPattern(pattern) {
546
+ if (!isSafeRegExpSource(pattern))
547
+ return null;
548
+ try {
549
+ return new RegExp(pattern);
550
+ }
551
+ catch {
552
+ return null;
553
+ }
554
+ }
555
+ function isSafeRegExpSource(source) {
556
+ if (!source || source.length > MAX_CONDITION_PATTERN_LENGTH)
557
+ return false;
558
+ if (/\(\?<?[=!]/.test(source))
559
+ return false; // lookahead / lookbehind
560
+ if (/\\[1-9]/.test(source))
561
+ return false; // backreference
562
+ return !hasNestedQuantifier(source);
563
+ }
564
+ function isUnboundedQuantifier(char) {
565
+ // `?` alone is bounded and cannot blow up; `*`, `+` and `{n,m}` can.
566
+ return char === "*" || char === "+" || char === "{";
567
+ }
568
+ function hasNestedQuantifier(source) {
569
+ // One frame per open group, plus a base frame for the top level. A frame is
570
+ // "quantified" once anything inside it repeats.
571
+ const stack = [{ quantified: false }];
572
+ let inCharacterClass = false;
573
+ let index = 0;
574
+ while (index < source.length) {
575
+ const char = source[index] ?? "";
576
+ if (char === "\\") {
577
+ index += 2;
578
+ continue;
579
+ }
580
+ if (inCharacterClass) {
581
+ if (char === "]")
582
+ inCharacterClass = false;
583
+ index += 1;
584
+ continue;
585
+ }
586
+ if (char === "[") {
587
+ inCharacterClass = true;
588
+ index += 1;
589
+ continue;
590
+ }
591
+ if (char === "(") {
592
+ stack.push({ quantified: false });
593
+ index += 1;
594
+ continue;
595
+ }
596
+ if (char === ")") {
597
+ index += 1;
598
+ // Unbalanced ")" — leave it to RegExp construction to reject.
599
+ if (stack.length === 1)
600
+ continue;
601
+ const frame = stack.pop();
602
+ const repeated = isUnboundedQuantifier(source[index] ?? "");
603
+ if (repeated && frame?.quantified === true)
604
+ return true;
605
+ const parent = stack[stack.length - 1];
606
+ if (parent && (repeated || frame?.quantified === true))
607
+ parent.quantified = true;
608
+ continue;
609
+ }
610
+ if (isUnboundedQuantifier(char)) {
611
+ const top = stack[stack.length - 1];
612
+ if (top)
613
+ top.quantified = true;
614
+ }
615
+ index += 1;
616
+ }
617
+ return false;
618
+ }
619
+ function matchesPattern(value, pattern) {
620
+ if (typeof pattern !== "string")
621
+ return false;
622
+ const regex = compileConditionPattern(pattern);
623
+ if (!regex)
624
+ return false;
625
+ return regex.test(textOf(value));
626
+ }
627
+ // ---------------------------------------------------------------------------
628
+ // Static analysis
629
+ // ---------------------------------------------------------------------------
630
+ /**
631
+ * Node ids a ref or condition reads, in first-seen order.
632
+ *
633
+ * `steps.<id>...` and `loop.<id>...` paths name a node exactly. Formula refs are
634
+ * a superset: the tokenizer's bare identifiers may name a node, a workflow
635
+ * input, or a loop, and nothing in the expression distinguishes them — callers
636
+ * that need certainty (edge inference, dependency ordering) intersect the result
637
+ * with the manifest's declared node ids.
638
+ */
639
+ export function referencedNodeIds(ref) {
640
+ const ids = [];
641
+ collectNodeIds(ref, ids);
642
+ return [...new Set(ids)];
643
+ }
644
+ function collectNodeIds(entry, into) {
645
+ switch (entry.type) {
646
+ case "group":
647
+ for (const child of entry.children)
648
+ collectNodeIds(child, into);
649
+ return;
650
+ case "compare":
651
+ collectNodeIds(entry.left, into);
652
+ if (entry.right !== undefined)
653
+ collectNodeIds(entry.right, into);
654
+ return;
655
+ case "ref":
656
+ pushPathNodeId(entry.path, into);
657
+ return;
658
+ case "template":
659
+ for (const match of entry.value.matchAll(/\{\{([^{}]*)\}\}/g)) {
660
+ const path = (match[1] ?? "").trim();
661
+ if (path)
662
+ pushPathNodeId(path, into);
663
+ }
664
+ return;
665
+ case "formula":
666
+ try {
667
+ // `paths: true` to match how the expression is EVALUATED. Parsing it the
668
+ // other way threw on every dotted identifier, and the catch below swallowed
669
+ // it — so a formula reading `steps.enrich.email` reported no node reference
670
+ // at all, and the upstream-only check and duplicate-remap both went blind
671
+ // to it.
672
+ for (const identifier of analyzeFormulaDependencies(entry.expression, { paths: true }).identifiers) {
673
+ // A rooted dotted identifier names its node in the second segment, the
674
+ // same as a ref path; a bare one IS the node id.
675
+ if (identifier.includes(".") || identifier.includes("[")) {
676
+ pushPathNodeId(identifier, into);
677
+ }
678
+ else {
679
+ into.push(identifier);
680
+ }
681
+ }
682
+ }
683
+ catch {
684
+ // An unparseable expression has no analysable dependencies; the linter
685
+ // reports it as invalid_formula.
686
+ }
687
+ return;
688
+ default:
689
+ return;
690
+ }
691
+ }
692
+ function pushPathNodeId(path, into) {
693
+ const segments = parseScopePath(path);
694
+ const root = segments?.[0];
695
+ const id = segments?.[1];
696
+ if (!root || !id)
697
+ return;
698
+ if (root === "steps" || root === "loop")
699
+ into.push(id);
700
+ }