@jarenjs/flow 0.72.3 → 0.73.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.
@@ -0,0 +1,461 @@
1
+ //@ts-check
2
+ /** Deterministic lowering and a persistence bridge over the two flow engines.
3
+ * The statechart selects control; compileDag owns node scheduling/concurrency.
4
+ * See docs/WORKFLOW-FORMAT.md for the document and compare-and-swap store. */
5
+ import { deepFreeze, isJsonObject, setObjectMember } from '@jarenjs/core/object';
6
+ import { hashContent } from '@jarenjs/core/string';
7
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
8
+ import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
9
+ import { compileJsonQuery } from '@jarenjs/json/query';
10
+ import { compileDag } from './dag.js';
11
+ import { compileStatechart } from './statechart.js';
12
+ import { asError, FlowCompileError, FlowRuntimeError } from './errors.js';
13
+
14
+ const NEXT = '@workflow/next';
15
+ const DONE = '@workflow/done';
16
+ const ERROR = '@workflow/error';
17
+ const clone = (value) => JSON.parse(canonicalizeJson(value));
18
+ const fail = (reason, path) => { throw new FlowCompileError('JF0021', reason, path); };
19
+ const malformed = (reason) => { throw new FlowRuntimeError('JF2016', reason); };
20
+ const nonblank = (s) => typeof s === 'string' && s.trim() !== '';
21
+
22
+ /** @typedef {{version: string, revision: string, document: any, fsm: any,
23
+ * dags: Record<string, any>, sources: Record<string, string>, specs: Record<string, any>}} LoweredWorkflow */
24
+ /** @typedef {{format: string, runId: string, identity: any, generation: number,
25
+ * control: import('./statechart.js').StatechartState,
26
+ * context: {input: any, data: any, event: null | {type: string, payload?: any}, results: Record<string, any>, visits: Record<string, number>},
27
+ * pending: null | {state: string, visit: number, input: any, values: Record<string, any>, identity?: any, result?: any},
28
+ * status: 'running'|'waiting'|'done'}} WorkflowSnapshot */
29
+ /** @typedef {{status: 'waiting'|'done', result: any, snapshot: WorkflowSnapshot}} WorkflowResult */
30
+ /** @typedef {{lowered: LoweredWorkflow, revisions: {control: string, dags: Record<string, string>},
31
+ * taskVersions: Readonly<Record<string, string>>,
32
+ * run: (input: any, opts: WorkflowRunOptions) => Promise<WorkflowResult>}} CompiledWorkflow */
33
+
34
+ /** Lower one neutral workflow to inspectable JSON, without resolving host tasks.
35
+ * Work states carry work:{task,version,with?} or work:{dag}; choose/on/flow/final
36
+ * are the other mutually exclusive control forms. @param {any} document
37
+ * @returns {LoweredWorkflow} */
38
+ export function lowerWorkflow(document) {
39
+ let doc;
40
+ try { doc = clone(document); }
41
+ catch (err) { throw new FlowCompileError('JF0021', 'workflow must be JSON', '', asError(err)); }
42
+ if (!isJsonObject(doc) || doc.$workflow !== '0.2' || !nonblank(doc.revision))
43
+ fail('a workflow requires "$workflow": "0.2" and a nonblank revision', '');
44
+ for (const key of Object.keys(doc)) if (!['$workflow', 'revision', 'initial', 'states'].includes(key)) fail('unknown workflow member', `/${key}`);
45
+ const states = [];
46
+ const transitions = [];
47
+ const dags = {};
48
+ const sources = {};
49
+ const specs = {};
50
+ const automatic = new Map();
51
+ const bounded = new Set();
52
+
53
+ function lower(body, prefix, parent, continuation, depth) {
54
+ if (depth > 64) fail('nested workflow depth exceeds 64', prefix);
55
+ if (!isJsonObject(body) || !isJsonObject(body.states) || !Object.keys(body.states).length)
56
+ fail('states must be a non-empty object', `${prefix}/states`);
57
+ if (parent !== null) for (const key of Object.keys(body)) if (!['initial', 'states'].includes(key)) fail('unknown nested flow member', `${prefix}/${key}`);
58
+ if (typeof body.initial !== 'string' || !Object.hasOwn(body.states, body.initial))
59
+ fail('initial must name a state in this scope', `${prefix}/initial`);
60
+ const idOf = (name) => {
61
+ if (typeof name !== 'string' || !Object.hasOwn(body.states, name))
62
+ fail(`undeclared state '${String(name)}' in this scope`, `${prefix}/states`);
63
+ return `${prefix}/states/${encodeJSONPointerSegment(name)}`;
64
+ };
65
+ for (const [name, spec] of Object.entries(body.states)) {
66
+ const id = idOf(name);
67
+ if (!name || !isJsonObject(spec)) fail('a state needs a non-empty id and an object declaration', id);
68
+ const forms = ['work', 'choose', 'on', 'flow', 'final'].filter((key) => Object.hasOwn(spec, key));
69
+ if (forms.length !== 1 || (forms[0] === 'final' && spec.final !== true))
70
+ fail('a state declares exactly one of work, choose, on, flow or final:true', id);
71
+ const kind = forms[0];
72
+ const allowed = {
73
+ work: ['work', 'input', 'then', 'catch', 'limit'],
74
+ choose: ['choose', 'otherwise', 'limit'],
75
+ on: ['on', 'after', 'limit'],
76
+ flow: ['flow', 'then', 'limit'],
77
+ final: ['final', 'limit'],
78
+ }[kind];
79
+ for (const key of Object.keys(spec)) if (!allowed.includes(key)) fail(`unexpected '${key}' on ${kind} state`, `${id}/${key}`);
80
+ if (spec.limit !== undefined && (!Number.isSafeInteger(spec.limit) || spec.limit < 1))
81
+ fail('limit must be a positive safe integer', `${id}/limit`);
82
+ if (spec.limit !== undefined) bounded.add(id);
83
+ setObjectMember(specs, id, { ...spec, kind });
84
+ setObjectMember(sources, id, id);
85
+ const state = { id, ...(parent ? { parent } : {}) };
86
+ const links = [];
87
+ automatic.set(id, links);
88
+ const edge = (to, fields, auto = true) => {
89
+ const target = idOf(to);
90
+ transitions.push({ from: id, to: target, ...fields });
91
+ if (auto) links.push(target);
92
+ };
93
+ if (kind === 'work') {
94
+ if (!isJsonObject(spec.work) || (Object.hasOwn(spec.work, 'task') === Object.hasOwn(spec.work, 'dag')))
95
+ fail('work must declare either task or dag', `${id}/work`);
96
+ let dag;
97
+ if (Object.hasOwn(spec.work, 'task')) {
98
+ if (!nonblank(spec.work.task) || !nonblank(spec.work.version))
99
+ fail('a task needs a nonblank task name and version', `${id}/work`);
100
+ for (const k of Object.keys(spec.work)) if (!['task', 'version', 'with'].includes(k)) fail('unknown task member', `${id}/work/${k}`);
101
+ dag = { $dag: '0.1', nodes: {
102
+ input: { kind: 'input' },
103
+ task: { kind: 'task', run: spec.work.task, version: spec.work.version, checkpoint: true,
104
+ ...(Object.hasOwn(spec.work, 'with') ? { with: spec.work.with } : {}) },
105
+ output: { kind: 'output', checkpoint: true },
106
+ }, edges: [{ from: 'input', to: 'task' }, { from: 'task', to: 'output' }] };
107
+ }
108
+ else {
109
+ if (Object.keys(spec.work).length !== 1 || !isJsonObject(spec.work.dag)) fail('work.dag must be a DAG document', `${id}/work/dag`);
110
+ dag = clone(spec.work.dag);
111
+ // Completion is a serialization boundary even if the author elects
112
+ // to recompute intermediate nodes. This requires all task versions.
113
+ for (const node of Object.values(dag.nodes ?? {})) if (node?.kind === 'output') node.checkpoint = true;
114
+ }
115
+ setObjectMember(dags, id, dag);
116
+ edge(spec.then, { event: DONE });
117
+ if (spec.catch !== undefined) edge(spec.catch, { event: ERROR });
118
+ }
119
+ else if (kind === 'choose') {
120
+ if (!Array.isArray(spec.choose) || !spec.choose.length) fail('choose needs at least one guarded branch', `${id}/choose`);
121
+ for (const [i, branch] of spec.choose.entries()) {
122
+ if (!isJsonObject(branch) || !Object.hasOwn(branch, 'guard') || Object.keys(branch).some((k) => !['guard', 'to'].includes(k)))
123
+ fail('a choice is {guard,to}', `${id}/choose/${i}`);
124
+ edge(branch.to, { event: NEXT, guard: branch.guard });
125
+ }
126
+ edge(spec.otherwise, { event: NEXT });
127
+ }
128
+ else if (kind === 'on') {
129
+ if (!Array.isArray(spec.on) || (!spec.on.length && spec.after === undefined)) fail('a wait needs events or a delay', `${id}/on`);
130
+ for (const [i, branch] of spec.on.entries()) {
131
+ if (!isJsonObject(branch) || !nonblank(branch.event) || Object.keys(branch).some((k) => !['event', 'to', 'guard'].includes(k)))
132
+ fail('a wait branch is {event,to,guard?}', `${id}/on/${i}`);
133
+ edge(branch.to, { event: `event:${branch.event}`, ...(Object.hasOwn(branch, 'guard') ? { guard: branch.guard } : {}) }, false);
134
+ }
135
+ if (spec.after !== undefined) {
136
+ if (!isJsonObject(spec.after) || !Number.isFinite(spec.after.ms) || spec.after.ms < 0
137
+ || Object.keys(spec.after).some((k) => !['ms', 'to'].includes(k))) fail('after is {ms,to} with a nonnegative finite duration', `${id}/after`);
138
+ edge(spec.after.to, { after: spec.after.ms });
139
+ }
140
+ }
141
+ else if (kind === 'flow') {
142
+ const next = idOf(spec.then);
143
+ state.initial = lower(spec.flow, `${id}/flow`, id, next, depth + 1);
144
+ links.push(state.initial);
145
+ transitions.push({ from: id, to: next, done: true });
146
+ }
147
+ else {
148
+ state.final = true;
149
+ if (continuation !== null) links.push(continuation);
150
+ }
151
+ states.push(state);
152
+ }
153
+ return idOf(body.initial);
154
+ }
155
+ const initial = lower(doc, '', null, null, 0);
156
+ // Every automatically traversable cycle must cross a finite visit budget.
157
+ // External event edges do not run by themselves and need no invented limit.
158
+ const visiting = new Set();
159
+ const visited = new Set();
160
+ const prove = (id) => {
161
+ if (bounded.has(id) || visited.has(id)) return;
162
+ if (visiting.has(id)) throw new FlowCompileError('JF0022', 'automatic cycle requires a state limit', sources[id]);
163
+ visiting.add(id);
164
+ for (const next of automatic.get(id) ?? []) prove(next);
165
+ visiting.delete(id); visited.add(id);
166
+ };
167
+ for (const id of automatic.keys()) prove(id);
168
+ const fsm = { $fsm: '0.2', initial, states, transitions };
169
+ compileStatechart(fsm);
170
+ return deepFreeze({ version: '0.1', revision: doc.revision, document: doc, fsm, dags, sources, specs });
171
+ }
172
+
173
+ /** @typedef {{ load: (runId: string) => WorkflowSnapshot | null | Promise<WorkflowSnapshot | null>,
174
+ * save: (runId: string, snapshot: WorkflowSnapshot, expectedGeneration: number) => boolean | Promise<boolean> }} WorkflowStore */
175
+ /** @typedef {{ runId: string, snapshot?: any, expectedGeneration?: number,
176
+ * signal?: AbortSignal, now?: number, event?: {type: string, payload?: any},
177
+ * onTrace?: (record: any) => void }} WorkflowRunOptions */
178
+ /** Compile once; each run owns its control and checkpoint records.
179
+ * Store save MUST atomically compare expectedGeneration (0 means absent).
180
+ * @param {any} document
181
+ * @param {{tasks?: NonNullable<Parameters<typeof compileDag>[1]>['tasks'], store?: WorkflowStore}} [options]
182
+ * @returns {CompiledWorkflow}
183
+ */
184
+ export function compileWorkflow(document, options = {}) {
185
+ const lowered = lowerWorkflow(document);
186
+ const chart = compileStatechart(lowered.fsm);
187
+ const store = options.store;
188
+ if (store && (typeof store.load !== 'function' || typeof store.save !== 'function'))
189
+ throw new TypeError('workflow store requires load and compare-and-swap save');
190
+ const routes = new Map();
191
+ const running = new Set();
192
+ const dags = new Map();
193
+ const inputs = new Map();
194
+ const taskVersions = {};
195
+ const revisions = { control: hashContent(canonicalizeJson(lowered.fsm)), dags: {} };
196
+ const route = (key) => {
197
+ const r = routes.get(key);
198
+ if (!r) throw new FlowRuntimeError('JF2014', 'DAG activation no longer belongs to a live workflow run');
199
+ return r;
200
+ };
201
+ for (const [id, doc] of Object.entries(lowered.dags)) {
202
+ const revision = hashContent(canonicalizeJson(doc));
203
+ setObjectMember(revisions.dags, id, revision);
204
+ const dag = compileDag(doc, { tasks: options.tasks, revision, checkpoint: {
205
+ load: (key, identity) => route(key).load(identity),
206
+ save: (key, node, value) => route(key).save(node, value),
207
+ complete: (key, result) => route(key).complete(result),
208
+ } });
209
+ dags.set(id, dag);
210
+ for (const [node, version] of Object.entries(dag.taskVersions))
211
+ setObjectMember(taskVersions, `${id}/${node}`, version);
212
+ try { inputs.set(id, compileJsonQuery(lowered.specs[id].input ?? '$.context.data')); }
213
+ catch (err) { throw new FlowCompileError('JF0014', 'workflow input query failed to compile', `${id}/input`, asError(err)); }
214
+ }
215
+ const versions = deepFreeze(Object.fromEntries(Object.entries(taskVersions).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)));
216
+ const identity = deepFreeze({ document: canonicalizeJson(lowered.document), lowering: lowered.version,
217
+ control: canonicalizeJson(lowered.fsm), dags: canonicalizeJson(lowered.dags), taskVersions: versions });
218
+ deepFreeze(revisions);
219
+
220
+ /** @param {any} input @param {WorkflowRunOptions} opts @returns {Promise<WorkflowResult>} */
221
+ async function run(input, opts) {
222
+ if (!nonblank(opts?.runId)) throw new TypeError('workflow run requires a nonblank runId');
223
+ if (opts.snapshot !== undefined && store) throw new TypeError('use either snapshot or a store, not both');
224
+ if (opts.event !== undefined && (!isJsonObject(opts.event) || !nonblank(opts.event.type)))
225
+ throw new TypeError('event requires a nonblank type');
226
+ if (opts.onTrace !== undefined && typeof opts.onTrace !== 'function') throw new TypeError('onTrace must be a function');
227
+ if (opts.signal !== undefined && typeof opts.signal?.addEventListener !== 'function') throw new TypeError('signal must be an AbortSignal');
228
+ if (opts.expectedGeneration !== undefined && (!Number.isSafeInteger(opts.expectedGeneration) || opts.expectedGeneration < 0))
229
+ throw new TypeError('expectedGeneration must be a nonnegative safe integer');
230
+ const runId = opts.runId;
231
+ if (running.has(runId)) throw new FlowRuntimeError('JF2014', `workflow '${runId}' is already running`);
232
+ running.add(runId);
233
+ const controller = new AbortController();
234
+ let live = true;
235
+ let rejectAborted;
236
+ const aborted = new Promise((_, reject) => { rejectAborted = reject; });
237
+ aborted.catch(() => {});
238
+ const abort = () => {
239
+ controller.abort(opts.signal?.reason);
240
+ rejectAborted(new FlowRuntimeError('JF2007', 'workflow run aborted'));
241
+ };
242
+ if (opts.signal?.aborted) abort();
243
+ else opts.signal?.addEventListener('abort', abort, { once: true });
244
+ const ensureLive = () => {
245
+ if (!live || controller.signal.aborted) throw new FlowRuntimeError('JF2007', 'workflow activation is no longer live');
246
+ };
247
+ const wait = (value) => Promise.race([value, aborted]);
248
+ const observe = (record) => {
249
+ try { opts.onTrace?.(deepFreeze({ runId, revision: lowered.revision, revisions, ...record })); }
250
+ catch { /* Observers cannot alter a run. */ }
251
+ };
252
+ let snapshot;
253
+ let writes = Promise.resolve();
254
+ // Node settlements use one serial CAS stream, so concurrent fan-in cannot
255
+ // overwrite a sibling's checkpoint. Failed writes poison this activation.
256
+ const persist = (mutate = undefined) => {
257
+ writes = writes.then(async () => {
258
+ ensureLive();
259
+ const next = clone(snapshot);
260
+ mutate?.(next);
261
+ if (!Number.isSafeInteger(next.generation + 1)) malformed('snapshot generation exhausted');
262
+ next.generation++;
263
+ deepFreeze(next);
264
+ if (store) {
265
+ let saved;
266
+ try { saved = await wait(store.save(runId, next, snapshot.generation)); }
267
+ catch (err) {
268
+ if (controller.signal.aborted) throw err;
269
+ throw new FlowRuntimeError('JF2009', 'workflow store save failed', '', asError(err));
270
+ }
271
+ if (saved !== true) throw new FlowRuntimeError('JF2014', 'workflow snapshot generation is stale');
272
+ }
273
+ ensureLive();
274
+ snapshot = next;
275
+ });
276
+ writes.catch(() => {});
277
+ return writes;
278
+ };
279
+ const admit = (next, entered) => {
280
+ for (const id of entered) {
281
+ const visits = (next.context.visits[id] ?? 0) + 1;
282
+ if (!Number.isSafeInteger(visits) || visits > (lowered.specs[id].limit ?? Infinity))
283
+ throw new FlowRuntimeError('JF2015', `state '${id}' exceeded its visit limit`, lowered.sources[id]);
284
+ setObjectMember(next.context.visits, id, visits);
285
+ }
286
+ };
287
+ const apply = async (r, mutate = undefined) => {
288
+ if (r.errors.length) throw new FlowRuntimeError('JF2016', r.errors[0].message, r.errors[0].docPath);
289
+ await persist((next) => {
290
+ mutate?.(next);
291
+ next.control = r.state;
292
+ next.status = r.final ? 'done' : 'running';
293
+ admit(next, r.entered);
294
+ });
295
+ observe({ type: 'transition', transitions: r.transitions, entered: r.entered, exited: r.exited,
296
+ active: r.state.active, generation: snapshot.generation });
297
+ };
298
+ const scope = () => scopeForSaved(snapshot);
299
+ const step = (event, payload = null, context = snapshot.context) => chart.step(snapshot.control, event,
300
+ { context, payload });
301
+ function validate(saved, originalInput) {
302
+ if (!isJsonObject(saved) || saved.format !== 'jaren-workflow-run/0.1' || saved.runId !== runId
303
+ || !Number.isSafeInteger(saved.generation) || saved.generation < 1
304
+ || !['running', 'waiting', 'done'].includes(saved.status) || !isJsonObject(saved.context)
305
+ || !isJsonObject(saved.context.results) || !isJsonObject(saved.context.visits)
306
+ || !Object.hasOwn(saved.context, 'data') || !Object.hasOwn(saved.context, 'input')
307
+ || (saved.context.event !== null && (!isJsonObject(saved.context.event) || !nonblank(saved.context.event.type)))) malformed('invalid workflow snapshot');
308
+ if (canonicalizeJson(saved.identity ?? null) !== canonicalizeJson(identity)
309
+ || canonicalizeJson(saved.context.input) !== canonicalizeJson(originalInput))
310
+ throw new FlowRuntimeError('JF2013', 'workflow, lowering, input or task identity changed');
311
+ chart.restore(saved.control);
312
+ for (const [id, count] of Object.entries(saved.context.visits)) {
313
+ if (!Object.hasOwn(lowered.specs, id) || !Number.isSafeInteger(count) || count < 1
314
+ || count > (lowered.specs[id].limit ?? Infinity)) malformed('invalid visit record');
315
+ }
316
+ for (const id of Object.keys(saved.context.results)) if (!dags.has(id)) malformed('unknown work result');
317
+ if (saved.control.active.length !== 1 || !saved.context.visits[saved.control.active[0]]) malformed('workflow needs one admitted active leaf');
318
+ const active = saved.control.active[0];
319
+ if ((saved.status === 'done') !== chart.final(saved.control)
320
+ || (saved.status === 'waiting' && lowered.specs[active].kind !== 'on')) malformed('snapshot status disagrees with control');
321
+ if (saved.pending !== null) {
322
+ const p = saved.pending;
323
+ if (!isJsonObject(p) || p.state !== active || !dags.has(active)
324
+ || p.visit !== saved.context.visits[active] || !isJsonObject(p.values)) malformed('invalid pending activation');
325
+ for (const id of Object.keys(p.values)) if (lowered.dags[active].nodes[id]?.checkpoint !== true) malformed('undeclared checkpoint node');
326
+ if (canonicalizeJson(p.input) !== canonicalizeJson(inputs.get(active)(scopeForSaved(saved)) ?? null))
327
+ malformed('pending work input disagrees with its control context');
328
+ if (p.identity !== undefined) {
329
+ const expected = { revision: revisions.dags[active], document: canonicalizeJson(lowered.dags[active]),
330
+ input: canonicalizeJson(p.input), taskVersions: dags.get(active).taskVersions };
331
+ if (canonicalizeJson(p.identity) !== canonicalizeJson(expected))
332
+ throw new FlowRuntimeError('JF2013', 'pending DAG provenance changed');
333
+ }
334
+ else if (Object.keys(p.values).length || Object.hasOwn(p, 'result')) malformed('pending values require DAG provenance');
335
+ }
336
+ }
337
+ function scopeForSaved(saved) {
338
+ const state = saved.control.active[0];
339
+ const visit = saved.context.visits[state];
340
+ return { state: saved.control.active, event: null, payload: null,
341
+ context: { ...saved.context, activation: { runId, state, visit,
342
+ key: canonicalizeJson([runId, state, visit]) } } };
343
+ }
344
+ try {
345
+ ensureLive();
346
+ const originalInput = clone(input ?? null);
347
+ let saved;
348
+ try { saved = store ? await wait(store.load(runId)) : opts.snapshot; }
349
+ catch (err) {
350
+ if (controller.signal.aborted) throw err;
351
+ throw new FlowRuntimeError('JF2009', 'workflow store load failed', '', asError(err));
352
+ }
353
+ if (saved != null) {
354
+ saved = clone(saved);
355
+ validate(saved, originalInput);
356
+ snapshot = saved;
357
+ }
358
+ else {
359
+ const context = { input: originalInput, data: originalInput, event: null, results: {}, visits: {} };
360
+ const start = chart.start({ now: opts.now ?? 0, context });
361
+ snapshot = { format: 'jaren-workflow-run/0.1', runId, identity, generation: 0,
362
+ control: start.state, context, pending: null, status: start.final ? 'done' : 'running' };
363
+ admit(snapshot, start.entered);
364
+ }
365
+ if (opts.expectedGeneration !== undefined && opts.expectedGeneration !== snapshot.generation)
366
+ throw new FlowRuntimeError('JF2014', 'event or resume observed a stale generation');
367
+ if (opts.now !== undefined) {
368
+ if (!Number.isFinite(opts.now) || opts.now < snapshot.control.time)
369
+ throw new FlowRuntimeError('JF2011', 'workflow time must be finite and cannot move backwards');
370
+ // A resumed computation starts at the supplied wake-up time. Waiting
371
+ // control instead processes its saved deadlines at their logical time.
372
+ if (snapshot.status !== 'done' && lowered.specs[snapshot.control.active[0]].kind !== 'on')
373
+ snapshot.control = chart.step(snapshot.control, '@workflow/clock', { now: opts.now, context: snapshot.context }).state;
374
+ }
375
+ // Claim this generation before launching work. A competing process or
376
+ // a late write from a crashed activation can win only one CAS.
377
+ await persist();
378
+ let event = opts.event;
379
+ while (snapshot.status !== 'done') {
380
+ ensureLive();
381
+ const id = snapshot.control.active[0];
382
+ const spec = lowered.specs[id];
383
+ if (spec.kind === 'work') {
384
+ if (snapshot.pending === null) {
385
+ let workInput;
386
+ try { workInput = clone(inputs.get(id)(scope()) ?? null); }
387
+ catch (err) { throw new FlowRuntimeError('JF2016', 'work input evaluation failed', `${id}/input`, asError(err)); }
388
+ await persist((next) => { next.pending = { state: id, visit: next.context.visits[id], input: workInput, values: {} }; });
389
+ }
390
+ const pending = snapshot.pending;
391
+ const key = canonicalizeJson([runId, id, pending.visit]);
392
+ const bound = () => {
393
+ ensureLive();
394
+ if (snapshot.pending?.state !== id || snapshot.pending?.visit !== pending.visit)
395
+ throw new FlowRuntimeError('JF2014', 'stale DAG activation');
396
+ };
397
+ routes.set(key, {
398
+ async load(dagIdentity) {
399
+ bound();
400
+ if (snapshot.pending.identity === undefined)
401
+ await persist((next) => { next.pending.identity = dagIdentity; });
402
+ return { identity: snapshot.pending.identity, values: clone(snapshot.pending.values) };
403
+ },
404
+ save(node, value) { bound(); return persist((next) => { setObjectMember(next.pending.values, node, value); }); },
405
+ complete(result) { bound(); return persist((next) => { next.pending.result = result; }); },
406
+ });
407
+ let result;
408
+ let error = null;
409
+ try {
410
+ result = Object.hasOwn(pending, 'result') ? pending.result
411
+ : await wait(dags.get(id).run(clone(pending.input), { runId: key, signal: controller.signal,
412
+ onNode: (record) => observe({ type: 'node', state: id, activation: pending.visit, ...record }) }));
413
+ }
414
+ catch (err) {
415
+ if (spec.catch === undefined || err?.code !== 'JF2006') throw err;
416
+ error = { code: err.code, message: err.message, nodeId: err.nodeId ?? null };
417
+ }
418
+ finally { routes.delete(key); }
419
+ const data = clone(error ? { error } : result);
420
+ const context = { ...snapshot.context, data };
421
+ await apply(step(error ? ERROR : DONE, null, context), (next) => {
422
+ next.context.data = data;
423
+ setObjectMember(next.context.results, id, data);
424
+ next.pending = null;
425
+ });
426
+ }
427
+ else if (spec.kind === 'choose') {
428
+ const r = step(NEXT);
429
+ if (!r.changed) malformed('choice did not select a transition');
430
+ await apply(r);
431
+ }
432
+ else if (spec.kind === 'on') {
433
+ if (opts.now !== undefined) {
434
+ const r = chart.advance(snapshot.control, opts.now, { context: snapshot.context, one: true });
435
+ await apply(r);
436
+ if (r.changed) continue;
437
+ }
438
+ if (event) {
439
+ const incoming = event; event = undefined;
440
+ const r = step(`event:${incoming.type}`, incoming.payload ?? null);
441
+ await apply(r, (next) => { if (r.changed) next.context.event = clone(incoming); });
442
+ if (r.changed) continue;
443
+ }
444
+ await persist((next) => { next.status = 'waiting'; });
445
+ break;
446
+ }
447
+ else malformed(`unexpected active workflow state '${id}'`);
448
+ }
449
+ observe({ type: snapshot.status, active: snapshot.control.active, generation: snapshot.generation });
450
+ return deepFreeze({ status: snapshot.status, result: snapshot.status === 'done' ? snapshot.context.data : null,
451
+ snapshot: clone(snapshot) });
452
+ }
453
+ finally {
454
+ live = false;
455
+ controller.abort();
456
+ opts.signal?.removeEventListener('abort', abort);
457
+ running.delete(runId);
458
+ }
459
+ }
460
+ return Object.freeze({ lowered, revisions, taskVersions: versions, run });
461
+ }