@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.
- package/README.md +1 -1
- package/dist/column-run-notices.d.ts +11 -0
- package/dist/column-run-notices.js +37 -0
- package/dist/command-manifest.js +13 -8
- package/dist/help.js +79 -16
- package/dist/index.js +3812 -460
- package/dist/skills.js +106 -1
- package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
- package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
- package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
- package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
- package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
- package/node_modules/@oxygen/formula/dist/expression.js +428 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
- package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
- package/node_modules/@oxygen/formula/dist/index.js +17 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
- package/node_modules/@oxygen/formula/package.json +26 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -5
- package/node_modules/@oxygen/shared/dist/billing.js +185 -8
- package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
- package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
- package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
- package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/directory.js +1 -0
- package/node_modules/@oxygen/shared/dist/file-import.js +58 -11
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +19 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/index.js +9 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
- package/node_modules/@oxygen/shared/dist/log.js +41 -2
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
- package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
- package/node_modules/@oxygen/shared/dist/tags.js +122 -6
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
- package/node_modules/@oxygen/shared/package.json +95 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
- package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
- package/node_modules/@oxygen/workflows/dist/index.js +179 -13
- package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
- package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
- package/node_modules/@oxygen/workflows/package.json +4 -0
- 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
|
+
}
|