@oxygen-agent/cli 1.365.3 → 1.575.19
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 +78 -16
- package/dist/index.js +3873 -514
- 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 +101 -5
- package/node_modules/@oxygen/shared/dist/billing.js +192 -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/hosted-ai.d.ts +17 -1
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +52 -3
- package/node_modules/@oxygen/shared/dist/index.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/index.js +15 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +77 -0
- package/node_modules/@oxygen/shared/dist/langfuse.js +231 -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 +56 -4
- 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 +126 -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/dist/workspace-agents.d.ts +8 -7
- package/node_modules/@oxygen/shared/dist/workspace-agents.js +34 -7
- 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 +10 -5
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { SCOPE_PATH_ROOTS } from "./expression.js";
|
|
2
|
+
/** Rewrite the node ids in one scope path, leaving foreign syntax untouched. */
|
|
3
|
+
export function remapScopePath(path, ids) {
|
|
4
|
+
const trimmed = path.trim();
|
|
5
|
+
if (!trimmed)
|
|
6
|
+
return path;
|
|
7
|
+
// The id segment accepts anything up to the next delimiter, not just
|
|
8
|
+
// [A-Za-z_][A-Za-z0-9_]*. genNodeId only ever mints `[a-z0-9_]`, but a
|
|
9
|
+
// CLI-authored manifest can carry `find-person` or a non-ASCII id, and
|
|
10
|
+
// parseScopePath/resolvePath both read those — so a narrower pattern here
|
|
11
|
+
// silently skipped exactly the ids the editor did not create.
|
|
12
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)\.([^.[]+)([.[].*)?$/.exec(trimmed);
|
|
13
|
+
if (!match)
|
|
14
|
+
return path;
|
|
15
|
+
const [, root, id, rest] = match;
|
|
16
|
+
if (root === undefined || id === undefined)
|
|
17
|
+
return path;
|
|
18
|
+
// `trigger` has no node id in the second position — it is `trigger.input`.
|
|
19
|
+
if (!SCOPE_PATH_ROOTS.has(root) || root === "trigger")
|
|
20
|
+
return path;
|
|
21
|
+
const next = ids.get(id);
|
|
22
|
+
return next === undefined ? path : `${root}.${next}${rest ?? ""}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Rewrite node ids inside `{{ ... }}` tokens, leaving every foreign token alone.
|
|
26
|
+
*
|
|
27
|
+
* Same discriminator the evaluator uses: only a token rooted at one of our scope
|
|
28
|
+
* roots is ours. `{{column}}` and `{{RANDOM|a|b}}` are Oxygen merge-tag syntax
|
|
29
|
+
* that travels inside these same strings and must survive untouched.
|
|
30
|
+
*/
|
|
31
|
+
export function remapTokens(value, ids) {
|
|
32
|
+
return value.replace(/\{\{([^{}]*)\}\}/g, (match, body) => {
|
|
33
|
+
const path = body.trim();
|
|
34
|
+
if (!path)
|
|
35
|
+
return match;
|
|
36
|
+
const root = path.split(/[.[]/, 1)[0];
|
|
37
|
+
if (root === undefined || !SCOPE_PATH_ROOTS.has(root))
|
|
38
|
+
return match;
|
|
39
|
+
const remapped = remapScopePath(path, ids);
|
|
40
|
+
return remapped === path ? match : `{{ ${remapped} }}`;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function remapValueRef(ref, ids) {
|
|
44
|
+
switch (ref.type) {
|
|
45
|
+
case "ref":
|
|
46
|
+
return { ...ref, path: remapScopePath(ref.path, ids) };
|
|
47
|
+
case "template":
|
|
48
|
+
return { ...ref, value: remapTokens(ref.value, ids) };
|
|
49
|
+
case "literal":
|
|
50
|
+
return { ...ref, value: remapLiteral(ref.value, ids) };
|
|
51
|
+
case "formula":
|
|
52
|
+
return { ...ref, expression: remapFormulaIdentifiers(ref.expression, ids) };
|
|
53
|
+
default:
|
|
54
|
+
// context_profile / context_asset name no node.
|
|
55
|
+
return ref;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function remapLiteral(value, ids, depth = 0) {
|
|
59
|
+
if (depth > 12)
|
|
60
|
+
return value;
|
|
61
|
+
if (typeof value === "string")
|
|
62
|
+
return remapTokens(value, ids);
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
return value.map((entry) => remapLiteral(entry, ids, depth + 1));
|
|
65
|
+
if (typeof value === "object" && value !== null) {
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
68
|
+
out[key] = remapLiteral(entry, ids, depth + 1);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Rewrite bare identifiers in a formula that name a mapped node.
|
|
76
|
+
*
|
|
77
|
+
* Quoted string literals are skipped, so a formula like `concat(find, "find")`
|
|
78
|
+
* rewrites the identifier and leaves the text alone. Beyond that this is a
|
|
79
|
+
* word-boundary substitution rather than a parse: @oxygen/formula owns the
|
|
80
|
+
* grammar, and re-implementing it here to rename an identifier would be a second
|
|
81
|
+
* opinion on what a formula means.
|
|
82
|
+
*/
|
|
83
|
+
function remapFormulaIdentifiers(expression, ids) {
|
|
84
|
+
if (ids.size === 0)
|
|
85
|
+
return expression;
|
|
86
|
+
let out = "";
|
|
87
|
+
let index = 0;
|
|
88
|
+
while (index < expression.length) {
|
|
89
|
+
const char = expression[index];
|
|
90
|
+
if (char === '"' || char === "'") {
|
|
91
|
+
// Copy the string literal verbatim, including its closing quote.
|
|
92
|
+
const end = expression.indexOf(char, index + 1);
|
|
93
|
+
const stop = end === -1 ? expression.length : end + 1;
|
|
94
|
+
out += expression.slice(index, stop);
|
|
95
|
+
index = stop;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (/[A-Za-z_]/.test(char)) {
|
|
99
|
+
let word = "";
|
|
100
|
+
while (index < expression.length && /[A-Za-z0-9_]/.test(expression[index])) {
|
|
101
|
+
word += expression[index];
|
|
102
|
+
index += 1;
|
|
103
|
+
}
|
|
104
|
+
// A DOTTED identifier is a scope path, so hand the whole thing to
|
|
105
|
+
// remapScopePath rather than substituting word by word. Word-by-word looked
|
|
106
|
+
// like it worked — `steps.<id>.x` has the node id in a word position — but it
|
|
107
|
+
// also rewrote FIELDS that happen to share a duplicated node's name:
|
|
108
|
+
// `steps.a.enrich` would have had its `enrich` field renamed. Only the second
|
|
109
|
+
// segment of a rooted path is a node id, and only remapScopePath knows that.
|
|
110
|
+
if (SCOPE_PATH_ROOTS.has(word) && expression[index] === ".") {
|
|
111
|
+
const pathEnd = readPathEnd(expression, index);
|
|
112
|
+
const path = word + expression.slice(index, pathEnd);
|
|
113
|
+
out += remapScopePath(path, ids);
|
|
114
|
+
index = pathEnd;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
out += ids.get(word) ?? word;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
out += char;
|
|
121
|
+
index += 1;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** End of a `.field` / `[0]` run, mirroring the tokenizer's own path scan. */
|
|
126
|
+
function readPathEnd(expression, from) {
|
|
127
|
+
let index = from;
|
|
128
|
+
for (;;) {
|
|
129
|
+
const char = expression[index];
|
|
130
|
+
if (char === "." && /[A-Za-z0-9_]/.test(expression[index + 1] ?? "")) {
|
|
131
|
+
index += 2;
|
|
132
|
+
while (/[A-Za-z0-9_]/.test(expression[index] ?? ""))
|
|
133
|
+
index += 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (char === "[") {
|
|
137
|
+
const close = expression.indexOf("]", index);
|
|
138
|
+
if (close === -1)
|
|
139
|
+
return index;
|
|
140
|
+
index = close + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
return index;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function remapCondition(condition, ids) {
|
|
147
|
+
if (condition.type === "group") {
|
|
148
|
+
return { ...condition, children: condition.children.map((child) => remapCondition(child, ids)) };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...condition,
|
|
152
|
+
left: remapValueRef(condition.left, ids),
|
|
153
|
+
...(condition.right === undefined ? {} : { right: remapValueRef(condition.right, ids) }),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Every binding in one node, rewritten.
|
|
158
|
+
*
|
|
159
|
+
* Switches on kind exhaustively rather than walking unknown keys: a node's shape
|
|
160
|
+
* is closed, and a generic deep walk would also rewrite things that merely look
|
|
161
|
+
* like paths (a `code` node's JavaScript, a switch case's label).
|
|
162
|
+
*/
|
|
163
|
+
export function remapNodeRefs(node, ids) {
|
|
164
|
+
switch (node.kind) {
|
|
165
|
+
case "tool":
|
|
166
|
+
return {
|
|
167
|
+
...node,
|
|
168
|
+
params: Object.fromEntries(Object.entries(node.params).map(([key, ref]) => [key, remapValueRef(ref, ids)])),
|
|
169
|
+
};
|
|
170
|
+
case "filter":
|
|
171
|
+
return { ...node, when: remapCondition(node.when, ids) };
|
|
172
|
+
case "switch":
|
|
173
|
+
return {
|
|
174
|
+
...node,
|
|
175
|
+
cases: node.cases.map((entry) => ({ ...entry, when: remapCondition(entry.when, ids) })),
|
|
176
|
+
};
|
|
177
|
+
case "loop":
|
|
178
|
+
return { ...node, over: remapValueRef(node.over, ids) };
|
|
179
|
+
case "set":
|
|
180
|
+
return {
|
|
181
|
+
...node,
|
|
182
|
+
fields: node.fields.map((field) => ({ ...field, value: remapValueRef(field.value, ids) })),
|
|
183
|
+
};
|
|
184
|
+
case "wait":
|
|
185
|
+
return node.until === undefined
|
|
186
|
+
? node
|
|
187
|
+
: { ...node, until: remapValueRef(node.until, ids) };
|
|
188
|
+
case "workflow":
|
|
189
|
+
return {
|
|
190
|
+
...node,
|
|
191
|
+
input: Object.fromEntries(Object.entries(node.input).map(([key, ref]) => [key, remapValueRef(ref, ids)])),
|
|
192
|
+
};
|
|
193
|
+
// Missed when this module was first written, because the kind list was typed
|
|
194
|
+
// from memory instead of read off the WorkflowGraphNode union — so a
|
|
195
|
+
// duplicated approval node kept pointing at the original, silently, which is
|
|
196
|
+
// the exact failure this module exists to prevent.
|
|
197
|
+
case "approval":
|
|
198
|
+
return {
|
|
199
|
+
...node,
|
|
200
|
+
...(node.reason === undefined ? {} : { reason: remapValueRef(node.reason, ids) }),
|
|
201
|
+
...(node.request === undefined
|
|
202
|
+
? {}
|
|
203
|
+
: {
|
|
204
|
+
request: Object.fromEntries(Object.entries(node.request).map(([key, ref]) => [key, remapValueRef(ref, ids)])),
|
|
205
|
+
}),
|
|
206
|
+
};
|
|
207
|
+
default:
|
|
208
|
+
// trigger, merge, code — no bindings. A `code` node's source is JavaScript
|
|
209
|
+
// reading `ctx.steps`, deliberately NOT rewritten: it is arbitrary code, and
|
|
210
|
+
// string-substituting inside it would be a guess.
|
|
211
|
+
return node;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type WorkflowGraphEdge, type WorkflowGraphManifest } from "./types.js";
|
|
2
|
+
export type GraphAdjacency = {
|
|
3
|
+
/** node id -> edges leaving it, in manifest declaration order. */
|
|
4
|
+
outgoing: Map<string, WorkflowGraphEdge[]>;
|
|
5
|
+
/** node id -> edges arriving at it, in manifest declaration order. */
|
|
6
|
+
incoming: Map<string, WorkflowGraphEdge[]>;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Both directions of the edge list, with an entry for every declared node (an
|
|
10
|
+
* isolated node maps to an empty array rather than being absent).
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildAdjacency(graph: WorkflowGraphManifest): GraphAdjacency;
|
|
13
|
+
/** Edges naming a source or target that no node declares. */
|
|
14
|
+
export declare function danglingEdges(graph: WorkflowGraphManifest): WorkflowGraphEdge[];
|
|
15
|
+
/** Nodes with no inbound edge, in declaration order. */
|
|
16
|
+
export declare function entryNodeIds(graph: WorkflowGraphManifest): string[];
|
|
17
|
+
/** Every node a run can actually reach from its roots (see rootNodeIds). */
|
|
18
|
+
export declare function reachableNodeIds(graph: WorkflowGraphManifest): Set<string>;
|
|
19
|
+
/**
|
|
20
|
+
* The nodes that make up a loop's body: everything reachable from the loop's
|
|
21
|
+
* "loop_body" handle without passing back through the loop node itself. The loop
|
|
22
|
+
* node is not a member of its own body.
|
|
23
|
+
*
|
|
24
|
+
* Membership is transitive through a nested loop, so an inner loop's body is also
|
|
25
|
+
* part of the outer loop's body. That is what lets a genuinely nested pair keep
|
|
26
|
+
* both of its back-edges legal.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loopBodyNodeIds(graph: WorkflowGraphManifest, loopNodeId: string): Set<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Kahn's algorithm over the graph minus legal back-edges. Returns null when some
|
|
31
|
+
* node can never be scheduled — i.e. an illegal cycle exists.
|
|
32
|
+
*
|
|
33
|
+
* Ties are broken by manifest declaration order, so the order is stable across
|
|
34
|
+
* runs and across machines.
|
|
35
|
+
*/
|
|
36
|
+
export declare function topologicalOrder(graph: WorkflowGraphManifest): string[] | null;
|
|
37
|
+
/**
|
|
38
|
+
* Illegal cycles: one entry per strongly connected component that still contains
|
|
39
|
+
* a cycle once legal loop back-edges are removed.
|
|
40
|
+
*
|
|
41
|
+
* Each entry lists the component's node ids in declaration order; a single-id
|
|
42
|
+
* entry means a self-edge. A component is the smallest deterministic description
|
|
43
|
+
* of an illegal cycle — enumerating every simple cycle is exponential and no
|
|
44
|
+
* caller needs the enumeration.
|
|
45
|
+
*/
|
|
46
|
+
export declare function findIllegalCycles(graph: WorkflowGraphManifest): string[][];
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Pure graph algorithms over an oxygen-workflows-v2 manifest, shared by the
|
|
2
|
+
// linter, the worker scheduler and the visual editor. No I/O, no node builtins:
|
|
3
|
+
// this module ships to the browser.
|
|
4
|
+
//
|
|
5
|
+
// Two conventions hold throughout:
|
|
6
|
+
// - Dangling edges (an endpoint that names no node) are excluded from every
|
|
7
|
+
// traversal and reported separately by danglingEdges(). Topology answers stay
|
|
8
|
+
// meaningful on a half-drawn canvas.
|
|
9
|
+
// - `disabled` nodes still participate. Disabling is a run-time skip, not a
|
|
10
|
+
// structural edit, so the editor keeps showing the same shape.
|
|
11
|
+
import { LOOP_BODY_HANDLE, isLoopNode, isTriggerNode, } from "./types.js";
|
|
12
|
+
function nodeIndex(graph) {
|
|
13
|
+
const index = new Map();
|
|
14
|
+
for (const node of graph.nodes) {
|
|
15
|
+
// First declaration wins; duplicate ids are the linter's problem, and
|
|
16
|
+
// silently preferring the later one would hide the duplicate here.
|
|
17
|
+
if (!index.has(node.id))
|
|
18
|
+
index.set(node.id, node);
|
|
19
|
+
}
|
|
20
|
+
return index;
|
|
21
|
+
}
|
|
22
|
+
function pushEdge(map, key, edge) {
|
|
23
|
+
const existing = map.get(key);
|
|
24
|
+
if (existing)
|
|
25
|
+
existing.push(edge);
|
|
26
|
+
else
|
|
27
|
+
map.set(key, [edge]);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Both directions of the edge list, with an entry for every declared node (an
|
|
31
|
+
* isolated node maps to an empty array rather than being absent).
|
|
32
|
+
*/
|
|
33
|
+
export function buildAdjacency(graph) {
|
|
34
|
+
const nodes = nodeIndex(graph);
|
|
35
|
+
const outgoing = new Map();
|
|
36
|
+
const incoming = new Map();
|
|
37
|
+
for (const id of nodes.keys()) {
|
|
38
|
+
outgoing.set(id, []);
|
|
39
|
+
incoming.set(id, []);
|
|
40
|
+
}
|
|
41
|
+
for (const edge of graph.edges) {
|
|
42
|
+
if (!nodes.has(edge.source) || !nodes.has(edge.target))
|
|
43
|
+
continue;
|
|
44
|
+
pushEdge(outgoing, edge.source, edge);
|
|
45
|
+
pushEdge(incoming, edge.target, edge);
|
|
46
|
+
}
|
|
47
|
+
return { outgoing, incoming };
|
|
48
|
+
}
|
|
49
|
+
/** Edges naming a source or target that no node declares. */
|
|
50
|
+
export function danglingEdges(graph) {
|
|
51
|
+
const nodes = nodeIndex(graph);
|
|
52
|
+
return graph.edges.filter((edge) => !nodes.has(edge.source) || !nodes.has(edge.target));
|
|
53
|
+
}
|
|
54
|
+
/** Nodes with no inbound edge, in declaration order. */
|
|
55
|
+
export function entryNodeIds(graph) {
|
|
56
|
+
const { incoming } = buildAdjacency(graph);
|
|
57
|
+
const entries = [];
|
|
58
|
+
for (const id of nodeIndex(graph).keys()) {
|
|
59
|
+
if ((incoming.get(id) ?? []).length === 0)
|
|
60
|
+
entries.push(id);
|
|
61
|
+
}
|
|
62
|
+
return entries;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Roots of execution: the declared trigger nodes when the graph has any,
|
|
66
|
+
* otherwise every node with no inbound edge.
|
|
67
|
+
*
|
|
68
|
+
* The preference matters — once a trigger exists it is the only way a run can
|
|
69
|
+
* start, so a second unconnected entry node is an island, not a second root.
|
|
70
|
+
*/
|
|
71
|
+
function rootNodeIds(graph) {
|
|
72
|
+
const triggers = graph.nodes.filter(isTriggerNode).map((node) => node.id);
|
|
73
|
+
return triggers.length > 0 ? triggers : entryNodeIds(graph);
|
|
74
|
+
}
|
|
75
|
+
/** Forward reachability over `outgoing`, optionally refusing to expand one node. */
|
|
76
|
+
function traverse(startIds, outgoing, stopAt) {
|
|
77
|
+
const visited = new Set();
|
|
78
|
+
const queue = [];
|
|
79
|
+
for (const id of startIds) {
|
|
80
|
+
if (visited.has(id))
|
|
81
|
+
continue;
|
|
82
|
+
visited.add(id);
|
|
83
|
+
queue.push(id);
|
|
84
|
+
}
|
|
85
|
+
while (queue.length > 0) {
|
|
86
|
+
const current = queue.pop();
|
|
87
|
+
if (current === undefined)
|
|
88
|
+
break;
|
|
89
|
+
for (const edge of outgoing.get(current) ?? []) {
|
|
90
|
+
if (edge.target === stopAt || visited.has(edge.target))
|
|
91
|
+
continue;
|
|
92
|
+
visited.add(edge.target);
|
|
93
|
+
queue.push(edge.target);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return visited;
|
|
97
|
+
}
|
|
98
|
+
/** Every node a run can actually reach from its roots (see rootNodeIds). */
|
|
99
|
+
export function reachableNodeIds(graph) {
|
|
100
|
+
const { outgoing } = buildAdjacency(graph);
|
|
101
|
+
return traverse(rootNodeIds(graph), outgoing);
|
|
102
|
+
}
|
|
103
|
+
function loopBodyFrom(outgoing, loopNodeId) {
|
|
104
|
+
const bodyEntries = (outgoing.get(loopNodeId) ?? [])
|
|
105
|
+
.filter((edge) => edge.source_handle === LOOP_BODY_HANDLE && edge.target !== loopNodeId)
|
|
106
|
+
.map((edge) => edge.target);
|
|
107
|
+
const body = traverse(bodyEntries, outgoing, loopNodeId);
|
|
108
|
+
body.delete(loopNodeId);
|
|
109
|
+
return body;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The nodes that make up a loop's body: everything reachable from the loop's
|
|
113
|
+
* "loop_body" handle without passing back through the loop node itself. The loop
|
|
114
|
+
* node is not a member of its own body.
|
|
115
|
+
*
|
|
116
|
+
* Membership is transitive through a nested loop, so an inner loop's body is also
|
|
117
|
+
* part of the outer loop's body. That is what lets a genuinely nested pair keep
|
|
118
|
+
* both of its back-edges legal.
|
|
119
|
+
*/
|
|
120
|
+
export function loopBodyNodeIds(graph, loopNodeId) {
|
|
121
|
+
const loop = nodeIndex(graph).get(loopNodeId);
|
|
122
|
+
if (!loop || !isLoopNode(loop))
|
|
123
|
+
return new Set();
|
|
124
|
+
return loopBodyFrom(buildAdjacency(graph).outgoing, loopNodeId);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The one legal back-edge rule: an edge may close a cycle only by returning to
|
|
128
|
+
* the loop node whose "loop_body" it starts inside. Every other edge that closes
|
|
129
|
+
* a cycle is illegal.
|
|
130
|
+
*
|
|
131
|
+
* Proper nesting is strict containment — the inner loop sits in the outer loop's
|
|
132
|
+
* body, never the reverse. Two loops that each sit inside the other's body are
|
|
133
|
+
* interlocked rather than nested (each "back-edge" is also the other's forward
|
|
134
|
+
* edge), so neither loop grants legality and their tangle is reported as an
|
|
135
|
+
* illegal cycle.
|
|
136
|
+
*/
|
|
137
|
+
function legalBackEdgeIds(graph) {
|
|
138
|
+
const { outgoing, incoming } = buildAdjacency(graph);
|
|
139
|
+
const loopIds = graph.nodes.filter(isLoopNode).map((node) => node.id);
|
|
140
|
+
const bodies = new Map();
|
|
141
|
+
for (const loopId of loopIds)
|
|
142
|
+
bodies.set(loopId, loopBodyFrom(outgoing, loopId));
|
|
143
|
+
const legal = new Set();
|
|
144
|
+
for (const loopId of loopIds) {
|
|
145
|
+
const body = bodies.get(loopId) ?? new Set();
|
|
146
|
+
const interlocked = loopIds.some((other) => other !== loopId && body.has(other) && (bodies.get(other)?.has(loopId) ?? false));
|
|
147
|
+
if (interlocked)
|
|
148
|
+
continue;
|
|
149
|
+
for (const edge of incoming.get(loopId) ?? []) {
|
|
150
|
+
if (body.has(edge.source))
|
|
151
|
+
legal.add(edge.id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return legal;
|
|
155
|
+
}
|
|
156
|
+
/** Adjacency minus legal loop back-edges — acyclic if and only if the graph is legal. */
|
|
157
|
+
function forwardOutgoing(graph) {
|
|
158
|
+
const { outgoing } = buildAdjacency(graph);
|
|
159
|
+
const backEdges = legalBackEdgeIds(graph);
|
|
160
|
+
const forward = new Map();
|
|
161
|
+
for (const [id, edges] of outgoing) {
|
|
162
|
+
forward.set(id, edges.filter((edge) => !backEdges.has(edge.id)));
|
|
163
|
+
}
|
|
164
|
+
return forward;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Kahn's algorithm over the graph minus legal back-edges. Returns null when some
|
|
168
|
+
* node can never be scheduled — i.e. an illegal cycle exists.
|
|
169
|
+
*
|
|
170
|
+
* Ties are broken by manifest declaration order, so the order is stable across
|
|
171
|
+
* runs and across machines.
|
|
172
|
+
*/
|
|
173
|
+
export function topologicalOrder(graph) {
|
|
174
|
+
const nodes = nodeIndex(graph);
|
|
175
|
+
const forward = forwardOutgoing(graph);
|
|
176
|
+
const indegree = new Map();
|
|
177
|
+
for (const id of nodes.keys())
|
|
178
|
+
indegree.set(id, 0);
|
|
179
|
+
for (const edges of forward.values()) {
|
|
180
|
+
for (const edge of edges)
|
|
181
|
+
indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1);
|
|
182
|
+
}
|
|
183
|
+
const ready = [...nodes.keys()].filter((id) => (indegree.get(id) ?? 0) === 0);
|
|
184
|
+
const order = [];
|
|
185
|
+
while (ready.length > 0) {
|
|
186
|
+
const current = ready.shift();
|
|
187
|
+
if (current === undefined)
|
|
188
|
+
break;
|
|
189
|
+
order.push(current);
|
|
190
|
+
for (const edge of forward.get(current) ?? []) {
|
|
191
|
+
const remaining = (indegree.get(edge.target) ?? 0) - 1;
|
|
192
|
+
indegree.set(edge.target, remaining);
|
|
193
|
+
// Enqueue on exactly zero: parallel edges decrement more than once and must
|
|
194
|
+
// not enqueue the same node twice.
|
|
195
|
+
if (remaining === 0)
|
|
196
|
+
ready.push(edge.target);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return order.length === nodes.size ? order : null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Illegal cycles: one entry per strongly connected component that still contains
|
|
203
|
+
* a cycle once legal loop back-edges are removed.
|
|
204
|
+
*
|
|
205
|
+
* Each entry lists the component's node ids in declaration order; a single-id
|
|
206
|
+
* entry means a self-edge. A component is the smallest deterministic description
|
|
207
|
+
* of an illegal cycle — enumerating every simple cycle is exponential and no
|
|
208
|
+
* caller needs the enumeration.
|
|
209
|
+
*/
|
|
210
|
+
export function findIllegalCycles(graph) {
|
|
211
|
+
const nodes = nodeIndex(graph);
|
|
212
|
+
const forward = forwardOutgoing(graph);
|
|
213
|
+
const declarationIndex = new Map();
|
|
214
|
+
for (const [position, id] of [...nodes.keys()].entries())
|
|
215
|
+
declarationIndex.set(id, position);
|
|
216
|
+
const orderOf = (id) => declarationIndex.get(id ?? "") ?? 0;
|
|
217
|
+
// Iterative Tarjan: recursion depth would otherwise track graph size, and a
|
|
218
|
+
// generated graph can be far deeper than an author-drawn one.
|
|
219
|
+
const index = new Map();
|
|
220
|
+
const lowlink = new Map();
|
|
221
|
+
const onStack = new Set();
|
|
222
|
+
const stack = [];
|
|
223
|
+
const components = [];
|
|
224
|
+
let counter = 0;
|
|
225
|
+
const open = (id) => {
|
|
226
|
+
index.set(id, counter);
|
|
227
|
+
lowlink.set(id, counter);
|
|
228
|
+
counter += 1;
|
|
229
|
+
stack.push(id);
|
|
230
|
+
onStack.add(id);
|
|
231
|
+
};
|
|
232
|
+
for (const root of nodes.keys()) {
|
|
233
|
+
if (index.has(root))
|
|
234
|
+
continue;
|
|
235
|
+
open(root);
|
|
236
|
+
const work = [{ id: root, edgeIndex: 0 }];
|
|
237
|
+
while (work.length > 0) {
|
|
238
|
+
const frame = work[work.length - 1];
|
|
239
|
+
if (!frame)
|
|
240
|
+
break;
|
|
241
|
+
const edges = forward.get(frame.id) ?? [];
|
|
242
|
+
const edge = frame.edgeIndex < edges.length ? edges[frame.edgeIndex] : undefined;
|
|
243
|
+
if (edge) {
|
|
244
|
+
frame.edgeIndex += 1;
|
|
245
|
+
if (!index.has(edge.target)) {
|
|
246
|
+
open(edge.target);
|
|
247
|
+
work.push({ id: edge.target, edgeIndex: 0 });
|
|
248
|
+
}
|
|
249
|
+
else if (onStack.has(edge.target)) {
|
|
250
|
+
lowlink.set(frame.id, Math.min(lowlink.get(frame.id) ?? 0, index.get(edge.target) ?? 0));
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
work.pop();
|
|
255
|
+
const parent = work[work.length - 1];
|
|
256
|
+
if (parent) {
|
|
257
|
+
lowlink.set(parent.id, Math.min(lowlink.get(parent.id) ?? 0, lowlink.get(frame.id) ?? 0));
|
|
258
|
+
}
|
|
259
|
+
if (lowlink.get(frame.id) !== index.get(frame.id))
|
|
260
|
+
continue;
|
|
261
|
+
const component = [];
|
|
262
|
+
for (;;) {
|
|
263
|
+
const member = stack.pop();
|
|
264
|
+
if (member === undefined)
|
|
265
|
+
break;
|
|
266
|
+
onStack.delete(member);
|
|
267
|
+
component.push(member);
|
|
268
|
+
if (member === frame.id)
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const hasSelfEdge = (forward.get(frame.id) ?? []).some((e) => e.target === frame.id);
|
|
272
|
+
if (component.length > 1 || hasSelfEdge) {
|
|
273
|
+
component.sort((a, b) => orderOf(a) - orderOf(b));
|
|
274
|
+
components.push(component);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
components.sort((a, b) => orderOf(a[0]) - orderOf(b[0]));
|
|
279
|
+
return components;
|
|
280
|
+
}
|