@particle-academy/fancy-flow 0.45.0 → 0.46.0
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/dist/{ConfigFieldRenderer-Dzy4f6rG.d.cts → ConfigFieldRenderer-Cyir8-B8.d.cts} +2 -2
- package/dist/{ConfigFieldRenderer-WqJr-td_.d.ts → ConfigFieldRenderer-iMkZj0VK.d.ts} +2 -2
- package/dist/{FlowViewer-BbbUJe2b.d.cts → FlowViewer-CY3_vHSg.d.cts} +1 -1
- package/dist/{FlowViewer-csuaTL5t.d.ts → FlowViewer-DGW6t-80.d.ts} +1 -1
- package/dist/{capabilities-Brn74URS.d.ts → capabilities-DMUhnyZD.d.ts} +1 -1
- package/dist/{capabilities-CYCeIx6I.d.cts → capabilities-Dmd26aAs.d.cts} +1 -1
- package/dist/chunk-BHM5HGXH.js +79 -0
- package/dist/chunk-BHM5HGXH.js.map +1 -0
- package/dist/{chunk-DHUFUDEX.js → chunk-HFUBVT7W.js} +3 -3
- package/dist/{chunk-DHUFUDEX.js.map → chunk-HFUBVT7W.js.map} +1 -1
- package/dist/{chunk-VLATLVGH.js → chunk-L43CWD63.js} +129 -5
- package/dist/chunk-L43CWD63.js.map +1 -0
- package/dist/{chunk-A6RFLGWV.js → chunk-MH4ZW4DJ.js} +3 -3
- package/dist/{chunk-A6RFLGWV.js.map → chunk-MH4ZW4DJ.js.map} +1 -1
- package/dist/{chunk-QZTGV3ZL.js → chunk-P5SYH6M6.js} +3 -3
- package/dist/{chunk-QZTGV3ZL.js.map → chunk-P5SYH6M6.js.map} +1 -1
- package/dist/{chunk-WBXGZVLB.js → chunk-RMU6YCIQ.js} +11 -5
- package/dist/chunk-RMU6YCIQ.js.map +1 -0
- package/dist/durable/index.d.cts +490 -0
- package/dist/durable/index.d.ts +490 -0
- package/dist/durable.cjs +927 -0
- package/dist/durable.cjs.map +1 -0
- package/dist/durable.js +552 -0
- package/dist/durable.js.map +1 -0
- package/dist/engine.cjs +128 -2
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.cts +9 -7
- package/dist/engine.d.ts +9 -7
- package/dist/engine.js +4 -4
- package/dist/fields/react-fancy.d.cts +4 -3
- package/dist/fields/react-fancy.d.ts +4 -3
- package/dist/index.cjs +133 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -10
- package/dist/index.d.ts +12 -10
- package/dist/index.js +13 -88
- package/dist/index.js.map +1 -1
- package/dist/layout/index.d.cts +1 -1
- package/dist/layout/index.d.ts +1 -1
- package/dist/llm/prism.d.cts +2 -2
- package/dist/llm/prism.d.ts +2 -2
- package/dist/llm/vercel-ai.d.cts +2 -2
- package/dist/llm/vercel-ai.d.ts +2 -2
- package/dist/pause-9iT4tCEV.d.cts +96 -0
- package/dist/pause-9iT4tCEV.d.ts +96 -0
- package/dist/registry/index.d.cts +7 -6
- package/dist/registry/index.d.ts +7 -6
- package/dist/{registry-Budds73w.d.cts → registry-D_kyUZEf.d.cts} +1 -1
- package/dist/{registry-D0I20HJ3.d.ts → registry-LHB3MlR9.d.ts} +1 -1
- package/dist/registry.cjs +133 -3
- package/dist/registry.cjs.map +1 -1
- package/dist/registry.js +4 -4
- package/dist/{run-cohort-CV7EcwGG.d.ts → run-cohort-Cyeny2Uu.d.ts} +3 -34
- package/dist/{run-cohort-DitsaYgS.d.cts → run-cohort-DdPPowJ3.d.cts} +3 -34
- package/dist/run-flow-1KiXhy59.d.cts +55 -0
- package/dist/run-flow-CQvTDBYk.d.ts +55 -0
- package/dist/runtime/index.d.cts +5 -3
- package/dist/runtime/index.d.ts +5 -3
- package/dist/runtime.cjs +128 -2
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +3 -3
- package/dist/schema/index.d.cts +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/screens.d.cts +2 -2
- package/dist/screens.d.ts +2 -2
- package/dist/screens.js +6 -6
- package/dist/{types-D6ZSKBly.d.ts → types--S2Kzx7w.d.cts} +3 -97
- package/dist/{types-nQOA8jpk.d.cts → types-ChkCRonF.d.ts} +3 -97
- package/dist/types-Jx1TwehV.d.cts +247 -0
- package/dist/types-Jx1TwehV.d.ts +247 -0
- package/dist/ux.d.cts +3 -2
- package/dist/ux.d.ts +3 -2
- package/package.json +12 -2
- package/dist/chunk-VLATLVGH.js.map +0 -1
- package/dist/chunk-WBXGZVLB.js.map +0 -1
- package/dist/types-CMSrWVYM.d.cts +0 -109
- package/dist/types-CMSrWVYM.d.ts +0 -109
package/dist/durable.cjs
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/durable/state.ts
|
|
4
|
+
var NodeRunStatus = {
|
|
5
|
+
CLAIMED: "claimed",
|
|
6
|
+
COMPLETED: "completed",
|
|
7
|
+
SKIPPED: "skipped",
|
|
8
|
+
FAILED: "failed",
|
|
9
|
+
PAUSED: "paused"
|
|
10
|
+
};
|
|
11
|
+
var SETTLED = [
|
|
12
|
+
NodeRunStatus.COMPLETED,
|
|
13
|
+
NodeRunStatus.SKIPPED,
|
|
14
|
+
NodeRunStatus.FAILED
|
|
15
|
+
];
|
|
16
|
+
var InMemoryClaimStore = class {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.runs = /* @__PURE__ */ new Map();
|
|
19
|
+
}
|
|
20
|
+
claim(runKey, nodeId, owner) {
|
|
21
|
+
const run = this.run(runKey);
|
|
22
|
+
const existing = run.get(nodeId);
|
|
23
|
+
if (!existing) {
|
|
24
|
+
run.set(nodeId, {
|
|
25
|
+
status: NodeRunStatus.CLAIMED,
|
|
26
|
+
ports: [],
|
|
27
|
+
owner,
|
|
28
|
+
attempts: 1,
|
|
29
|
+
firstAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
const ownClaim = (existing.status === NodeRunStatus.CLAIMED || existing.status === NodeRunStatus.PAUSED) && existing.owner === owner;
|
|
34
|
+
if (ownClaim) {
|
|
35
|
+
existing.attempts += 1;
|
|
36
|
+
existing.status = NodeRunStatus.CLAIMED;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
state(runKey) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const [nodeId, entry] of this.run(runKey)) out[nodeId] = { ...entry, ports: [...entry.ports] };
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
complete(runKey, nodeId, output, ports) {
|
|
47
|
+
const entry = this.entry(runKey, nodeId);
|
|
48
|
+
entry.status = NodeRunStatus.COMPLETED;
|
|
49
|
+
entry.output = output;
|
|
50
|
+
entry.ports = [...ports];
|
|
51
|
+
entry.error = null;
|
|
52
|
+
}
|
|
53
|
+
skip(runKey, nodeId) {
|
|
54
|
+
const entry = this.entry(runKey, nodeId);
|
|
55
|
+
entry.status = NodeRunStatus.SKIPPED;
|
|
56
|
+
entry.ports = [];
|
|
57
|
+
}
|
|
58
|
+
fail(runKey, nodeId, error) {
|
|
59
|
+
const entry = this.entry(runKey, nodeId);
|
|
60
|
+
entry.status = NodeRunStatus.FAILED;
|
|
61
|
+
entry.error = error;
|
|
62
|
+
entry.ports = [];
|
|
63
|
+
}
|
|
64
|
+
pause(runKey, nodeId, reason) {
|
|
65
|
+
const entry = this.entry(runKey, nodeId);
|
|
66
|
+
entry.status = NodeRunStatus.PAUSED;
|
|
67
|
+
entry.error = reason;
|
|
68
|
+
entry.ports = [];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Drop a paused node's claim so a recorded answer can re-run it.
|
|
72
|
+
*
|
|
73
|
+
* Not part of the interface: resuming a human gate is the host's decision and
|
|
74
|
+
* its storage's business. Provided here because the in-memory store is also
|
|
75
|
+
* what the tests resume through.
|
|
76
|
+
*/
|
|
77
|
+
release(runKey, nodeId) {
|
|
78
|
+
this.run(runKey).delete(nodeId);
|
|
79
|
+
}
|
|
80
|
+
run(runKey) {
|
|
81
|
+
let run = this.runs.get(runKey);
|
|
82
|
+
if (!run) {
|
|
83
|
+
run = /* @__PURE__ */ new Map();
|
|
84
|
+
this.runs.set(runKey, run);
|
|
85
|
+
}
|
|
86
|
+
return run;
|
|
87
|
+
}
|
|
88
|
+
/** The row for one node, created as CLAIMED if a driver never claimed it. */
|
|
89
|
+
entry(runKey, nodeId) {
|
|
90
|
+
const run = this.run(runKey);
|
|
91
|
+
let entry = run.get(nodeId);
|
|
92
|
+
if (!entry) {
|
|
93
|
+
entry = {
|
|
94
|
+
status: NodeRunStatus.CLAIMED,
|
|
95
|
+
ports: [],
|
|
96
|
+
attempts: 1,
|
|
97
|
+
firstAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
98
|
+
};
|
|
99
|
+
run.set(nodeId, entry);
|
|
100
|
+
}
|
|
101
|
+
return entry;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
function isSettled(status) {
|
|
105
|
+
return status !== void 0 && SETTLED.includes(status);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/registry/registry.ts
|
|
109
|
+
var kinds = /* @__PURE__ */ new Map();
|
|
110
|
+
var aliases = /* @__PURE__ */ new Map();
|
|
111
|
+
var overrides = /* @__PURE__ */ new Map();
|
|
112
|
+
function resolveKindId(id) {
|
|
113
|
+
if (kinds.has(id)) return id;
|
|
114
|
+
const canonical = aliases.get(id);
|
|
115
|
+
return canonical && kinds.has(canonical) ? canonical : null;
|
|
116
|
+
}
|
|
117
|
+
function withOverride(kind) {
|
|
118
|
+
if (!kind) return null;
|
|
119
|
+
const patch = overrides.get(kind.name);
|
|
120
|
+
return patch ? { ...kind, ...patch } : kind;
|
|
121
|
+
}
|
|
122
|
+
function getNodeKind(name) {
|
|
123
|
+
const canonical = resolveKindId(name);
|
|
124
|
+
return canonical ? withOverride(kinds.get(canonical)) : null;
|
|
125
|
+
}
|
|
126
|
+
function kindIds(kind) {
|
|
127
|
+
return [kind.name, ...kind.aliases ?? []];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/durable/frontier.ts
|
|
131
|
+
function isNote(type) {
|
|
132
|
+
if (!type) return false;
|
|
133
|
+
if (type === "note") return true;
|
|
134
|
+
const kind = getNodeKind(type);
|
|
135
|
+
return kind ? kindIds(kind).includes("note") || kind.category === "annotation" : false;
|
|
136
|
+
}
|
|
137
|
+
var Frontier = {
|
|
138
|
+
compute(graph, state) {
|
|
139
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
140
|
+
for (const edge of graph.edges) {
|
|
141
|
+
const list = incoming.get(edge.target) ?? [];
|
|
142
|
+
list.push(edge);
|
|
143
|
+
incoming.set(edge.target, list);
|
|
144
|
+
}
|
|
145
|
+
const settled = /* @__PURE__ */ new Map();
|
|
146
|
+
const held = /* @__PURE__ */ new Set();
|
|
147
|
+
for (const [nodeId, entry] of Object.entries(state)) {
|
|
148
|
+
if (entry.status === NodeRunStatus.COMPLETED) settled.set(nodeId, entry.ports);
|
|
149
|
+
else if (isSettled(entry.status)) settled.set(nodeId, []);
|
|
150
|
+
else held.add(nodeId);
|
|
151
|
+
}
|
|
152
|
+
const ready = [];
|
|
153
|
+
const skipped = [];
|
|
154
|
+
let changed = true;
|
|
155
|
+
while (changed) {
|
|
156
|
+
changed = false;
|
|
157
|
+
for (const node of graph.nodes) {
|
|
158
|
+
const nodeId = node.id;
|
|
159
|
+
if (settled.has(nodeId) || held.has(nodeId) || ready.includes(nodeId)) continue;
|
|
160
|
+
const edges = incoming.get(nodeId) ?? [];
|
|
161
|
+
let blocked = false;
|
|
162
|
+
let active = false;
|
|
163
|
+
for (const edge of edges) {
|
|
164
|
+
if (!settled.has(edge.source)) {
|
|
165
|
+
blocked = true;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (settled.get(edge.source).includes(edge.sourceHandle ?? "out")) active = true;
|
|
169
|
+
}
|
|
170
|
+
if (blocked) continue;
|
|
171
|
+
if (edges.length > 0 && !active) {
|
|
172
|
+
settled.set(nodeId, []);
|
|
173
|
+
skipped.push(nodeId);
|
|
174
|
+
changed = true;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (isNote(node.type) || getNodeKind(node.type ?? "")?.category === "layout") {
|
|
178
|
+
settled.set(nodeId, []);
|
|
179
|
+
skipped.push(nodeId);
|
|
180
|
+
changed = true;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
ready.push(nodeId);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { ready, skipped };
|
|
187
|
+
},
|
|
188
|
+
/** Has every node settled? The run is finished when it has. */
|
|
189
|
+
isComplete(graph, state) {
|
|
190
|
+
return graph.nodes.every((node) => isSettled(state[node.id]?.status));
|
|
191
|
+
},
|
|
192
|
+
/**
|
|
193
|
+
* Is any node still held by a worker, or parked for a person?
|
|
194
|
+
*
|
|
195
|
+
* An empty frontier means something different depending on this: with work in
|
|
196
|
+
* flight the run is simply waiting, and whichever job finishes will advance
|
|
197
|
+
* it. With nothing in flight and nodes still unsettled, the graph cannot
|
|
198
|
+
* progress at all — which is a stuck run, and must be reported rather than
|
|
199
|
+
* waited on.
|
|
200
|
+
*/
|
|
201
|
+
hasWorkInFlight(state) {
|
|
202
|
+
return Object.values(state).some(
|
|
203
|
+
(entry) => entry.status === NodeRunStatus.CLAIMED || entry.status === NodeRunStatus.PAUSED
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
/** Persist the skip cascade so the next pass does not recompute it. */
|
|
207
|
+
async settleSkips(store, runKey, skipped) {
|
|
208
|
+
for (const nodeId of skipped) await store.skip(runKey, nodeId);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// src/registry/ports.ts
|
|
213
|
+
function resolvePortSpec(spec, config) {
|
|
214
|
+
if (spec === void 0) return void 0;
|
|
215
|
+
if (typeof spec !== "function") return spec;
|
|
216
|
+
try {
|
|
217
|
+
const resolved = spec(config);
|
|
218
|
+
return Array.isArray(resolved) ? resolved : void 0;
|
|
219
|
+
} catch {
|
|
220
|
+
return void 0;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function nodeConfig(node) {
|
|
224
|
+
return node.data?.config ?? {};
|
|
225
|
+
}
|
|
226
|
+
function resolveNodePorts(node, kind) {
|
|
227
|
+
const config = nodeConfig(node);
|
|
228
|
+
const data = node.data;
|
|
229
|
+
return {
|
|
230
|
+
inputs: data?.inputs ?? resolvePortSpec(kind?.inputs, config),
|
|
231
|
+
outputs: data?.outputs ?? resolvePortSpec(kind?.outputs, config)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/runtime/run-identity.ts
|
|
236
|
+
function escapeSegment(value) {
|
|
237
|
+
return value.replace(/%/g, "%25").replace(/\//g, "%2F").replace(/#/g, "%23");
|
|
238
|
+
}
|
|
239
|
+
function renderSegment(value, occurrence) {
|
|
240
|
+
const escaped = escapeSegment(value);
|
|
241
|
+
return occurrence === void 0 || occurrence === null ? escaped : `${escaped}#${occurrence}`;
|
|
242
|
+
}
|
|
243
|
+
function instant(value) {
|
|
244
|
+
const ms = Date.parse(value);
|
|
245
|
+
if (Number.isNaN(ms)) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`RunIdentity: firstAttemptAt is not a parseable timestamp: ${JSON.stringify(value)}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return ms;
|
|
251
|
+
}
|
|
252
|
+
var RunIdentity = class _RunIdentity {
|
|
253
|
+
constructor(runKey, path = [], attempt = 1, firstAttemptAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
254
|
+
if (!runKey || runKey.trim() === "") {
|
|
255
|
+
throw new Error("RunIdentity: runKey must be a non-empty string.");
|
|
256
|
+
}
|
|
257
|
+
this.runKey = runKey;
|
|
258
|
+
this.path = Object.freeze([...path]);
|
|
259
|
+
this.attempt = Math.max(1, Math.trunc(attempt));
|
|
260
|
+
this.firstAttemptAt = firstAttemptAt;
|
|
261
|
+
Object.freeze(this);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The identity of one execution of one node — stable across retries of that
|
|
265
|
+
* execution, distinct from every other execution of the same node.
|
|
266
|
+
*
|
|
267
|
+
* Pass `occurrence` when an executor runs the same node more than once at the
|
|
268
|
+
* same level (a loop body, one item of a fan-out it drives itself).
|
|
269
|
+
*/
|
|
270
|
+
stepKey(nodeId, occurrence) {
|
|
271
|
+
return `${this.runKey}:${[...this.path, renderSegment(nodeId, occurrence)].join("/")}`;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* A child identity for work nested inside this step.
|
|
275
|
+
*
|
|
276
|
+
* `subflow` pushes the invoking node's id, so a node inside the child graph
|
|
277
|
+
* cannot collide with a same-named node in the parent. Attempt and
|
|
278
|
+
* `firstAttemptAt` are carried down unchanged: the nested work happens inside
|
|
279
|
+
* this step's attempt, and shares its clock.
|
|
280
|
+
*/
|
|
281
|
+
descend(segment, occurrence) {
|
|
282
|
+
return new _RunIdentity(
|
|
283
|
+
this.runKey,
|
|
284
|
+
[...this.path, renderSegment(segment, occurrence)],
|
|
285
|
+
this.attempt,
|
|
286
|
+
this.firstAttemptAt
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
/** A copy on a different attempt, first-attempt clock preserved. */
|
|
290
|
+
withAttempt(attempt, firstAttemptAt) {
|
|
291
|
+
return new _RunIdentity(
|
|
292
|
+
this.runKey,
|
|
293
|
+
this.path,
|
|
294
|
+
attempt,
|
|
295
|
+
firstAttemptAt ?? this.firstAttemptAt
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* May this attempt reuse the step key and still be deduplicated?
|
|
300
|
+
*
|
|
301
|
+
* Providers forget idempotency keys — Stripe after 24 hours. Past that
|
|
302
|
+
* window, resending the key creates a second charge and sending a fresh one
|
|
303
|
+
* creates a second charge, so **the caller must refuse rather than pick
|
|
304
|
+
* between them**: a loud stuck run beats a silent double write.
|
|
305
|
+
*
|
|
306
|
+
* `true` on attempt 1 whatever the elapsed time — nothing was sent on an
|
|
307
|
+
* earlier attempt, so there is nothing for the provider to have forgotten.
|
|
308
|
+
* That is what lets a run park on a human gate for a week and then write.
|
|
309
|
+
*
|
|
310
|
+
* `windowSeconds: null` means the provider does not expire keys. `0` means
|
|
311
|
+
* it does not dedupe at all, so no retry may reuse a key — it is a real
|
|
312
|
+
* window, not an absent one, and the two must not be conflated: reading `0`
|
|
313
|
+
* as `null` turns "this provider does not dedupe" into "this provider
|
|
314
|
+
* dedupes forever", which is the more dangerous of the two by a distance.
|
|
315
|
+
*/
|
|
316
|
+
isReplaySafe(windowSeconds, now = /* @__PURE__ */ new Date()) {
|
|
317
|
+
if (this.attempt <= 1) return true;
|
|
318
|
+
if (windowSeconds === null || windowSeconds === void 0) return true;
|
|
319
|
+
if (windowSeconds <= 0) return false;
|
|
320
|
+
const nowMs = typeof now === "string" ? instant(now) : now.getTime();
|
|
321
|
+
const elapsedSeconds = Math.max(0, (nowMs - instant(this.firstAttemptAt)) / 1e3);
|
|
322
|
+
return elapsedSeconds <= windowSeconds;
|
|
323
|
+
}
|
|
324
|
+
toJSON() {
|
|
325
|
+
return {
|
|
326
|
+
runKey: this.runKey,
|
|
327
|
+
path: [...this.path],
|
|
328
|
+
attempt: this.attempt,
|
|
329
|
+
firstAttemptAt: this.firstAttemptAt
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/** Rebuild from a queue payload. */
|
|
333
|
+
static from(value) {
|
|
334
|
+
if (value instanceof _RunIdentity) return value;
|
|
335
|
+
if (typeof value === "string") return new _RunIdentity(value);
|
|
336
|
+
return new _RunIdentity(
|
|
337
|
+
value.runKey,
|
|
338
|
+
value.path ?? [],
|
|
339
|
+
value.attempt ?? 1,
|
|
340
|
+
value.firstAttemptAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// src/runtime/run-flow.ts
|
|
346
|
+
async function runFlow(graph, executors, onEvent = () => {
|
|
347
|
+
}, options = {}) {
|
|
348
|
+
const { signal, initialInputs = {}, timeoutMs, depth = 0, resumeOutputs = {} } = options;
|
|
349
|
+
const run = options.run === void 0 ? void 0 : RunIdentity.from(options.run);
|
|
350
|
+
const outputs = {};
|
|
351
|
+
const portValues = /* @__PURE__ */ new Map();
|
|
352
|
+
const completed = /* @__PURE__ */ new Set();
|
|
353
|
+
const errors = [];
|
|
354
|
+
const order = topoSort(graph);
|
|
355
|
+
if (order === null) {
|
|
356
|
+
const msg = "Cycle detected in flow graph \u2014 aborting.";
|
|
357
|
+
onEvent({ type: "run-error", error: msg });
|
|
358
|
+
return { ok: false, outputs, error: msg };
|
|
359
|
+
}
|
|
360
|
+
const incomingByNode = indexIncoming(graph.edges);
|
|
361
|
+
const timer = timeoutMs ? setTimeout(() => errors.push(`Run timed out after ${timeoutMs}ms`), timeoutMs) : null;
|
|
362
|
+
onEvent({ type: "run-start" });
|
|
363
|
+
try {
|
|
364
|
+
for (const node of order) {
|
|
365
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
366
|
+
if (errors.length) break;
|
|
367
|
+
if (Object.prototype.hasOwnProperty.call(resumeOutputs, node.id)) {
|
|
368
|
+
const stored = resumeOutputs[node.id];
|
|
369
|
+
outputs[node.id] = stored;
|
|
370
|
+
const activated = activatedPorts(node, stored);
|
|
371
|
+
for (const portId of activated.ports) {
|
|
372
|
+
portValues.set(`${node.id}:${portId}`, activated.value);
|
|
373
|
+
onEvent({ type: "node-output", nodeId: node.id, portId, value: activated.value });
|
|
374
|
+
}
|
|
375
|
+
completed.add(node.id);
|
|
376
|
+
onEvent({ type: "node-status", nodeId: node.id, status: "done", text: "resumed" });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const incoming = incomingByNode.get(node.id) ?? [];
|
|
380
|
+
if (incoming.length > 0) {
|
|
381
|
+
const anyActive = incoming.some((e) => portValues.has(`${e.source}:${e.sourceHandle ?? "out"}`));
|
|
382
|
+
if (!anyActive) {
|
|
383
|
+
onEvent({ type: "node-status", nodeId: node.id, status: "idle", text: "skipped" });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const visualKind = getNodeKind(node.type ?? "");
|
|
388
|
+
const isLayout = visualKind?.category === "layout";
|
|
389
|
+
const isAnnotation = node.type === "note" || visualKind?.category === "annotation";
|
|
390
|
+
if (isLayout || isAnnotation) {
|
|
391
|
+
onEvent({
|
|
392
|
+
type: "node-status",
|
|
393
|
+
nodeId: node.id,
|
|
394
|
+
status: "idle",
|
|
395
|
+
text: isLayout ? "lane" : "annotation"
|
|
396
|
+
});
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
onEvent({ type: "node-status", nodeId: node.id, status: "running" });
|
|
400
|
+
const inputs = collectInputs(node, incoming, portValues, initialInputs);
|
|
401
|
+
const exec = pickExecutor(executors, node);
|
|
402
|
+
if (!exec) {
|
|
403
|
+
const msg = `No executor registered for kind=${node.type}`;
|
|
404
|
+
errors.push(msg);
|
|
405
|
+
onEvent({ type: "node-status", nodeId: node.id, status: "error", text: msg });
|
|
406
|
+
onEvent({ type: "log", nodeId: node.id, level: "error", message: msg });
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const result = await Promise.resolve(
|
|
411
|
+
exec({
|
|
412
|
+
node,
|
|
413
|
+
inputs,
|
|
414
|
+
abort: (reason) => {
|
|
415
|
+
throw new Error(reason ?? "aborted");
|
|
416
|
+
},
|
|
417
|
+
emit: onEvent,
|
|
418
|
+
depth,
|
|
419
|
+
run
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
outputs[node.id] = result;
|
|
423
|
+
const activated = activatedPorts(node, result);
|
|
424
|
+
for (const portId of activated.ports) {
|
|
425
|
+
portValues.set(`${node.id}:${portId}`, activated.value);
|
|
426
|
+
onEvent({ type: "node-output", nodeId: node.id, portId, value: activated.value });
|
|
427
|
+
}
|
|
428
|
+
completed.add(node.id);
|
|
429
|
+
onEvent({ type: "node-status", nodeId: node.id, status: "done" });
|
|
430
|
+
} catch (e) {
|
|
431
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
432
|
+
errors.push(msg);
|
|
433
|
+
onEvent({ type: "node-status", nodeId: node.id, status: "error", text: msg });
|
|
434
|
+
onEvent({ type: "log", nodeId: node.id, level: "error", message: msg });
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
} finally {
|
|
439
|
+
if (timer) clearTimeout(timer);
|
|
440
|
+
}
|
|
441
|
+
const ok = errors.length === 0;
|
|
442
|
+
onEvent({ type: "run-end", ok });
|
|
443
|
+
return ok ? { ok, outputs } : { ok, outputs, error: errors[0] };
|
|
444
|
+
}
|
|
445
|
+
function indexIncoming(edges) {
|
|
446
|
+
const map = /* @__PURE__ */ new Map();
|
|
447
|
+
for (const e of edges) {
|
|
448
|
+
const list = map.get(e.target) ?? [];
|
|
449
|
+
list.push(e);
|
|
450
|
+
map.set(e.target, list);
|
|
451
|
+
}
|
|
452
|
+
return map;
|
|
453
|
+
}
|
|
454
|
+
function topoSort(graph) {
|
|
455
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
456
|
+
for (const n of graph.nodes) inDegree.set(n.id, 0);
|
|
457
|
+
for (const e of graph.edges) inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1);
|
|
458
|
+
const queue = [];
|
|
459
|
+
for (const [id, d] of inDegree) if (d === 0) queue.push(id);
|
|
460
|
+
const ordered = [];
|
|
461
|
+
while (queue.length) {
|
|
462
|
+
const id = queue.shift();
|
|
463
|
+
ordered.push(id);
|
|
464
|
+
for (const e of graph.edges) {
|
|
465
|
+
if (e.source !== id) continue;
|
|
466
|
+
const next = (inDegree.get(e.target) ?? 0) - 1;
|
|
467
|
+
inDegree.set(e.target, next);
|
|
468
|
+
if (next === 0) queue.push(e.target);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (ordered.length !== graph.nodes.length) return null;
|
|
472
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
473
|
+
return ordered.map((id) => byId.get(id)).filter(Boolean);
|
|
474
|
+
}
|
|
475
|
+
function collectInputs(node, incoming, portValues, initial) {
|
|
476
|
+
const inputs = { ...initial[node.id] ?? {} };
|
|
477
|
+
for (const e of incoming) {
|
|
478
|
+
const portId = e.targetHandle ?? "in";
|
|
479
|
+
const key = `${e.source}:${e.sourceHandle ?? "out"}`;
|
|
480
|
+
if (!portValues.has(key)) continue;
|
|
481
|
+
inputs[portId] = portValues.get(key);
|
|
482
|
+
}
|
|
483
|
+
return inputs;
|
|
484
|
+
}
|
|
485
|
+
function pickExecutor(executors, node) {
|
|
486
|
+
if (executors[node.id]) return executors[node.id];
|
|
487
|
+
if (node.type && executors[node.type]) return executors[node.type];
|
|
488
|
+
const kind = node.type ? getNodeKind(node.type) : null;
|
|
489
|
+
if (kind) {
|
|
490
|
+
for (const id of kindIds(kind)) {
|
|
491
|
+
if (executors[id]) return executors[id];
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return executors["*"];
|
|
495
|
+
}
|
|
496
|
+
function activatedPorts(node, result) {
|
|
497
|
+
if (result && typeof result === "object") {
|
|
498
|
+
const r = result;
|
|
499
|
+
if (typeof r.__port === "string") {
|
|
500
|
+
return { ports: [r.__port], value: r.value };
|
|
501
|
+
}
|
|
502
|
+
if (typeof r.branch === "string") {
|
|
503
|
+
return { ports: [r.branch], value: r.value ?? r };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const kind = getNodeKind(node.data?.kind ?? node.type ?? "") ?? void 0;
|
|
507
|
+
const declared = resolveNodePorts(node, kind).outputs?.map((p) => p.id);
|
|
508
|
+
return { ports: declared?.length ? declared : ["out"], value: result };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// src/durable/replay.ts
|
|
512
|
+
var BOUNDARY = "fancy-flow:node-boundary";
|
|
513
|
+
async function replayUpTo(graph, nodeId, executors, options = {}) {
|
|
514
|
+
const fenced = { ...executors };
|
|
515
|
+
for (const node of graph.nodes) {
|
|
516
|
+
if (node.id !== nodeId) {
|
|
517
|
+
fenced[node.id] = (ctx) => ctx.abort(BOUNDARY);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const ports = {};
|
|
521
|
+
const collect = (event) => {
|
|
522
|
+
var _a;
|
|
523
|
+
if (event.type === "node-output") {
|
|
524
|
+
(ports[_a = event.nodeId] ?? (ports[_a] = [])).push(event.portId);
|
|
525
|
+
}
|
|
526
|
+
options.onEvent?.(event);
|
|
527
|
+
};
|
|
528
|
+
const result = await runFlow(graph, fenced, collect, {
|
|
529
|
+
initialInputs: options.initialInputs ?? {},
|
|
530
|
+
resumeOutputs: options.resumeOutputs ?? {},
|
|
531
|
+
depth: options.depth ?? 0,
|
|
532
|
+
run: options.run
|
|
533
|
+
});
|
|
534
|
+
return {
|
|
535
|
+
result,
|
|
536
|
+
ports,
|
|
537
|
+
outputOf: (id) => result.outputs[id],
|
|
538
|
+
portsOf: (id) => ports[id] ?? []
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function isBoundary(error) {
|
|
542
|
+
return error === BOUNDARY;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/durable/retry.ts
|
|
546
|
+
var UNSAFE_TO_REPLAY = "unsafe-to-replay";
|
|
547
|
+
var RetryPolicy = class _RetryPolicy {
|
|
548
|
+
constructor(options = {}) {
|
|
549
|
+
this.tries = options.tries ?? 1;
|
|
550
|
+
this.backoffSeconds = options.backoffSeconds ?? 0;
|
|
551
|
+
this.perKind = options.perKind ?? {};
|
|
552
|
+
}
|
|
553
|
+
triesFor(node) {
|
|
554
|
+
if (_RetryPolicy.isUnsafeToReplay(node)) return 1;
|
|
555
|
+
for (const id of idsFor(node)) {
|
|
556
|
+
if (id in this.perKind) return Math.max(1, Math.trunc(this.perKind[id]));
|
|
557
|
+
}
|
|
558
|
+
return Math.max(1, Math.trunc(this.tries));
|
|
559
|
+
}
|
|
560
|
+
backoffFor(node) {
|
|
561
|
+
if (_RetryPolicy.isUnsafeToReplay(node)) return 0;
|
|
562
|
+
return Math.max(0, this.backoffSeconds);
|
|
563
|
+
}
|
|
564
|
+
static isUnsafeToReplay(node) {
|
|
565
|
+
if (!node.type) return false;
|
|
566
|
+
return getNodeKind(node.type)?.sideEffects === UNSAFE_TO_REPLAY;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
function idsFor(node) {
|
|
570
|
+
if (!node.type) return [];
|
|
571
|
+
const kind = getNodeKind(node.type);
|
|
572
|
+
const canonical = resolveKindId(node.type);
|
|
573
|
+
const ordered = [node.type, ...canonical ? [canonical] : [], ...kind ? kindIds(kind) : []];
|
|
574
|
+
return [...new Set(ordered)];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// src/registry/pause.ts
|
|
578
|
+
var PAUSE_PREFIX = "fancy-flow:pause:";
|
|
579
|
+
var LEGACY_PAUSE_PREFIXES = [
|
|
580
|
+
["awaiting-approval:", "approval"],
|
|
581
|
+
["awaiting-input:", "input"]
|
|
582
|
+
];
|
|
583
|
+
function encodePause(signal) {
|
|
584
|
+
const { nodeId, awaiting, detail } = signal;
|
|
585
|
+
return PAUSE_PREFIX + JSON.stringify(detail === void 0 ? { nodeId, awaiting } : { nodeId, awaiting, detail });
|
|
586
|
+
}
|
|
587
|
+
function decodePause(reason) {
|
|
588
|
+
if (typeof reason !== "string") return null;
|
|
589
|
+
if (reason.startsWith(PAUSE_PREFIX)) {
|
|
590
|
+
const body = reason.slice(PAUSE_PREFIX.length);
|
|
591
|
+
try {
|
|
592
|
+
const parsed = JSON.parse(body);
|
|
593
|
+
if (typeof parsed?.nodeId !== "string" || typeof parsed?.awaiting !== "string") return null;
|
|
594
|
+
return "detail" in parsed ? { nodeId: parsed.nodeId, awaiting: parsed.awaiting, detail: parsed.detail } : { nodeId: parsed.nodeId, awaiting: parsed.awaiting };
|
|
595
|
+
} catch {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
for (const [prefix, awaiting] of LEGACY_PAUSE_PREFIXES) {
|
|
600
|
+
if (reason.startsWith(prefix)) {
|
|
601
|
+
return { nodeId: reason.slice(prefix.length), awaiting };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
function pauseForHuman(ctx, awaiting, detail) {
|
|
607
|
+
return ctx.abort(encodePause({ nodeId: ctx.node.id, awaiting, detail }));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/expressions/expr.ts
|
|
611
|
+
var FALSY_STRINGS = /* @__PURE__ */ new Set(["", "0", "false", "no", "off", "null"]);
|
|
612
|
+
function truthy(value) {
|
|
613
|
+
if (typeof value === "boolean") return value;
|
|
614
|
+
if (value === null || value === void 0) return false;
|
|
615
|
+
if (typeof value === "string") return !FALSY_STRINGS.has(value.trim().toLowerCase());
|
|
616
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
617
|
+
if (typeof value === "number") return value !== 0;
|
|
618
|
+
return Boolean(value);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/durable/human.ts
|
|
622
|
+
var NotAwaitingHuman = class extends Error {
|
|
623
|
+
constructor(message) {
|
|
624
|
+
super(message);
|
|
625
|
+
this.name = "NotAwaitingHuman";
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
var Submissions = class {
|
|
629
|
+
constructor() {
|
|
630
|
+
this.answers = /* @__PURE__ */ new Map();
|
|
631
|
+
/** The node the run is currently parked on, if any. */
|
|
632
|
+
this.awaiting = null;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Record an answer for the node the run is parked on.
|
|
636
|
+
*
|
|
637
|
+
* Throws when the run is not waiting on that node. A queued answer for a node
|
|
638
|
+
* that never paused is a write nobody reads — and it looks, from the outside,
|
|
639
|
+
* exactly like a submission that worked.
|
|
640
|
+
*/
|
|
641
|
+
record(nodeId, value) {
|
|
642
|
+
if (this.awaiting !== null && this.awaiting !== nodeId) {
|
|
643
|
+
throw new NotAwaitingHuman(
|
|
644
|
+
`This run is waiting on "${this.awaiting}", not "${nodeId}". Recording an answer for a node the run is not parked on would be stored and never read.`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
if (this.awaiting === null) {
|
|
648
|
+
throw new NotAwaitingHuman(
|
|
649
|
+
`This run is not waiting for anyone, so an answer for "${nodeId}" has nothing to resume.`
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
this.answers.set(nodeId, value);
|
|
653
|
+
this.awaiting = null;
|
|
654
|
+
}
|
|
655
|
+
answered(nodeId) {
|
|
656
|
+
return this.answers.has(nodeId);
|
|
657
|
+
}
|
|
658
|
+
answer(nodeId) {
|
|
659
|
+
return this.answers.get(nodeId);
|
|
660
|
+
}
|
|
661
|
+
park(nodeId) {
|
|
662
|
+
this.awaiting = nodeId;
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
function option(ctx, key, fallback) {
|
|
666
|
+
const config = ctx.node.data?.config ?? {};
|
|
667
|
+
return config[key] ?? fallback;
|
|
668
|
+
}
|
|
669
|
+
function durableUserInput(submissions) {
|
|
670
|
+
return (ctx) => {
|
|
671
|
+
if (submissions.answered(ctx.node.id)) return submissions.answer(ctx.node.id);
|
|
672
|
+
if (option(ctx, "autoAnswerFromInput") === true) {
|
|
673
|
+
const values = ctx.inputs.values;
|
|
674
|
+
if (values !== void 0) return values;
|
|
675
|
+
}
|
|
676
|
+
submissions.park(ctx.node.id);
|
|
677
|
+
return pauseForHuman(ctx, "input", {
|
|
678
|
+
title: option(ctx, "title", "Need your input"),
|
|
679
|
+
fields: option(ctx, "fields", [])
|
|
680
|
+
});
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function durableApproval(submissions) {
|
|
684
|
+
return (ctx) => {
|
|
685
|
+
const passthrough = ctx.inputs.in ?? ctx.inputs;
|
|
686
|
+
if (submissions.answered(ctx.node.id)) {
|
|
687
|
+
return { branch: truthy(submissions.answer(ctx.node.id)) ? "approved" : "denied", value: passthrough };
|
|
688
|
+
}
|
|
689
|
+
if (option(ctx, "autoAnswerFromInput") === true) {
|
|
690
|
+
const decision = ctx.inputs.approved;
|
|
691
|
+
if (decision !== void 0) {
|
|
692
|
+
return { branch: truthy(decision) ? "approved" : "denied", value: passthrough };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
submissions.park(ctx.node.id);
|
|
696
|
+
return pauseForHuman(ctx, "approval", {
|
|
697
|
+
title: option(ctx, "title", "Approve action"),
|
|
698
|
+
description: option(ctx, "description")
|
|
699
|
+
});
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// src/durable/coordinator.ts
|
|
704
|
+
var Coordinator = class {
|
|
705
|
+
constructor(options) {
|
|
706
|
+
this.graph = options.graph;
|
|
707
|
+
this.executors = options.executors;
|
|
708
|
+
this.run = RunIdentity.from(options.run);
|
|
709
|
+
this.store = options.store ?? new InMemoryClaimStore();
|
|
710
|
+
this.initialInputs = options.initialInputs ?? {};
|
|
711
|
+
this.retry = options.retry ?? new RetryPolicy();
|
|
712
|
+
this.onEvent = options.onEvent;
|
|
713
|
+
}
|
|
714
|
+
get runKey() {
|
|
715
|
+
return this.run.runKey;
|
|
716
|
+
}
|
|
717
|
+
// -- the two operations ----------------------------------------------
|
|
718
|
+
/**
|
|
719
|
+
* Which nodes may be dispatched right now.
|
|
720
|
+
*
|
|
721
|
+
* Also settles the skip cascade, because a skip is a decision the frontier
|
|
722
|
+
* just made and a second caller must not make it again.
|
|
723
|
+
*/
|
|
724
|
+
async advance() {
|
|
725
|
+
const frontier = Frontier.compute(this.graph, await this.store.state(this.runKey));
|
|
726
|
+
await Frontier.settleSkips(this.store, this.runKey, frontier.skipped);
|
|
727
|
+
return frontier.ready;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Claim, execute and checkpoint one node.
|
|
731
|
+
*
|
|
732
|
+
* The claim is taken FIRST. Two workers racing for the same node produce one
|
|
733
|
+
* execution and one no-op, and the loser learns that from the store rather
|
|
734
|
+
* than from a duplicate side effect.
|
|
735
|
+
*
|
|
736
|
+
* `owner` is the token that lets a job's own retry re-enter its claim instead
|
|
737
|
+
* of deadlocking against the row it wrote itself — pass the SAME token across
|
|
738
|
+
* a job's attempts.
|
|
739
|
+
*/
|
|
740
|
+
async runNode(nodeId, owner) {
|
|
741
|
+
const token = owner ?? cryptoRandom();
|
|
742
|
+
if (!await this.store.claim(this.runKey, nodeId, token)) {
|
|
743
|
+
return { nodeId, status: "not-claimed", claimed: false, attempt: 0 };
|
|
744
|
+
}
|
|
745
|
+
const row = (await this.store.state(this.runKey))[nodeId];
|
|
746
|
+
const identity = this.identityFor(row);
|
|
747
|
+
const replay = await replayUpTo(this.graph, nodeId, this.executors, {
|
|
748
|
+
resumeOutputs: await this.completedOutputs(),
|
|
749
|
+
initialInputs: this.initialInputs,
|
|
750
|
+
onEvent: this.forward(nodeId),
|
|
751
|
+
run: identity
|
|
752
|
+
});
|
|
753
|
+
const result = replay.result;
|
|
754
|
+
const attempt = identity.attempt;
|
|
755
|
+
if (Object.prototype.hasOwnProperty.call(result.outputs, nodeId)) {
|
|
756
|
+
const output = result.outputs[nodeId];
|
|
757
|
+
const ports = replay.portsOf(nodeId);
|
|
758
|
+
await this.store.complete(this.runKey, nodeId, output, ports);
|
|
759
|
+
return { nodeId, status: "completed", output, ports, claimed: true, attempt };
|
|
760
|
+
}
|
|
761
|
+
const pause = decodePause(result.error);
|
|
762
|
+
if (pause && pause.nodeId === nodeId) {
|
|
763
|
+
await this.store.pause(this.runKey, nodeId, result.error ?? "");
|
|
764
|
+
return { nodeId, status: "paused", pause, claimed: true, attempt };
|
|
765
|
+
}
|
|
766
|
+
if (isBoundary(result.error)) {
|
|
767
|
+
await this.store.skip(this.runKey, nodeId);
|
|
768
|
+
return {
|
|
769
|
+
nodeId,
|
|
770
|
+
status: "skipped",
|
|
771
|
+
error: "replay stopped before reaching this node",
|
|
772
|
+
claimed: true,
|
|
773
|
+
attempt
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
const error = result.error ?? `node ${nodeId} produced no output and no error`;
|
|
777
|
+
const node = this.graph.nodes.find((n) => n.id === nodeId);
|
|
778
|
+
const tries = node ? this.retry.triesFor(node) : 1;
|
|
779
|
+
if (attempt < tries) {
|
|
780
|
+
return { nodeId, status: "failed", error, claimed: true, attempt, retryable: true };
|
|
781
|
+
}
|
|
782
|
+
await this.store.fail(this.runKey, nodeId, error);
|
|
783
|
+
return { nodeId, status: "failed", error, claimed: true, attempt, retryable: false };
|
|
784
|
+
}
|
|
785
|
+
// -- an in-process driver over the two ------------------------------
|
|
786
|
+
/**
|
|
787
|
+
* Drive the graph here, in this process, one node at a time.
|
|
788
|
+
*
|
|
789
|
+
* Every checkpoint is written exactly as a queued run writes it, so a crash
|
|
790
|
+
* mid-loop resumes from the same place a crashed worker would.
|
|
791
|
+
*
|
|
792
|
+
* Retries honour {@link RetryPolicy}: a node declaring `unsafe-to-replay`
|
|
793
|
+
* gets one attempt whatever the policy says, and the retry re-enters the same
|
|
794
|
+
* claim with the same owner token — so the step key it derives is unchanged,
|
|
795
|
+
* which is what makes the retry idempotent rather than duplicative.
|
|
796
|
+
*/
|
|
797
|
+
async runToCompletion(maxPasses = 1e4) {
|
|
798
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
799
|
+
const ready = await this.advance();
|
|
800
|
+
if (ready.length === 0) break;
|
|
801
|
+
for (const nodeId of ready) {
|
|
802
|
+
const outcome = await this.runNodeWithRetries(nodeId);
|
|
803
|
+
if (outcome.status === "paused") {
|
|
804
|
+
return {
|
|
805
|
+
ok: false,
|
|
806
|
+
outputs: await this.outputs(),
|
|
807
|
+
pause: outcome.pause,
|
|
808
|
+
paused: true
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
if (outcome.status === "failed") {
|
|
812
|
+
return { ok: false, outputs: await this.outputs(), error: outcome.error, paused: false };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const state = await this.store.state(this.runKey);
|
|
817
|
+
if (!Frontier.isComplete(this.graph, state)) {
|
|
818
|
+
if (Frontier.hasWorkInFlight(state)) {
|
|
819
|
+
return {
|
|
820
|
+
ok: false,
|
|
821
|
+
outputs: await this.outputs(),
|
|
822
|
+
error: "the run is waiting on work held elsewhere",
|
|
823
|
+
paused: false
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
const unsettled = this.graph.nodes.filter((n) => !["completed", "skipped", "failed"].includes(state[n.id]?.status ?? "")).map((n) => n.id);
|
|
827
|
+
return {
|
|
828
|
+
ok: false,
|
|
829
|
+
outputs: await this.outputs(),
|
|
830
|
+
error: `the run cannot progress; unsettled nodes: ${unsettled.join(", ")}`,
|
|
831
|
+
paused: false
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
const failed = Object.values(state).filter((e) => e.status === NodeRunStatus.FAILED).map((e) => e.error ?? "node failed");
|
|
835
|
+
return {
|
|
836
|
+
ok: failed.length === 0,
|
|
837
|
+
outputs: await this.outputs(),
|
|
838
|
+
error: failed[0],
|
|
839
|
+
paused: false
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Checkpointed outputs, in the graph's own node order.
|
|
844
|
+
*
|
|
845
|
+
* Ordered by the graph rather than by completion so two runs of the same
|
|
846
|
+
* workflow produce comparable output maps even when nodes finished in a
|
|
847
|
+
* different order.
|
|
848
|
+
*/
|
|
849
|
+
async outputs() {
|
|
850
|
+
const state = await this.store.state(this.runKey);
|
|
851
|
+
const out = {};
|
|
852
|
+
for (const node of this.graph.nodes) {
|
|
853
|
+
if (state[node.id]?.status === NodeRunStatus.COMPLETED) out[node.id] = state[node.id].output;
|
|
854
|
+
}
|
|
855
|
+
return out;
|
|
856
|
+
}
|
|
857
|
+
/** The checkpointed run, in the shape a single-process run returns. */
|
|
858
|
+
async asRunResult() {
|
|
859
|
+
const outcome = await this.runToCompletion();
|
|
860
|
+
return outcome.error === void 0 ? { ok: outcome.ok, outputs: outcome.outputs } : { ok: outcome.ok, outputs: outcome.outputs, error: outcome.error };
|
|
861
|
+
}
|
|
862
|
+
// -- internals -------------------------------------------------------
|
|
863
|
+
async runNodeWithRetries(nodeId) {
|
|
864
|
+
const owner = cryptoRandom();
|
|
865
|
+
let outcome = await this.runNode(nodeId, owner);
|
|
866
|
+
while (outcome.retryable) {
|
|
867
|
+
outcome = await this.runNode(nodeId, owner);
|
|
868
|
+
}
|
|
869
|
+
return outcome;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* The identity handed to the node about to execute.
|
|
873
|
+
*
|
|
874
|
+
* `attempt` and `firstAttemptAt` come off the CLAIM ROW, so they describe
|
|
875
|
+
* this step rather than the run — which is what makes the retry window check
|
|
876
|
+
* exact instead of conservative.
|
|
877
|
+
*/
|
|
878
|
+
identityFor(row) {
|
|
879
|
+
if (!row) return this.run;
|
|
880
|
+
return this.run.withAttempt(row.attempts, row.firstAttemptAt);
|
|
881
|
+
}
|
|
882
|
+
async completedOutputs() {
|
|
883
|
+
const state = await this.store.state(this.runKey);
|
|
884
|
+
const out = {};
|
|
885
|
+
for (const [nodeId, entry] of Object.entries(state)) {
|
|
886
|
+
if (entry.status === NodeRunStatus.COMPLETED) out[nodeId] = entry.output;
|
|
887
|
+
}
|
|
888
|
+
return out;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Forward only the events the target node produced.
|
|
892
|
+
*
|
|
893
|
+
* A replay re-emits the whole completed prefix. Passing that through would
|
|
894
|
+
* show a consumer every node running again on every job — the run feed would
|
|
895
|
+
* report a 20-node workflow as 200 status changes.
|
|
896
|
+
*/
|
|
897
|
+
forward(nodeId) {
|
|
898
|
+
const sink = this.onEvent;
|
|
899
|
+
if (!sink) return void 0;
|
|
900
|
+
return (event) => {
|
|
901
|
+
const id = "nodeId" in event ? event.nodeId : void 0;
|
|
902
|
+
if (id === void 0 || id === nodeId) sink(event);
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
function cryptoRandom() {
|
|
907
|
+
const g = globalThis;
|
|
908
|
+
return g.crypto?.randomUUID?.() ?? `own_${Math.random().toString(36).slice(2)}${Date.now()}`;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
exports.BOUNDARY = BOUNDARY;
|
|
912
|
+
exports.Coordinator = Coordinator;
|
|
913
|
+
exports.Frontier = Frontier;
|
|
914
|
+
exports.InMemoryClaimStore = InMemoryClaimStore;
|
|
915
|
+
exports.NodeRunStatus = NodeRunStatus;
|
|
916
|
+
exports.NotAwaitingHuman = NotAwaitingHuman;
|
|
917
|
+
exports.RetryPolicy = RetryPolicy;
|
|
918
|
+
exports.SETTLED = SETTLED;
|
|
919
|
+
exports.Submissions = Submissions;
|
|
920
|
+
exports.UNSAFE_TO_REPLAY = UNSAFE_TO_REPLAY;
|
|
921
|
+
exports.durableApproval = durableApproval;
|
|
922
|
+
exports.durableUserInput = durableUserInput;
|
|
923
|
+
exports.isBoundary = isBoundary;
|
|
924
|
+
exports.isSettled = isSettled;
|
|
925
|
+
exports.replayUpTo = replayUpTo;
|
|
926
|
+
//# sourceMappingURL=durable.cjs.map
|
|
927
|
+
//# sourceMappingURL=durable.cjs.map
|