@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.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { truthy } from './chunk-BHM5HGXH.js';
|
|
2
|
+
import { pauseForHuman, decodePause } from './chunk-UEOE6B52.js';
|
|
3
|
+
import { runFlow, RunIdentity } from './chunk-L43CWD63.js';
|
|
4
|
+
import './chunk-TITD5W4Y.js';
|
|
5
|
+
import { getNodeKind, resolveKindId, kindIds } from './chunk-PVROYW7C.js';
|
|
6
|
+
|
|
7
|
+
// src/durable/state.ts
|
|
8
|
+
var NodeRunStatus = {
|
|
9
|
+
CLAIMED: "claimed",
|
|
10
|
+
COMPLETED: "completed",
|
|
11
|
+
SKIPPED: "skipped",
|
|
12
|
+
FAILED: "failed",
|
|
13
|
+
PAUSED: "paused"
|
|
14
|
+
};
|
|
15
|
+
var SETTLED = [
|
|
16
|
+
NodeRunStatus.COMPLETED,
|
|
17
|
+
NodeRunStatus.SKIPPED,
|
|
18
|
+
NodeRunStatus.FAILED
|
|
19
|
+
];
|
|
20
|
+
var InMemoryClaimStore = class {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.runs = /* @__PURE__ */ new Map();
|
|
23
|
+
}
|
|
24
|
+
claim(runKey, nodeId, owner) {
|
|
25
|
+
const run = this.run(runKey);
|
|
26
|
+
const existing = run.get(nodeId);
|
|
27
|
+
if (!existing) {
|
|
28
|
+
run.set(nodeId, {
|
|
29
|
+
status: NodeRunStatus.CLAIMED,
|
|
30
|
+
ports: [],
|
|
31
|
+
owner,
|
|
32
|
+
attempts: 1,
|
|
33
|
+
firstAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
34
|
+
});
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
const ownClaim = (existing.status === NodeRunStatus.CLAIMED || existing.status === NodeRunStatus.PAUSED) && existing.owner === owner;
|
|
38
|
+
if (ownClaim) {
|
|
39
|
+
existing.attempts += 1;
|
|
40
|
+
existing.status = NodeRunStatus.CLAIMED;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
state(runKey) {
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const [nodeId, entry] of this.run(runKey)) out[nodeId] = { ...entry, ports: [...entry.ports] };
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
complete(runKey, nodeId, output, ports) {
|
|
51
|
+
const entry = this.entry(runKey, nodeId);
|
|
52
|
+
entry.status = NodeRunStatus.COMPLETED;
|
|
53
|
+
entry.output = output;
|
|
54
|
+
entry.ports = [...ports];
|
|
55
|
+
entry.error = null;
|
|
56
|
+
}
|
|
57
|
+
skip(runKey, nodeId) {
|
|
58
|
+
const entry = this.entry(runKey, nodeId);
|
|
59
|
+
entry.status = NodeRunStatus.SKIPPED;
|
|
60
|
+
entry.ports = [];
|
|
61
|
+
}
|
|
62
|
+
fail(runKey, nodeId, error) {
|
|
63
|
+
const entry = this.entry(runKey, nodeId);
|
|
64
|
+
entry.status = NodeRunStatus.FAILED;
|
|
65
|
+
entry.error = error;
|
|
66
|
+
entry.ports = [];
|
|
67
|
+
}
|
|
68
|
+
pause(runKey, nodeId, reason) {
|
|
69
|
+
const entry = this.entry(runKey, nodeId);
|
|
70
|
+
entry.status = NodeRunStatus.PAUSED;
|
|
71
|
+
entry.error = reason;
|
|
72
|
+
entry.ports = [];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Drop a paused node's claim so a recorded answer can re-run it.
|
|
76
|
+
*
|
|
77
|
+
* Not part of the interface: resuming a human gate is the host's decision and
|
|
78
|
+
* its storage's business. Provided here because the in-memory store is also
|
|
79
|
+
* what the tests resume through.
|
|
80
|
+
*/
|
|
81
|
+
release(runKey, nodeId) {
|
|
82
|
+
this.run(runKey).delete(nodeId);
|
|
83
|
+
}
|
|
84
|
+
run(runKey) {
|
|
85
|
+
let run = this.runs.get(runKey);
|
|
86
|
+
if (!run) {
|
|
87
|
+
run = /* @__PURE__ */ new Map();
|
|
88
|
+
this.runs.set(runKey, run);
|
|
89
|
+
}
|
|
90
|
+
return run;
|
|
91
|
+
}
|
|
92
|
+
/** The row for one node, created as CLAIMED if a driver never claimed it. */
|
|
93
|
+
entry(runKey, nodeId) {
|
|
94
|
+
const run = this.run(runKey);
|
|
95
|
+
let entry = run.get(nodeId);
|
|
96
|
+
if (!entry) {
|
|
97
|
+
entry = {
|
|
98
|
+
status: NodeRunStatus.CLAIMED,
|
|
99
|
+
ports: [],
|
|
100
|
+
attempts: 1,
|
|
101
|
+
firstAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
102
|
+
};
|
|
103
|
+
run.set(nodeId, entry);
|
|
104
|
+
}
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
function isSettled(status) {
|
|
109
|
+
return status !== void 0 && SETTLED.includes(status);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/durable/frontier.ts
|
|
113
|
+
function isNote(type) {
|
|
114
|
+
if (!type) return false;
|
|
115
|
+
if (type === "note") return true;
|
|
116
|
+
const kind = getNodeKind(type);
|
|
117
|
+
return kind ? kindIds(kind).includes("note") || kind.category === "annotation" : false;
|
|
118
|
+
}
|
|
119
|
+
var Frontier = {
|
|
120
|
+
compute(graph, state) {
|
|
121
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
122
|
+
for (const edge of graph.edges) {
|
|
123
|
+
const list = incoming.get(edge.target) ?? [];
|
|
124
|
+
list.push(edge);
|
|
125
|
+
incoming.set(edge.target, list);
|
|
126
|
+
}
|
|
127
|
+
const settled = /* @__PURE__ */ new Map();
|
|
128
|
+
const held = /* @__PURE__ */ new Set();
|
|
129
|
+
for (const [nodeId, entry] of Object.entries(state)) {
|
|
130
|
+
if (entry.status === NodeRunStatus.COMPLETED) settled.set(nodeId, entry.ports);
|
|
131
|
+
else if (isSettled(entry.status)) settled.set(nodeId, []);
|
|
132
|
+
else held.add(nodeId);
|
|
133
|
+
}
|
|
134
|
+
const ready = [];
|
|
135
|
+
const skipped = [];
|
|
136
|
+
let changed = true;
|
|
137
|
+
while (changed) {
|
|
138
|
+
changed = false;
|
|
139
|
+
for (const node of graph.nodes) {
|
|
140
|
+
const nodeId = node.id;
|
|
141
|
+
if (settled.has(nodeId) || held.has(nodeId) || ready.includes(nodeId)) continue;
|
|
142
|
+
const edges = incoming.get(nodeId) ?? [];
|
|
143
|
+
let blocked = false;
|
|
144
|
+
let active = false;
|
|
145
|
+
for (const edge of edges) {
|
|
146
|
+
if (!settled.has(edge.source)) {
|
|
147
|
+
blocked = true;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
if (settled.get(edge.source).includes(edge.sourceHandle ?? "out")) active = true;
|
|
151
|
+
}
|
|
152
|
+
if (blocked) continue;
|
|
153
|
+
if (edges.length > 0 && !active) {
|
|
154
|
+
settled.set(nodeId, []);
|
|
155
|
+
skipped.push(nodeId);
|
|
156
|
+
changed = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (isNote(node.type) || getNodeKind(node.type ?? "")?.category === "layout") {
|
|
160
|
+
settled.set(nodeId, []);
|
|
161
|
+
skipped.push(nodeId);
|
|
162
|
+
changed = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
ready.push(nodeId);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { ready, skipped };
|
|
169
|
+
},
|
|
170
|
+
/** Has every node settled? The run is finished when it has. */
|
|
171
|
+
isComplete(graph, state) {
|
|
172
|
+
return graph.nodes.every((node) => isSettled(state[node.id]?.status));
|
|
173
|
+
},
|
|
174
|
+
/**
|
|
175
|
+
* Is any node still held by a worker, or parked for a person?
|
|
176
|
+
*
|
|
177
|
+
* An empty frontier means something different depending on this: with work in
|
|
178
|
+
* flight the run is simply waiting, and whichever job finishes will advance
|
|
179
|
+
* it. With nothing in flight and nodes still unsettled, the graph cannot
|
|
180
|
+
* progress at all — which is a stuck run, and must be reported rather than
|
|
181
|
+
* waited on.
|
|
182
|
+
*/
|
|
183
|
+
hasWorkInFlight(state) {
|
|
184
|
+
return Object.values(state).some(
|
|
185
|
+
(entry) => entry.status === NodeRunStatus.CLAIMED || entry.status === NodeRunStatus.PAUSED
|
|
186
|
+
);
|
|
187
|
+
},
|
|
188
|
+
/** Persist the skip cascade so the next pass does not recompute it. */
|
|
189
|
+
async settleSkips(store, runKey, skipped) {
|
|
190
|
+
for (const nodeId of skipped) await store.skip(runKey, nodeId);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// src/durable/replay.ts
|
|
195
|
+
var BOUNDARY = "fancy-flow:node-boundary";
|
|
196
|
+
async function replayUpTo(graph, nodeId, executors, options = {}) {
|
|
197
|
+
const fenced = { ...executors };
|
|
198
|
+
for (const node of graph.nodes) {
|
|
199
|
+
if (node.id !== nodeId) {
|
|
200
|
+
fenced[node.id] = (ctx) => ctx.abort(BOUNDARY);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const ports = {};
|
|
204
|
+
const collect = (event) => {
|
|
205
|
+
var _a;
|
|
206
|
+
if (event.type === "node-output") {
|
|
207
|
+
(ports[_a = event.nodeId] ?? (ports[_a] = [])).push(event.portId);
|
|
208
|
+
}
|
|
209
|
+
options.onEvent?.(event);
|
|
210
|
+
};
|
|
211
|
+
const result = await runFlow(graph, fenced, collect, {
|
|
212
|
+
initialInputs: options.initialInputs ?? {},
|
|
213
|
+
resumeOutputs: options.resumeOutputs ?? {},
|
|
214
|
+
depth: options.depth ?? 0,
|
|
215
|
+
run: options.run
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
result,
|
|
219
|
+
ports,
|
|
220
|
+
outputOf: (id) => result.outputs[id],
|
|
221
|
+
portsOf: (id) => ports[id] ?? []
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function isBoundary(error) {
|
|
225
|
+
return error === BOUNDARY;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/durable/retry.ts
|
|
229
|
+
var UNSAFE_TO_REPLAY = "unsafe-to-replay";
|
|
230
|
+
var RetryPolicy = class _RetryPolicy {
|
|
231
|
+
constructor(options = {}) {
|
|
232
|
+
this.tries = options.tries ?? 1;
|
|
233
|
+
this.backoffSeconds = options.backoffSeconds ?? 0;
|
|
234
|
+
this.perKind = options.perKind ?? {};
|
|
235
|
+
}
|
|
236
|
+
triesFor(node) {
|
|
237
|
+
if (_RetryPolicy.isUnsafeToReplay(node)) return 1;
|
|
238
|
+
for (const id of idsFor(node)) {
|
|
239
|
+
if (id in this.perKind) return Math.max(1, Math.trunc(this.perKind[id]));
|
|
240
|
+
}
|
|
241
|
+
return Math.max(1, Math.trunc(this.tries));
|
|
242
|
+
}
|
|
243
|
+
backoffFor(node) {
|
|
244
|
+
if (_RetryPolicy.isUnsafeToReplay(node)) return 0;
|
|
245
|
+
return Math.max(0, this.backoffSeconds);
|
|
246
|
+
}
|
|
247
|
+
static isUnsafeToReplay(node) {
|
|
248
|
+
if (!node.type) return false;
|
|
249
|
+
return getNodeKind(node.type)?.sideEffects === UNSAFE_TO_REPLAY;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
function idsFor(node) {
|
|
253
|
+
if (!node.type) return [];
|
|
254
|
+
const kind = getNodeKind(node.type);
|
|
255
|
+
const canonical = resolveKindId(node.type);
|
|
256
|
+
const ordered = [node.type, ...canonical ? [canonical] : [], ...kind ? kindIds(kind) : []];
|
|
257
|
+
return [...new Set(ordered)];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/durable/human.ts
|
|
261
|
+
var NotAwaitingHuman = class extends Error {
|
|
262
|
+
constructor(message) {
|
|
263
|
+
super(message);
|
|
264
|
+
this.name = "NotAwaitingHuman";
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
var Submissions = class {
|
|
268
|
+
constructor() {
|
|
269
|
+
this.answers = /* @__PURE__ */ new Map();
|
|
270
|
+
/** The node the run is currently parked on, if any. */
|
|
271
|
+
this.awaiting = null;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Record an answer for the node the run is parked on.
|
|
275
|
+
*
|
|
276
|
+
* Throws when the run is not waiting on that node. A queued answer for a node
|
|
277
|
+
* that never paused is a write nobody reads — and it looks, from the outside,
|
|
278
|
+
* exactly like a submission that worked.
|
|
279
|
+
*/
|
|
280
|
+
record(nodeId, value) {
|
|
281
|
+
if (this.awaiting !== null && this.awaiting !== nodeId) {
|
|
282
|
+
throw new NotAwaitingHuman(
|
|
283
|
+
`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.`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (this.awaiting === null) {
|
|
287
|
+
throw new NotAwaitingHuman(
|
|
288
|
+
`This run is not waiting for anyone, so an answer for "${nodeId}" has nothing to resume.`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
this.answers.set(nodeId, value);
|
|
292
|
+
this.awaiting = null;
|
|
293
|
+
}
|
|
294
|
+
answered(nodeId) {
|
|
295
|
+
return this.answers.has(nodeId);
|
|
296
|
+
}
|
|
297
|
+
answer(nodeId) {
|
|
298
|
+
return this.answers.get(nodeId);
|
|
299
|
+
}
|
|
300
|
+
park(nodeId) {
|
|
301
|
+
this.awaiting = nodeId;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
function option(ctx, key, fallback) {
|
|
305
|
+
const config = ctx.node.data?.config ?? {};
|
|
306
|
+
return config[key] ?? fallback;
|
|
307
|
+
}
|
|
308
|
+
function durableUserInput(submissions) {
|
|
309
|
+
return (ctx) => {
|
|
310
|
+
if (submissions.answered(ctx.node.id)) return submissions.answer(ctx.node.id);
|
|
311
|
+
if (option(ctx, "autoAnswerFromInput") === true) {
|
|
312
|
+
const values = ctx.inputs.values;
|
|
313
|
+
if (values !== void 0) return values;
|
|
314
|
+
}
|
|
315
|
+
submissions.park(ctx.node.id);
|
|
316
|
+
return pauseForHuman(ctx, "input", {
|
|
317
|
+
title: option(ctx, "title", "Need your input"),
|
|
318
|
+
fields: option(ctx, "fields", [])
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function durableApproval(submissions) {
|
|
323
|
+
return (ctx) => {
|
|
324
|
+
const passthrough = ctx.inputs.in ?? ctx.inputs;
|
|
325
|
+
if (submissions.answered(ctx.node.id)) {
|
|
326
|
+
return { branch: truthy(submissions.answer(ctx.node.id)) ? "approved" : "denied", value: passthrough };
|
|
327
|
+
}
|
|
328
|
+
if (option(ctx, "autoAnswerFromInput") === true) {
|
|
329
|
+
const decision = ctx.inputs.approved;
|
|
330
|
+
if (decision !== void 0) {
|
|
331
|
+
return { branch: truthy(decision) ? "approved" : "denied", value: passthrough };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
submissions.park(ctx.node.id);
|
|
335
|
+
return pauseForHuman(ctx, "approval", {
|
|
336
|
+
title: option(ctx, "title", "Approve action"),
|
|
337
|
+
description: option(ctx, "description")
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/durable/coordinator.ts
|
|
343
|
+
var Coordinator = class {
|
|
344
|
+
constructor(options) {
|
|
345
|
+
this.graph = options.graph;
|
|
346
|
+
this.executors = options.executors;
|
|
347
|
+
this.run = RunIdentity.from(options.run);
|
|
348
|
+
this.store = options.store ?? new InMemoryClaimStore();
|
|
349
|
+
this.initialInputs = options.initialInputs ?? {};
|
|
350
|
+
this.retry = options.retry ?? new RetryPolicy();
|
|
351
|
+
this.onEvent = options.onEvent;
|
|
352
|
+
}
|
|
353
|
+
get runKey() {
|
|
354
|
+
return this.run.runKey;
|
|
355
|
+
}
|
|
356
|
+
// -- the two operations ----------------------------------------------
|
|
357
|
+
/**
|
|
358
|
+
* Which nodes may be dispatched right now.
|
|
359
|
+
*
|
|
360
|
+
* Also settles the skip cascade, because a skip is a decision the frontier
|
|
361
|
+
* just made and a second caller must not make it again.
|
|
362
|
+
*/
|
|
363
|
+
async advance() {
|
|
364
|
+
const frontier = Frontier.compute(this.graph, await this.store.state(this.runKey));
|
|
365
|
+
await Frontier.settleSkips(this.store, this.runKey, frontier.skipped);
|
|
366
|
+
return frontier.ready;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Claim, execute and checkpoint one node.
|
|
370
|
+
*
|
|
371
|
+
* The claim is taken FIRST. Two workers racing for the same node produce one
|
|
372
|
+
* execution and one no-op, and the loser learns that from the store rather
|
|
373
|
+
* than from a duplicate side effect.
|
|
374
|
+
*
|
|
375
|
+
* `owner` is the token that lets a job's own retry re-enter its claim instead
|
|
376
|
+
* of deadlocking against the row it wrote itself — pass the SAME token across
|
|
377
|
+
* a job's attempts.
|
|
378
|
+
*/
|
|
379
|
+
async runNode(nodeId, owner) {
|
|
380
|
+
const token = owner ?? cryptoRandom();
|
|
381
|
+
if (!await this.store.claim(this.runKey, nodeId, token)) {
|
|
382
|
+
return { nodeId, status: "not-claimed", claimed: false, attempt: 0 };
|
|
383
|
+
}
|
|
384
|
+
const row = (await this.store.state(this.runKey))[nodeId];
|
|
385
|
+
const identity = this.identityFor(row);
|
|
386
|
+
const replay = await replayUpTo(this.graph, nodeId, this.executors, {
|
|
387
|
+
resumeOutputs: await this.completedOutputs(),
|
|
388
|
+
initialInputs: this.initialInputs,
|
|
389
|
+
onEvent: this.forward(nodeId),
|
|
390
|
+
run: identity
|
|
391
|
+
});
|
|
392
|
+
const result = replay.result;
|
|
393
|
+
const attempt = identity.attempt;
|
|
394
|
+
if (Object.prototype.hasOwnProperty.call(result.outputs, nodeId)) {
|
|
395
|
+
const output = result.outputs[nodeId];
|
|
396
|
+
const ports = replay.portsOf(nodeId);
|
|
397
|
+
await this.store.complete(this.runKey, nodeId, output, ports);
|
|
398
|
+
return { nodeId, status: "completed", output, ports, claimed: true, attempt };
|
|
399
|
+
}
|
|
400
|
+
const pause = decodePause(result.error);
|
|
401
|
+
if (pause && pause.nodeId === nodeId) {
|
|
402
|
+
await this.store.pause(this.runKey, nodeId, result.error ?? "");
|
|
403
|
+
return { nodeId, status: "paused", pause, claimed: true, attempt };
|
|
404
|
+
}
|
|
405
|
+
if (isBoundary(result.error)) {
|
|
406
|
+
await this.store.skip(this.runKey, nodeId);
|
|
407
|
+
return {
|
|
408
|
+
nodeId,
|
|
409
|
+
status: "skipped",
|
|
410
|
+
error: "replay stopped before reaching this node",
|
|
411
|
+
claimed: true,
|
|
412
|
+
attempt
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const error = result.error ?? `node ${nodeId} produced no output and no error`;
|
|
416
|
+
const node = this.graph.nodes.find((n) => n.id === nodeId);
|
|
417
|
+
const tries = node ? this.retry.triesFor(node) : 1;
|
|
418
|
+
if (attempt < tries) {
|
|
419
|
+
return { nodeId, status: "failed", error, claimed: true, attempt, retryable: true };
|
|
420
|
+
}
|
|
421
|
+
await this.store.fail(this.runKey, nodeId, error);
|
|
422
|
+
return { nodeId, status: "failed", error, claimed: true, attempt, retryable: false };
|
|
423
|
+
}
|
|
424
|
+
// -- an in-process driver over the two ------------------------------
|
|
425
|
+
/**
|
|
426
|
+
* Drive the graph here, in this process, one node at a time.
|
|
427
|
+
*
|
|
428
|
+
* Every checkpoint is written exactly as a queued run writes it, so a crash
|
|
429
|
+
* mid-loop resumes from the same place a crashed worker would.
|
|
430
|
+
*
|
|
431
|
+
* Retries honour {@link RetryPolicy}: a node declaring `unsafe-to-replay`
|
|
432
|
+
* gets one attempt whatever the policy says, and the retry re-enters the same
|
|
433
|
+
* claim with the same owner token — so the step key it derives is unchanged,
|
|
434
|
+
* which is what makes the retry idempotent rather than duplicative.
|
|
435
|
+
*/
|
|
436
|
+
async runToCompletion(maxPasses = 1e4) {
|
|
437
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
438
|
+
const ready = await this.advance();
|
|
439
|
+
if (ready.length === 0) break;
|
|
440
|
+
for (const nodeId of ready) {
|
|
441
|
+
const outcome = await this.runNodeWithRetries(nodeId);
|
|
442
|
+
if (outcome.status === "paused") {
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
outputs: await this.outputs(),
|
|
446
|
+
pause: outcome.pause,
|
|
447
|
+
paused: true
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (outcome.status === "failed") {
|
|
451
|
+
return { ok: false, outputs: await this.outputs(), error: outcome.error, paused: false };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const state = await this.store.state(this.runKey);
|
|
456
|
+
if (!Frontier.isComplete(this.graph, state)) {
|
|
457
|
+
if (Frontier.hasWorkInFlight(state)) {
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
outputs: await this.outputs(),
|
|
461
|
+
error: "the run is waiting on work held elsewhere",
|
|
462
|
+
paused: false
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const unsettled = this.graph.nodes.filter((n) => !["completed", "skipped", "failed"].includes(state[n.id]?.status ?? "")).map((n) => n.id);
|
|
466
|
+
return {
|
|
467
|
+
ok: false,
|
|
468
|
+
outputs: await this.outputs(),
|
|
469
|
+
error: `the run cannot progress; unsettled nodes: ${unsettled.join(", ")}`,
|
|
470
|
+
paused: false
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
const failed = Object.values(state).filter((e) => e.status === NodeRunStatus.FAILED).map((e) => e.error ?? "node failed");
|
|
474
|
+
return {
|
|
475
|
+
ok: failed.length === 0,
|
|
476
|
+
outputs: await this.outputs(),
|
|
477
|
+
error: failed[0],
|
|
478
|
+
paused: false
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Checkpointed outputs, in the graph's own node order.
|
|
483
|
+
*
|
|
484
|
+
* Ordered by the graph rather than by completion so two runs of the same
|
|
485
|
+
* workflow produce comparable output maps even when nodes finished in a
|
|
486
|
+
* different order.
|
|
487
|
+
*/
|
|
488
|
+
async outputs() {
|
|
489
|
+
const state = await this.store.state(this.runKey);
|
|
490
|
+
const out = {};
|
|
491
|
+
for (const node of this.graph.nodes) {
|
|
492
|
+
if (state[node.id]?.status === NodeRunStatus.COMPLETED) out[node.id] = state[node.id].output;
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
/** The checkpointed run, in the shape a single-process run returns. */
|
|
497
|
+
async asRunResult() {
|
|
498
|
+
const outcome = await this.runToCompletion();
|
|
499
|
+
return outcome.error === void 0 ? { ok: outcome.ok, outputs: outcome.outputs } : { ok: outcome.ok, outputs: outcome.outputs, error: outcome.error };
|
|
500
|
+
}
|
|
501
|
+
// -- internals -------------------------------------------------------
|
|
502
|
+
async runNodeWithRetries(nodeId) {
|
|
503
|
+
const owner = cryptoRandom();
|
|
504
|
+
let outcome = await this.runNode(nodeId, owner);
|
|
505
|
+
while (outcome.retryable) {
|
|
506
|
+
outcome = await this.runNode(nodeId, owner);
|
|
507
|
+
}
|
|
508
|
+
return outcome;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The identity handed to the node about to execute.
|
|
512
|
+
*
|
|
513
|
+
* `attempt` and `firstAttemptAt` come off the CLAIM ROW, so they describe
|
|
514
|
+
* this step rather than the run — which is what makes the retry window check
|
|
515
|
+
* exact instead of conservative.
|
|
516
|
+
*/
|
|
517
|
+
identityFor(row) {
|
|
518
|
+
if (!row) return this.run;
|
|
519
|
+
return this.run.withAttempt(row.attempts, row.firstAttemptAt);
|
|
520
|
+
}
|
|
521
|
+
async completedOutputs() {
|
|
522
|
+
const state = await this.store.state(this.runKey);
|
|
523
|
+
const out = {};
|
|
524
|
+
for (const [nodeId, entry] of Object.entries(state)) {
|
|
525
|
+
if (entry.status === NodeRunStatus.COMPLETED) out[nodeId] = entry.output;
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Forward only the events the target node produced.
|
|
531
|
+
*
|
|
532
|
+
* A replay re-emits the whole completed prefix. Passing that through would
|
|
533
|
+
* show a consumer every node running again on every job — the run feed would
|
|
534
|
+
* report a 20-node workflow as 200 status changes.
|
|
535
|
+
*/
|
|
536
|
+
forward(nodeId) {
|
|
537
|
+
const sink = this.onEvent;
|
|
538
|
+
if (!sink) return void 0;
|
|
539
|
+
return (event) => {
|
|
540
|
+
const id = "nodeId" in event ? event.nodeId : void 0;
|
|
541
|
+
if (id === void 0 || id === nodeId) sink(event);
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
function cryptoRandom() {
|
|
546
|
+
const g = globalThis;
|
|
547
|
+
return g.crypto?.randomUUID?.() ?? `own_${Math.random().toString(36).slice(2)}${Date.now()}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export { BOUNDARY, Coordinator, Frontier, InMemoryClaimStore, NodeRunStatus, NotAwaitingHuman, RetryPolicy, SETTLED, Submissions, UNSAFE_TO_REPLAY, durableApproval, durableUserInput, isBoundary, isSettled, replayUpTo };
|
|
551
|
+
//# sourceMappingURL=durable.js.map
|
|
552
|
+
//# sourceMappingURL=durable.js.map
|