bazis 0.132.0 → 0.133.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.
Files changed (36) hide show
  1. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  2. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
  3. package/core/workflow/definitions_module/http/Processes.controller.ts +2 -1
  4. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +2 -0
  5. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -2
  6. package/core/workflow/generated/deps.ts +4 -0
  7. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -0
  8. package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
  9. package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
  10. package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
  11. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  12. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  13. package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
  14. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  15. package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
  16. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +518 -132
  17. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +37 -6
  18. package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
  19. package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
  20. package/core/workflow/runtime_module/model/WfJob.model.ts +13 -4
  21. package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
  22. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +38 -3
  23. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +48 -7
  24. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  25. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
  26. package/library/workflow/ProcessDocument.ts +14 -0
  27. package/library/workflow/expressions.ts +5 -1
  28. package/library/workflow/index.ts +1 -1
  29. package/library/workflow/masking.ts +17 -3
  30. package/library/workflow/mermaid.ts +22 -6
  31. package/library/workflow/nodeKinds.ts +59 -0
  32. package/library/workflow/palette.ts +3 -1
  33. package/library/workflow/paths.ts +8 -2
  34. package/library/workflow/structure.ts +33 -4
  35. package/library/workflow/validateProcess.ts +129 -17
  36. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import type { EventToken } from "../../../kernel";
2
- import { durationMs, evaluateExpression, initialVarsOf, isTruthy, masksPersonal, maskByMapping, nodeKindOf, outputOf, personalPaths, personalVariables, PERSONAL_MASK, readPath, type Expr, type FormSpec, type InstanceData, type ProcessFlow, type ProcessNode } from "../../../../library/workflow";
2
+ import { durationMs, evaluateExpression, initialVarsOf, isTruthy, masksPersonal, maskByMapping, nodeKindOf, outputOf, personalPaths, personalVariables, PERSONAL_MASK, readPath, type Expr, type FormSpec, type InstanceData, type ProcessDocument, type ProcessFlow, type ProcessNode } from "../../../../library/workflow";
3
3
  import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
4
4
  import type { WorkflowClock } from "../../contracts/WorkflowClock";
5
5
  import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
@@ -8,7 +8,7 @@ import { WORKFLOW_INSTANCE_CANCELLED, WORKFLOW_INSTANCE_FINISHED, WORKFLOW_INSTA
8
8
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
9
9
  import { WfHistory, type HistoryType } from "../model/WfHistory.model";
10
10
  import { WfInstance, type StartedVia } from "../model/WfInstance.model";
11
- import { WfJob } from "../model/WfJob.model";
11
+ import { WfJob, type JobKind } from "../model/WfJob.model";
12
12
  import { WfSubscription } from "../model/WfSubscription.model";
13
13
  import { WfToken } from "../model/WfToken.model";
14
14
  import { buildGraph, type GraphCache, type ProcessGraph } from "./GraphCache";
@@ -28,6 +28,11 @@ export interface Step {
28
28
  readonly tokens: WfToken[];
29
29
  readonly graph: ProcessGraph;
30
30
  readonly events: PendingEvent[];
31
+ /** Iterations of loops this step has begun: past {@link LOOP_BATCH} the rest go to a job. */
32
+ iterations: number;
33
+ /** Rows for the end of the step ({@link WorkflowEngine.flush}). */
34
+ jobs: WfJob[];
35
+ readonly subscriptions: WfSubscription[];
31
36
  }
32
37
 
33
38
  export interface BeginOptions {
@@ -40,10 +45,77 @@ export interface BeginOptions {
40
45
  readonly request?: WorkflowRequest | null;
41
46
  /** The event that starts it: `$event` and `$trigger.eventKey`. */
42
47
  readonly event?: { readonly key: string; readonly payload: unknown };
48
+ /** The step of the instance that called it. */
49
+ readonly parent?: { readonly instanceId: string; readonly tokenId: string; readonly depth: number };
50
+ /** What led to the start: `$trigger.cause`. */
51
+ readonly cause?: Readonly<Record<string, unknown>>;
43
52
  }
44
53
 
54
+ /** Why a step failed. `reason` is the business reason an error boundary may name. */
55
+ export interface StepError {
56
+ readonly code: string;
57
+ readonly message: string;
58
+ readonly reason?: string;
59
+ }
60
+
61
+ /** The result of {@link WorkflowEngine.begin}: the new instance, or with `skipped` the unfinished one that took its place (`sameKey: "skip"`). */
62
+ export interface Begun {
63
+ readonly id: string;
64
+ readonly skipped: boolean;
65
+ }
66
+
67
+ /** What a token inside a loop body sees besides the instance: its element and what steps of the iteration gave. */
68
+ interface Locals {
69
+ readonly item: unknown;
70
+ readonly index: number;
71
+ readonly vars: Readonly<Record<string, unknown>>;
72
+ readonly nodes: Readonly<Record<string, unknown>>;
73
+ }
74
+
75
+ /** The progress of a running loop, in the `output` of its token. `items` is taken once: the body may change the list it came from. */
76
+ interface LoopState {
77
+ readonly items: readonly unknown[] | null;
78
+ readonly next: number;
79
+ readonly skipped: number;
80
+ readonly collected: Readonly<Record<string, unknown>>;
81
+ /** The rest waits for a `loop` job. */
82
+ readonly yielded: boolean;
83
+ }
84
+
85
+ interface LoopConfig {
86
+ readonly over?: Expr;
87
+ readonly while?: Expr;
88
+ readonly times?: number;
89
+ readonly item?: string;
90
+ readonly index?: string;
91
+ readonly maxIterations: number;
92
+ readonly parallel?: { readonly limit: number };
93
+ readonly onItemError?: "stop" | "skip";
94
+ readonly collect?: { readonly from: Expr; readonly to: string };
95
+ }
96
+
97
+ /** Where a token runs: the loop and the iteration. Top-level tokens have neither. */
98
+ type Frame = Pick<WfToken, "parentTokenId" | "iteration" | "locals">;
99
+
100
+ const TOP: Frame = { parentTokenId: null, iteration: null, locals: null };
101
+
45
102
  const LIVE = new Set(["active", "waiting", "failed"]);
46
103
 
104
+ /** How many iterations one transaction begins: a body without waits must not hold the instance for ten thousand of them. */
105
+ const LOOP_BATCH = 100;
106
+
107
+ /** Jobs that end with the instance. The others carry news of its end to someone else. */
108
+ const OWN_JOBS: readonly JobKind[] = ["tool", "timer", "loop", "call", "runTimeout"];
109
+
110
+ /** Whether two tokens are in one iteration: tokens of other iterations pass the same nodes and do not touch each other. */
111
+ function sameFrame(a: Frame, b: Frame): boolean {
112
+ return a.parentTokenId === b.parentTokenId && a.iteration === b.iteration;
113
+ }
114
+
115
+ function localsOf(token: Frame): Locals | null {
116
+ return token.locals as unknown as Locals | null;
117
+ }
118
+
47
119
  /**
48
120
  * Moves tokens along the graph up to the steps that wait (a Tool, a task, a
49
121
  * join) and finishes the instance when no live token is left. No state lives in
@@ -79,10 +151,94 @@ export class WorkflowEngine {
79
151
  return graph;
80
152
  }
81
153
 
82
- /** Creates an instance of `graph` and moves its token from `startNodeId`. */
83
- async begin(graph: ProcessGraph, startNodeId: string, options: BeginOptions): Promise<string> {
84
- const instance = Object.assign(new WfInstance(), {
154
+ /**
155
+ * Creates an instance of `graph` and moves its token from `startNodeId`, or
156
+ * leaves it `pending` when `maxConcurrent` or `sameKey` tell it to wait. With
157
+ * `sameKey: "skip"` no instance is created.
158
+ */
159
+ async begin(graph: ProcessGraph, startNodeId: string, options: BeginOptions): Promise<Begun> {
160
+ return await this.db.transactionScope(async (transaction) => {
161
+ const admission = await this.admit(graph, options.businessKey);
162
+ if (typeof admission === "object") return { id: admission.skippedBy, skipped: true };
163
+ const instance = this.newInstance(graph, options, admission === "queue" ? "pending" : "running");
164
+ this.db.instances.add(instance);
165
+ await this.db.saveChanges();
166
+ const step = this.stepOf(instance, [], graph);
167
+ this.history(step, null, null, "started", { via: options.startedVia }, options.actor);
168
+ step.events.push({
169
+ event: WORKFLOW_INSTANCE_STARTED as EventToken<unknown>,
170
+ payload: { instanceId: instance.id, processKey: graph.processKey, version: graph.version, partition: options.partition, businessKey: options.businessKey, startedVia: options.startedVia, actor: options.actor, request: options.request ?? null },
171
+ });
172
+ const first = this.spawn(step, startNodeId, null, TOP);
173
+ if (admission === "queue") {
174
+ first.status = "waiting";
175
+ this.history(step, first, startNodeId, "queued", null);
176
+ } else {
177
+ this.armRun(step);
178
+ await this.advance(step, [first]);
179
+ }
180
+ await this.flush(step);
181
+ this.publishAfter(transaction, step);
182
+ return { id: instance.id, skipped: false };
183
+ });
184
+ }
185
+
186
+ private stepOf(instance: WfInstance, tokens: WfToken[], graph: ProcessGraph): Step {
187
+ return { instance, tokens, graph, events: [], iterations: 0, jobs: [], subscriptions: [] };
188
+ }
189
+
190
+ /**
191
+ * Saves the step. Jobs and subscriptions are inserted past the change tracker:
192
+ * they belong to the worker, and one scope may delete such rows directly in its
193
+ * next step, which an ORM context that holds them would refuse.
194
+ */
195
+ private async flush(step: Step): Promise<void> {
196
+ await this.db.saveChanges();
197
+ for (const job of step.jobs) await this.db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] });
198
+ for (const subscription of step.subscriptions) await this.db.subscriptions.insertIfAbsent(subscription, { conflictBy: (s) => [s.id] });
199
+ }
200
+
201
+ /** Whether a start goes ahead, under the lock of the process: only processes with a limit take it. */
202
+ private async admit(graph: ProcessGraph, businessKey: string | null): Promise<"start" | "queue" | { readonly skippedBy: string }> {
203
+ const { maxConcurrent, sameKey = "allow" } = graph.document.settings ?? {};
204
+ const keyed = sameKey !== "allow" && businessKey !== null;
205
+ if (maxConcurrent === undefined && !keyed) return "start";
206
+ await this.lockProcess(graph.processKey);
207
+ if (keyed) {
208
+ const same = await this.db.instances.asNoTracking()
209
+ .where((i) => i.processKey.eq(graph.processKey).and(i.businessKey.eq(businessKey)).and(i.status.in(["pending", "running", "failed"])))
210
+ .firstOrDefault();
211
+ if (same) return sameKey === "skip" ? { skippedBy: same.id } : "queue";
212
+ }
213
+ return maxConcurrent !== undefined && await this.runningOf(graph.processKey) >= maxConcurrent ? "queue" : "start";
214
+ }
215
+
216
+ /** One transaction at a time decides who of a process runs. Held until the commit. */
217
+ async lockProcess(processKey: string): Promise<void> {
218
+ await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext({0}))", `bazis.workflow.process:${processKey}`);
219
+ }
220
+
221
+ async runningOf(processKey: string): Promise<number> {
222
+ return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.status.eq("running"))).count();
223
+ }
224
+
225
+ /** A `pending` instance got its place: its token moves from the start. */
226
+ async release(step: Step): Promise<void> {
227
+ if (step.instance.status !== "pending") return;
228
+ step.instance.status = "running";
229
+ const first = step.tokens.filter((t) => t.status === "waiting");
230
+ for (const token of first) {
231
+ token.status = "active";
232
+ this.history(step, token, token.nodeId, "released", null);
233
+ }
234
+ this.armRun(step);
235
+ await this.advance(step, first);
236
+ }
237
+
238
+ private newInstance(graph: ProcessGraph, options: BeginOptions, status: "pending" | "running"): WfInstance {
239
+ return Object.assign(new WfInstance(), {
85
240
  id: Bun.randomUUIDv7(),
241
+ status,
86
242
  versionId: graph.versionId,
87
243
  processKey: graph.processKey,
88
244
  version: graph.version,
@@ -95,26 +251,28 @@ export class WorkflowEngine {
95
251
  at: this.now().toISOString(),
96
252
  actor: options.actor,
97
253
  ...(options.event === undefined ? {} : { eventKey: options.event.key }),
254
+ ...(options.cause === undefined ? {} : { cause: options.cause }),
255
+ ...(options.parent === undefined ? {} : { depth: options.parent.depth }),
98
256
  },
99
257
  businessKey: options.businessKey,
100
258
  startedVia: options.startedVia,
101
259
  startedByKind: options.actor.kind,
102
260
  startedById: options.actor.id,
261
+ parentInstanceId: options.parent?.instanceId ?? null,
262
+ parentTokenId: options.parent?.tokenId ?? null,
103
263
  });
104
- await this.db.transactionScope(async (transaction) => {
105
- this.db.instances.add(instance);
106
- await this.db.saveChanges();
107
- const step: Step = { instance, tokens: [], graph, events: [] };
108
- this.history(step, null, null, "started", { via: options.startedVia }, options.actor);
109
- step.events.push({
110
- event: WORKFLOW_INSTANCE_STARTED as EventToken<unknown>,
111
- payload: { instanceId: instance.id, processKey: graph.processKey, version: graph.version, partition: options.partition, businessKey: options.businessKey, startedVia: options.startedVia, actor: options.actor, request: options.request ?? null },
112
- });
113
- await this.advance(step, [this.spawn(step, startNodeId, null)]);
114
- await this.db.saveChanges();
115
- this.publishAfter(transaction, step);
116
- });
117
- return instance.id;
264
+ }
265
+
266
+ /** A run with `runTimeout` has a job that ends it. */
267
+ private armRun(step: Step, startedAt = this.now()): void {
268
+ const timeout = step.graph.document.settings?.runTimeout;
269
+ const ms = timeout === undefined ? undefined : durationMs(timeout);
270
+ if (ms === undefined) return;
271
+ this.addJob(step, "runTimeout", { dueAt: new Date(Math.max(startedAt.getTime() + ms, this.now().getTime())) });
272
+ }
273
+
274
+ private addJob(step: Step, kind: JobKind, fields: Partial<Pick<WfJob, "tokenId" | "dueAt" | "payload">> = {}): void {
275
+ step.jobs.push(Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind, instanceId: step.instance.id, dueAt: this.now(), createdAt: this.now(), ...fields }));
118
276
  }
119
277
 
120
278
  /**
@@ -130,9 +288,9 @@ export class WorkflowEngine {
130
288
  const instance = await this.db.instances.where((i) => i.id.eq(instanceId)).forUpdate().firstOrDefault();
131
289
  if (!instance) return null;
132
290
  const tokens = await this.db.tokens.where((t) => t.instanceId.eq(instanceId).and(t.status.in(["active", "waiting", "failed"]))).toList();
133
- const step: Step = { instance, tokens, graph, events: [] };
291
+ const step = this.stepOf(instance, tokens, graph);
134
292
  const result = await work(step);
135
- await this.db.saveChanges();
293
+ await this.flush(step);
136
294
  this.publishAfter(transaction, step);
137
295
  return result;
138
296
  });
@@ -146,45 +304,100 @@ export class WorkflowEngine {
146
304
  async complete(step: Step, tokenId: string, output: unknown, actor?: WorkflowActor): Promise<boolean> {
147
305
  const token = step.tokens.find((t) => t.id === tokenId);
148
306
  if (!token || token.status !== "waiting" || step.instance.status !== "running") return false;
307
+ if (actor !== undefined) this.history(step, token, token.nodeId, "completed", null, actor);
308
+ await this.advance(step, this.finish(step, token, output));
309
+ return true;
310
+ }
311
+
312
+ /** A step's result goes to `$nodes.<node>` and to `saveTo`; the token leaves. */
313
+ private finish(step: Step, token: WfToken, output: unknown): WfToken[] {
149
314
  const node = step.graph.document.nodes[token.nodeId]!;
150
315
  token.output = output ?? null;
151
- step.instance.nodes = { ...step.instance.nodes, [token.nodeId]: output ?? null };
316
+ this.record(step, token, token.nodeId, output ?? null);
152
317
  const saveTo = node.config?.saveTo;
153
- if (typeof saveTo === "string") step.instance.vars = { ...step.instance.vars, [saveTo]: output ?? null };
154
- if (actor !== undefined) this.history(step, token, token.nodeId, "completed", null, actor);
155
- this.disarm(step, token.nodeId);
156
- await this.advance(step, this.leave(step, token, this.single(step, token.nodeId)));
157
- return true;
318
+ if (typeof saveTo === "string") this.save(step, token, saveTo, output ?? null);
319
+ this.disarm(step, token);
320
+ return this.leave(step, token, this.single(step, token.nodeId));
321
+ }
322
+
323
+ /** `$nodes.<node>`: inside a loop body it belongs to the iteration. */
324
+ private record(step: Step, token: WfToken, nodeId: string, output: unknown): void {
325
+ const locals = localsOf(token);
326
+ if (locals === null) step.instance.nodes = { ...step.instance.nodes, [nodeId]: output };
327
+ else token.locals = { ...locals, nodes: { ...locals.nodes, [nodeId]: output } };
328
+ }
329
+
330
+ /** `saveTo`: inside a loop body it belongs to the iteration, unless the process declares the variable. */
331
+ private save(step: Step, token: WfToken, name: string, value: unknown): void {
332
+ const locals = localsOf(token);
333
+ if (locals === null || step.graph.document.variables?.some((variable) => variable.name === name)) step.instance.vars = { ...step.instance.vars, [name]: value };
334
+ else token.locals = { ...locals, vars: { ...locals.vars, [name]: value } };
158
335
  }
159
336
 
160
337
  /**
161
- * A step's Tool failed for good. An error boundary attached to the step takes
162
- * the token (first one whose `errorCode` equals the Tool's `reason` or error
163
- * code, else one without `errorCode`); otherwise `onError: "skip"` moves on with
164
- * `{ skipped: true, error }`; otherwise the instance fails.
338
+ * A step failed for good: its Tool, or the process it called. An error boundary
339
+ * attached to the step takes the token (first one whose `errorCode` equals the
340
+ * `reason` or the error code, else one without `errorCode`); otherwise
341
+ * `onError: "skip"` moves on with `{ skipped: true, error }`; otherwise it goes
342
+ * to the loops around the step and then to the whole instance ({@link failed}).
165
343
  */
166
- async failStep(step: Step, token: WfToken, error: { readonly code: string; readonly message: string; readonly reason?: string }): Promise<void> {
167
- const boundaries = (step.graph.boundaries.get(token.nodeId) ?? []).filter(([, node]) => node.kind === "boundaryError");
168
- const matching = boundaries.find(([, node]) => node.config?.errorCode !== undefined && (node.config.errorCode === error.reason || node.config.errorCode === error.code))
169
- ?? boundaries.find(([, node]) => node.config?.errorCode === undefined);
170
- if (!matching) {
171
- if (step.graph.document.nodes[token.nodeId]?.config?.onError === "skip") {
172
- const skipped = { code: error.code, message: error.message, ...(error.reason === undefined ? {} : { reason: error.reason }) };
173
- this.history(step, token, token.nodeId, "skipped", skipped);
174
- await this.complete(step, token.id, { skipped: true, error: skipped });
175
- return;
176
- }
177
- this.fail(step, token, error.code, error.message);
344
+ async failStep(step: Step, token: WfToken, error: StepError): Promise<void> {
345
+ const boundary = this.errorBoundary(step, token.nodeId, error);
346
+ if (boundary) {
347
+ await this.advance(step, this.caught(step, token, boundary, error));
178
348
  return;
179
349
  }
180
- const [boundaryId] = matching;
181
- const caught = { code: error.code, message: error.message, ...(error.reason === undefined ? {} : { reason: error.reason }) };
182
- token.output = { error: caught };
350
+ const node = step.graph.document.nodes[token.nodeId];
351
+ if (node?.config?.onError === "skip") {
352
+ const skipped = detailsOf(error);
353
+ this.history(step, token, token.nodeId, "skipped", skipped);
354
+ if (node.kind === "callProcess") this.addJob(step, "cancelChildren", { payload: { tokenId: token.id, reason: "the caller skipped this step" } });
355
+ await this.complete(step, token.id, { skipped: true, error: skipped });
356
+ return;
357
+ }
358
+ await this.advance(step, this.failed(step, token, error));
359
+ }
360
+
361
+ private errorBoundary(step: Step, nodeId: string, error: StepError): readonly [string, ProcessNode] | undefined {
362
+ const boundaries = (step.graph.boundaries.get(nodeId) ?? []).filter(([, node]) => node.kind === "boundaryError");
363
+ return boundaries.find(([, node]) => node.config?.errorCode !== undefined && (node.config.errorCode === error.reason || node.config.errorCode === error.code))
364
+ ?? boundaries.find(([, node]) => node.config?.errorCode === undefined);
365
+ }
366
+
367
+ /** An error boundary takes over from a step: what ran inside the step closes, the boundary leads on. */
368
+ private caught(step: Step, token: WfToken, [boundaryId]: readonly [string, ProcessNode], error: StepError): WfToken[] {
369
+ const details = detailsOf(error);
370
+ for (const child of this.childrenOf(step, token)) this.closeTree(step, child, "interrupted");
371
+ if (step.graph.document.nodes[token.nodeId]?.kind === "callProcess") this.addJob(step, "cancelChildren", { payload: { tokenId: token.id, reason: "the caller took over the error" } });
372
+ token.output = { error: details };
183
373
  this.close(token, "done");
184
- this.disarm(step, token.nodeId);
185
- this.history(step, token, token.nodeId, "error", { ...caught, boundary: boundaryId });
186
- step.instance.nodes = { ...step.instance.nodes, [boundaryId]: caught };
187
- await this.advance(step, [this.spawn(step, boundaryId, null)]);
374
+ this.disarm(step, token);
375
+ this.history(step, token, token.nodeId, "error", { ...details, boundary: boundaryId });
376
+ this.record(step, token, boundaryId, details);
377
+ return [this.spawn(step, boundaryId, null, token)];
378
+ }
379
+
380
+ /**
381
+ * A failure no step took: the loops around it have their say, from the inside
382
+ * out (`onItemError: "skip"` drops the iteration, an error boundary on the loop
383
+ * takes over). Otherwise the token waits for a retry and the instance is `failed`.
384
+ */
385
+ private failed(step: Step, token: WfToken, error: StepError): WfToken[] {
386
+ for (let inner = token; inner.parentTokenId !== null;) {
387
+ const loop = step.tokens.find((t) => t.id === inner.parentTokenId);
388
+ if (!loop) break;
389
+ if (step.graph.document.nodes[loop.nodeId]?.config?.onItemError === "skip") return this.skipIteration(step, loop, inner.iteration!, error);
390
+ const boundary = this.errorBoundary(step, loop.nodeId, error);
391
+ if (boundary) return this.caught(step, loop, boundary, error);
392
+ inner = loop;
393
+ }
394
+ this.disarm(step, token);
395
+ token.status = "failed";
396
+ step.instance.status = "failed";
397
+ step.instance.error = { node: token.nodeId, code: error.code, message: error.message };
398
+ this.history(step, token, token.nodeId, "error", { code: error.code, message: error.message });
399
+ this.stopped(step);
400
+ return [];
188
401
  }
189
402
 
190
403
  /**
@@ -229,66 +442,122 @@ export class WorkflowEngine {
229
442
  const interrupting = node.config?.interrupting !== false;
230
443
  this.history(step, token, token.nodeId, type, { ...details, ...(type === "timer" ? output : {}), interrupting });
231
444
  if (interrupting) {
232
- for (const host of step.tokens.filter((t) => t.nodeId === node.attachedTo && LIVE.has(t.status))) {
233
- this.close(host, "cancelled");
234
- if (step.graph.document.nodes[host.nodeId]?.kind === "userTask") {
235
- step.events.push({ event: WORKFLOW_TOKEN_CLOSED as EventToken<unknown>, payload: { instanceId: step.instance.id, tokenId: host.id, reason: "interrupted" } });
236
- }
237
- }
238
- this.disarm(step, node.attachedTo, token.id);
445
+ for (const host of step.tokens.filter((t) => t.nodeId === node.attachedTo && LIVE.has(t.status) && sameFrame(t, token))) this.closeTree(step, host, "interrupted");
446
+ this.disarm(step, token, token.id, node.attachedTo);
239
447
  }
240
- step.instance.nodes = { ...step.instance.nodes, [token.nodeId]: output };
448
+ this.record(step, token, token.nodeId, output);
241
449
  await this.advance(step, this.leave(step, token, this.single(step, token.nodeId)));
242
450
  return true;
243
451
  }
244
452
 
245
- /** A step failed: the token waits for a retry, the instance is `failed` with the reason. */
246
- fail(step: Step, token: WfToken, code: string, message: string): void {
247
- this.disarm(step, token.nodeId);
248
- token.status = "failed";
249
- step.instance.status = "failed";
250
- step.instance.error = { node: token.nodeId, code, message };
251
- this.history(step, token, token.nodeId, "error", { code, message });
453
+ /** A step failed outside its Tool: as {@link failStep} without the step's own handlers. */
454
+ async fail(step: Step, token: WfToken, code: string, message: string): Promise<void> {
455
+ await this.advance(step, this.failed(step, token, { code, message }));
252
456
  }
253
457
 
254
- /** Retries the failed steps: their tokens are back on their nodes and the instance runs again. */
458
+ /**
459
+ * Retries the failed steps: their tokens are back on their nodes and the instance
460
+ * runs again. What the failure left behind goes on too: tokens that had not moved,
461
+ * and the jobs a worker dropped while the instance was stopped.
462
+ */
255
463
  async retry(step: Step, actor: WorkflowActor, request: WorkflowRequest | null = null): Promise<void> {
256
464
  const failed = step.tokens.filter((t) => t.status === "failed");
465
+ const left = step.tokens.filter((t) => t.status === "active");
257
466
  step.events.push({
258
467
  event: WORKFLOW_INSTANCE_RETRIED as EventToken<unknown>,
259
468
  payload: { instanceId: step.instance.id, processKey: step.instance.processKey, partition: step.instance.partition, actor, request },
260
469
  });
261
470
  step.instance.status = "running";
262
471
  step.instance.error = null;
472
+ await this.rearm(step);
263
473
  for (const token of failed) {
264
474
  token.status = "active";
265
475
  this.history(step, token, token.nodeId, "retried", null, actor);
266
476
  }
267
- await this.advance(step, failed);
477
+ await this.advance(step, [...failed, ...left]);
478
+ }
479
+
480
+ private async rearm(step: Step): Promise<void> {
481
+ const jobs = await this.db.jobs.asNoTracking().where((j) => j.instanceId.eq(step.instance.id)).toList();
482
+ const held = new Set(jobs.map((job) => job.tokenId));
483
+ for (const token of step.tokens) {
484
+ if (token.status !== "waiting" || held.has(token.id)) continue;
485
+ const kind = step.graph.document.nodes[token.nodeId]?.kind;
486
+ if (kind === "serviceTask") this.addJob(step, "tool", { tokenId: token.id });
487
+ else if (kind === "timerCatch" || kind === "boundaryTimer") this.addJob(step, "timer", { tokenId: token.id });
488
+ else if (kind === "callProcess") this.addJob(step, "call", { tokenId: token.id });
489
+ else if (kind === "loop" && (token.output as LoopState | null)?.yielded === true) this.addJob(step, "loop", { tokenId: token.id });
490
+ }
491
+ if (!jobs.some((job) => job.kind === "runTimeout")) this.armRun(step, new Date(String(step.instance.trigger?.at)));
268
492
  }
269
493
 
270
494
  /** Closes every live token, job and subscription; the application drops the tasks on `WORKFLOW_TOKEN_CLOSED`. */
271
495
  async cancel(step: Step, reason: string | null, actor: WorkflowActor, request: WorkflowRequest | null = null): Promise<void> {
272
- const open = step.tokens.filter((t) => LIVE.has(t.status));
273
496
  step.events.push({
274
497
  event: WORKFLOW_INSTANCE_CANCELLED as EventToken<unknown>,
275
498
  payload: { instanceId: step.instance.id, processKey: step.instance.processKey, partition: step.instance.partition, reason, actor, request },
276
499
  });
277
- for (const token of open) {
278
- token.status = "cancelled";
279
- token.endedAt = this.now();
280
- }
281
- await this.db.jobs.asNoTracking().where((j) => j.instanceId.eq(step.instance.id)).executeDelete();
282
- await this.db.subscriptions.asNoTracking().where((s) => s.instanceId.eq(step.instance.id)).executeDelete();
500
+ await this.closeAll(step, "cancelled");
283
501
  step.instance.status = "cancelled";
284
502
  step.instance.endedAt = this.now();
285
503
  this.history(step, null, null, "cancelled", { reason }, actor);
286
- for (const token of open) {
287
- if (token.nodeId !== "" && step.graph.document.nodes[token.nodeId]?.kind === "userTask") {
288
- step.events.push({ event: WORKFLOW_TOKEN_CLOSED as EventToken<unknown>, payload: { instanceId: step.instance.id, tokenId: token.id, reason: "cancelled" } });
289
- }
290
- }
291
504
  this.finished(step, "cancelled");
505
+ this.stopped(step);
506
+ }
507
+
508
+ /** `runTimeout` passed: the run ends here, whatever it was waiting for. */
509
+ async timeOut(step: Step): Promise<void> {
510
+ const timeout = step.graph.document.settings?.runTimeout ?? "";
511
+ await this.closeAll(step, "timedOut");
512
+ step.instance.status = "timedOut";
513
+ step.instance.endedAt = this.now();
514
+ step.instance.error = { node: "", code: "run_timeout", message: `The run took longer than ${timeout}` };
515
+ this.history(step, null, null, "timedOut", { timeout });
516
+ this.finished(step, "timedOut");
517
+ this.stopped(step);
518
+ }
519
+
520
+ private async closeAll(step: Step, reason: "cancelled" | "timedOut"): Promise<void> {
521
+ for (const token of step.tokens.filter((t) => t.parentTokenId === null || !step.tokens.some((other) => other.id === t.parentTokenId))) {
522
+ if (LIVE.has(token.status)) this.closeTree(step, token, reason);
523
+ }
524
+ await this.dropJobs(step, OWN_JOBS);
525
+ await this.db.subscriptions.asNoTracking().where((s) => s.instanceId.eq(step.instance.id)).executeDelete();
526
+ }
527
+
528
+ /** Removes jobs of these kinds, saved and about to be. */
529
+ private async dropJobs(step: Step, kinds: readonly JobKind[]): Promise<void> {
530
+ step.jobs = step.jobs.filter((job) => !kinds.includes(job.kind));
531
+ await this.db.jobs.asNoTracking().where((j) => j.instanceId.eq(step.instance.id).and(j.kind.in([...kinds]))).executeDelete();
532
+ }
533
+
534
+ /** The live tokens of a loop's body. */
535
+ private childrenOf(step: Step, token: WfToken): WfToken[] {
536
+ return step.tokens.filter((t) => t.parentTokenId === token.id && LIVE.has(t.status));
537
+ }
538
+
539
+ /** Closes a token with all that runs inside it: the body of a loop, a user task, a called process. */
540
+ private closeTree(step: Step, token: WfToken, reason: "cancelled" | "interrupted" | "timedOut"): void {
541
+ for (const child of this.childrenOf(step, token)) this.closeTree(step, child, reason);
542
+ this.close(token, "cancelled");
543
+ const kind = step.graph.document.nodes[token.nodeId]?.kind;
544
+ if (kind === "userTask") step.events.push({ event: WORKFLOW_TOKEN_CLOSED as EventToken<unknown>, payload: { instanceId: step.instance.id, tokenId: token.id, reason } });
545
+ if (kind === "callProcess") this.addJob(step, "cancelChildren", { payload: { tokenId: token.id, reason: `the caller was ${reason}` } });
546
+ }
547
+
548
+ /**
549
+ * The instance stopped running: who waits for it hears of it. Each goes through a
550
+ * job of this instance, so that its transaction never holds another instance's row.
551
+ */
552
+ private stopped(step: Step): void {
553
+ const { instance, graph } = step;
554
+ const settings = graph.document.settings;
555
+ if (instance.parentInstanceId !== null) this.addJob(step, "returned", { payload: { parentInstanceId: instance.parentInstanceId, parentTokenId: instance.parentTokenId } });
556
+ if (settings?.maxConcurrent !== undefined || settings?.sameKey === "queue") this.addJob(step, "release", { payload: { processKey: instance.processKey } });
557
+ // A handler that fails does not start a handler: a chain of them would never end.
558
+ if ((instance.status === "failed" || instance.status === "timedOut") && settings?.errorHandler !== undefined && instance.startedVia !== "errorHandler") {
559
+ this.addJob(step, "errorHandler", { payload: { process: settings.errorHandler } });
560
+ }
292
561
  }
293
562
 
294
563
  /** Adds a history entry to the step. */
@@ -319,27 +588,32 @@ export class WorkflowEngine {
319
588
  step.instance.output = outputOf(step.graph.document, step.instance.vars);
320
589
  this.history(step, null, null, "completed", null);
321
590
  this.finished(step, "completed");
591
+ if (step.graph.document.settings?.runTimeout !== undefined) await this.dropJobs(step, ["runTimeout"]);
592
+ this.stopped(step);
322
593
  }
323
594
  }
324
595
 
325
596
  /** The node under a token: returns the new tokens to move on. */
326
597
  private enter(step: Step, token: WfToken): WfToken[] {
327
598
  const node = step.graph.document.nodes[token.nodeId];
328
- if (!node) {
329
- this.fail(step, token, "unknown_node", `node ${token.nodeId} is not in the version`);
330
- return [];
331
- }
599
+ if (!node) return this.failed(step, token, { code: "unknown_node", message: `node ${token.nodeId} is not in the version` });
332
600
  const role = nodeKindOf(node.kind)?.role;
333
601
  if (role === "start") return this.leave(step, token, this.single(step, token.nodeId));
334
602
  if (role === "end") {
335
603
  this.close(token, "done");
336
- return [];
604
+ return token.parentTokenId === null ? [] : this.bodyEnded(step, token);
337
605
  }
338
606
  // An error boundary that caught a step's failure: it only leads on.
339
607
  if (role === "boundary") return this.leave(step, token, this.single(step, token.nodeId));
340
608
  switch (node.kind) {
341
609
  case "assign":
342
610
  return this.assign(step, token, node);
611
+ case "loop":
612
+ return this.enterLoop(step, token, node);
613
+ case "callProcess":
614
+ token.status = "waiting";
615
+ this.addJob(step, "call", { tokenId: token.id, payload: { fresh: true } });
616
+ return this.arm(step, token, []);
343
617
  case "serviceTask":
344
618
  return this.arm(step, token, this.waitForTool(step, token));
345
619
  case "userTask":
@@ -353,8 +627,7 @@ export class WorkflowEngine {
353
627
  case "parallelGateway":
354
628
  return this.parallel(step, token);
355
629
  default:
356
- this.fail(step, token, "not_supported", `${node.kind} cannot be entered by a flow`);
357
- return [];
630
+ return this.failed(step, token, { code: "not_supported", message: `${node.kind} cannot be entered by a flow` });
358
631
  }
359
632
  }
360
633
 
@@ -369,7 +642,7 @@ export class WorkflowEngine {
369
642
  const reads = masksPersonal(document) ? personalPaths(document) : new Set<string>();
370
643
  const logged: Record<string, unknown> = {};
371
644
  for (const { var: name, value } of assignments) {
372
- const result = evaluateExpression(value, dataOf(step.instance, this.now())) ?? null;
645
+ const result = evaluateExpression(value, dataOf(step.instance, this.now(), token)) ?? null;
373
646
  step.instance.vars = { ...step.instance.vars, [name]: result };
374
647
  logged[name] = personal.has(name) ? PERSONAL_MASK : maskByMapping(value, result, reads);
375
648
  }
@@ -379,13 +652,10 @@ export class WorkflowEngine {
379
652
  }
380
653
 
381
654
  private waitForTimer(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
382
- const dueAt = timerDue(node.config ?? {}, dataOf(step.instance, this.now()), this.now());
383
- if (dueAt === undefined) {
384
- this.fail(step, token, "bad_timer", `the timer of ${token.nodeId} has no valid date`);
385
- return [];
386
- }
655
+ const dueAt = timerDue(node.config ?? {}, dataOf(step.instance, this.now(), token), this.now());
656
+ if (dueAt === undefined) return this.failed(step, token, { code: "bad_timer", message: `the timer of ${token.nodeId} has no valid date` });
387
657
  token.status = "waiting";
388
- this.db.jobs.add(Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "timer" as const, instanceId: step.instance.id, tokenId: token.id, dueAt }));
658
+ this.addJob(step, "timer", { tokenId: token.id, dueAt });
389
659
  return [];
390
660
  }
391
661
 
@@ -397,63 +667,55 @@ export class WorkflowEngine {
397
667
  if (host.status !== "waiting") return next;
398
668
  for (const [id, node] of step.graph.boundaries.get(host.nodeId) ?? []) {
399
669
  if (node.kind === "boundaryEvent") {
400
- const correlation = correlationOf(node.config ?? {}, dataOf(step.instance, this.now()));
401
- if (correlation === undefined) {
402
- this.fail(step, host, "bad_correlation", `the correlation of ${id} has no value`);
403
- return [];
404
- }
405
- const watcher = this.spawn(step, id, null);
670
+ const correlation = correlationOf(node.config ?? {}, dataOf(step.instance, this.now(), host));
671
+ if (correlation === undefined) return this.failed(step, host, { code: "bad_correlation", message: `the correlation of ${id} has no value` });
672
+ const watcher = this.spawn(step, id, null, host);
406
673
  watcher.status = "waiting";
407
674
  this.subscribe(step, watcher, String(node.config?.event), correlation);
408
675
  continue;
409
676
  }
410
677
  if (node.kind !== "boundaryTimer") continue;
411
- const dueAt = timerDue(node.config ?? {}, dataOf(step.instance, this.now()), this.now());
412
- if (dueAt === undefined) {
413
- this.fail(step, host, "bad_timer", `the timer of ${id} has no valid date`);
414
- return [];
415
- }
416
- const watcher = this.spawn(step, id, null);
678
+ const dueAt = timerDue(node.config ?? {}, dataOf(step.instance, this.now(), host), this.now());
679
+ if (dueAt === undefined) return this.failed(step, host, { code: "bad_timer", message: `the timer of ${id} has no valid date` });
680
+ const watcher = this.spawn(step, id, null, host);
417
681
  watcher.status = "waiting";
418
- this.db.jobs.add(Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "timer" as const, instanceId: step.instance.id, tokenId: watcher.id, dueAt }));
682
+ this.addJob(step, "timer", { tokenId: watcher.id, dueAt });
419
683
  }
420
684
  return next;
421
685
  }
422
686
 
423
- /** The step left or failed: its boundary tokens close; their jobs find the tokens closed and drop. */
424
- private disarm(step: Step, hostNodeId: string, except?: string): void {
687
+ /** The step left or failed: its boundary tokens close; their jobs find the tokens closed and drop. Tokens of other iterations stay. */
688
+ private disarm(step: Step, host: WfToken, except?: string, hostNodeId = host.nodeId): void {
425
689
  const boundaries = new Set((step.graph.boundaries.get(hostNodeId) ?? []).map(([id]) => id));
426
690
  if (boundaries.size === 0) return;
427
691
  for (const token of step.tokens) {
428
- if (token.id !== except && boundaries.has(token.nodeId) && LIVE.has(token.status)) this.close(token, "cancelled");
692
+ if (token.id !== except && boundaries.has(token.nodeId) && LIVE.has(token.status) && sameFrame(token, host)) this.close(token, "cancelled");
429
693
  }
430
694
  }
431
695
 
432
696
  private waitForEvent(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
433
- const correlation = correlationOf(node.config ?? {}, dataOf(step.instance, this.now()));
434
- if (correlation === undefined) {
435
- this.fail(step, token, "bad_correlation", `the correlation of ${token.nodeId} has no value`);
436
- return [];
437
- }
697
+ const correlation = correlationOf(node.config ?? {}, dataOf(step.instance, this.now(), token));
698
+ if (correlation === undefined) return this.failed(step, token, { code: "bad_correlation", message: `the correlation of ${token.nodeId} has no value` });
438
699
  token.status = "waiting";
439
700
  this.subscribe(step, token, String(node.config?.event), correlation);
440
701
  return [];
441
702
  }
442
703
 
443
704
  private subscribe(step: Step, token: WfToken, eventKey: string, correlation: string | null): void {
444
- this.db.subscriptions.add(Object.assign(new WfSubscription(), {
705
+ step.subscriptions.push(Object.assign(new WfSubscription(), {
445
706
  id: Bun.randomUUIDv7(),
446
707
  instanceId: step.instance.id,
447
708
  tokenId: token.id,
448
709
  eventKey,
449
710
  correlation,
450
711
  partition: step.instance.partition,
712
+ createdAt: this.now(),
451
713
  }));
452
714
  }
453
715
 
454
716
  private waitForTool(step: Step, token: WfToken): WfToken[] {
455
717
  token.status = "waiting";
456
- this.db.jobs.add(Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "tool" as const, instanceId: step.instance.id, tokenId: token.id, dueAt: this.now() }));
718
+ this.addJob(step, "tool", { tokenId: token.id });
457
719
  return [];
458
720
  }
459
721
 
@@ -482,13 +744,10 @@ export class WorkflowEngine {
482
744
  private choose(step: Step, token: WfToken): WfToken[] {
483
745
  const flows = step.graph.outgoing.get(token.nodeId) ?? [];
484
746
  if (flows.length === 1) return this.leave(step, token, flows[0]!);
485
- const data = dataOf(step.instance, this.now());
747
+ const data = dataOf(step.instance, this.now(), token);
486
748
  const chosen = flows.find(([, flow]) => flow.condition !== undefined && isTruthy(evaluateExpression(flow.condition, data)))
487
749
  ?? flows.find(([, flow]) => flow.isDefault === true);
488
- if (!chosen) {
489
- this.fail(step, token, "no_branch", "no condition matched and there is no default flow");
490
- return [];
491
- }
750
+ if (!chosen) return this.failed(step, token, { code: "no_branch", message: "no condition matched and there is no default flow" });
492
751
  return this.leave(step, token, chosen);
493
752
  }
494
753
 
@@ -500,16 +759,17 @@ export class WorkflowEngine {
500
759
  token.status = "waiting";
501
760
  const arrived = new Map<string, WfToken>();
502
761
  for (const other of step.tokens) {
503
- if (other.nodeId === token.nodeId && other.status === "waiting" && other.viaFlowId !== null && !arrived.has(other.viaFlowId)) arrived.set(other.viaFlowId, other);
762
+ if (other.nodeId === token.nodeId && other.status === "waiting" && other.viaFlowId !== null && !arrived.has(other.viaFlowId) && sameFrame(other, token)) arrived.set(other.viaFlowId, other);
504
763
  }
505
764
  if (!inputs.every(([id]) => arrived.has(id))) return [];
506
765
  for (const joined of arrived.values()) this.close(joined, "done");
507
- current = this.spawn(step, token.nodeId, null);
766
+ // What the branches of an iteration gave meets here.
767
+ current = this.spawn(step, token.nodeId, null, { ...token, locals: mergedLocals([...arrived.values()]) });
508
768
  }
509
769
  const outputs = step.graph.outgoing.get(token.nodeId) ?? [];
510
770
  this.close(current, "done");
511
771
  this.history(step, current, current.nodeId, "left", { flows: outputs.map(([id]) => id) });
512
- return outputs.map(([id, flow]) => this.spawn(step, flow.target, id));
772
+ return outputs.map(([id, flow]) => this.spawn(step, flow.target, id, current));
513
773
  }
514
774
 
515
775
  /** The single outgoing flow of a node; validation guarantees it exists. */
@@ -522,7 +782,106 @@ export class WorkflowEngine {
522
782
  private leave(step: Step, token: WfToken, [flowId, flow]: readonly [string, ProcessFlow]): WfToken[] {
523
783
  this.close(token, "done");
524
784
  this.history(step, token, token.nodeId, "left", { flow: flowId });
525
- return [this.spawn(step, flow.target, flowId)];
785
+ return [this.spawn(step, flow.target, flowId, token)];
786
+ }
787
+
788
+ /** A loop begins, or goes on after a retry: its token waits while the iterations run. */
789
+ private enterLoop(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
790
+ const config = node.config as unknown as LoopConfig;
791
+ if (!isLoopState(token.output)) {
792
+ let items: readonly unknown[] | null = null;
793
+ if (config.over !== undefined) {
794
+ const value = evaluateExpression(config.over, dataOf(step.instance, this.now(), token)) ?? [];
795
+ if (!Array.isArray(value)) return this.failed(step, token, { code: "loop_not_a_list", message: `over of ${token.nodeId} is not a list` });
796
+ if (value.length > config.maxIterations) return this.failed(step, token, { code: "loop_limit", message: `${value.length} elements for ${token.nodeId}, maxIterations is ${config.maxIterations}` });
797
+ items = value;
798
+ }
799
+ token.output = { items, next: 0, skipped: 0, collected: {}, yielded: false } satisfies LoopState;
800
+ }
801
+ token.status = "waiting";
802
+ const armed = this.arm(step, token, []);
803
+ return token.status === "waiting" ? this.iterate(step, token) : armed;
804
+ }
805
+
806
+ /** Begins iterations up to the loop's limit; when nothing is left to begin or to wait for, the loop leaves. */
807
+ private iterate(step: Step, loop: WfToken): WfToken[] {
808
+ const config = step.graph.document.nodes[loop.nodeId]!.config as unknown as LoopConfig;
809
+ const state = loop.output as LoopState;
810
+ if (state.yielded) return [];
811
+ const entry = step.graph.entries.get(loop.nodeId)!;
812
+ const outer = localsOf(loop);
813
+ const frame = (index: number, item: unknown): Locals => ({
814
+ item,
815
+ index,
816
+ vars: { ...outer?.vars, ...(config.item === undefined ? {} : { [config.item]: item }), ...(config.index === undefined ? {} : { [config.index]: index }) },
817
+ nodes: outer?.nodes ?? {},
818
+ });
819
+ let running = new Set(this.childrenOf(step, loop).map((t) => t.iteration)).size;
820
+ let next = state.next;
821
+ const begun: WfToken[] = [];
822
+ const save = (more: Partial<LoopState> = {}) => {
823
+ loop.output = { ...state, next, ...more } satisfies LoopState;
824
+ };
825
+ while (running < (config.parallel?.limit ?? 1)) {
826
+ const item = state.items?.[next] ?? null;
827
+ const more = state.items !== null ? next < state.items.length
828
+ : config.times !== undefined ? next < config.times
829
+ : isTruthy(evaluateExpression(config.while!, dataOf(step.instance, this.now(), { locals: frame(next, null) as unknown as Frame["locals"] })));
830
+ if (!more) break;
831
+ if (next >= config.maxIterations) {
832
+ save();
833
+ return this.failed(step, loop, { code: "loop_limit", message: `${loop.nodeId} reached maxIterations: ${config.maxIterations}` });
834
+ }
835
+ if (step.iterations >= LOOP_BATCH) {
836
+ save({ yielded: true });
837
+ this.addJob(step, "loop", { tokenId: loop.id });
838
+ return begun;
839
+ }
840
+ step.iterations += 1;
841
+ const first = this.spawn(step, entry, null, { parentTokenId: loop.id, iteration: next, locals: frame(next, item) as unknown as Frame["locals"] });
842
+ this.history(step, first, loop.nodeId, "iteration", { iteration: next });
843
+ begun.push(first);
844
+ next += 1;
845
+ running += 1;
846
+ }
847
+ save();
848
+ if (running > 0) return begun;
849
+ const collect = config.collect;
850
+ if (collect !== undefined) step.instance.vars = { ...step.instance.vars, [collect.to]: Object.keys(state.collected).sort((a, b) => Number(a) - Number(b)).map((key) => state.collected[key]) };
851
+ return this.finish(step, loop, { iterations: next, skipped: state.skipped });
852
+ }
853
+
854
+ /** A `loop` job: the next batch of iterations. `false` when the loop no longer waits. */
855
+ async continueLoop(step: Step, tokenId: string): Promise<boolean> {
856
+ const loop = step.tokens.find((t) => t.id === tokenId);
857
+ if (!loop || loop.status !== "waiting" || step.instance.status !== "running" || !isLoopState(loop.output)) return false;
858
+ loop.output = { ...loop.output, yielded: false } satisfies LoopState;
859
+ await this.advance(step, this.iterate(step, loop));
860
+ return true;
861
+ }
862
+
863
+ /** A token of a body reached its end: when it is the last of the iteration, what the loop collects is taken and the next one begins. */
864
+ private bodyEnded(step: Step, token: WfToken): WfToken[] {
865
+ const loop = step.tokens.find((t) => t.id === token.parentTokenId && t.status === "waiting");
866
+ if (!loop || step.tokens.some((t) => t.parentTokenId === loop.id && t.iteration === token.iteration && LIVE.has(t.status))) return [];
867
+ const config = step.graph.document.nodes[loop.nodeId]!.config as unknown as LoopConfig;
868
+ if (config.collect !== undefined) {
869
+ const state = loop.output as LoopState;
870
+ const value = evaluateExpression(config.collect.from, dataOf(step.instance, this.now(), token)) ?? null;
871
+ loop.output = { ...state, collected: { ...state.collected, [String(token.iteration)]: value } } satisfies LoopState;
872
+ }
873
+ return this.iterate(step, loop);
874
+ }
875
+
876
+ /** `onItemError: "skip"`: what still runs in the iteration closes and the loop goes on without it. */
877
+ private skipIteration(step: Step, loop: WfToken, iteration: number, error: StepError): WfToken[] {
878
+ for (const token of this.childrenOf(step, loop)) {
879
+ if (token.iteration === iteration) this.closeTree(step, token, "interrupted");
880
+ }
881
+ const state = loop.output as LoopState;
882
+ loop.output = { ...state, skipped: state.skipped + 1 } satisfies LoopState;
883
+ this.history(step, loop, loop.nodeId, "skipped", { iteration, ...detailsOf(error) });
884
+ return this.iterate(step, loop);
526
885
  }
527
886
 
528
887
  private close(token: WfToken, status: "done" | "cancelled"): void {
@@ -530,15 +889,16 @@ export class WorkflowEngine {
530
889
  token.endedAt = this.now();
531
890
  }
532
891
 
533
- private spawn(step: Step, nodeId: string, viaFlowId: string | null): WfToken {
534
- const token = Object.assign(new WfToken(), { id: Bun.randomUUIDv7(), instanceId: step.instance.id, nodeId, viaFlowId, status: "active" as const });
892
+ /** A new token in the frame of `from`: in the same iteration, it sees what `from` saw. */
893
+ private spawn(step: Step, nodeId: string, viaFlowId: string | null, from: Frame): WfToken {
894
+ const token = Object.assign(new WfToken(), { id: Bun.randomUUIDv7(), instanceId: step.instance.id, nodeId, viaFlowId, status: "active" as const, parentTokenId: from.parentTokenId, iteration: from.iteration, locals: from.locals });
535
895
  this.db.tokens.add(token);
536
896
  step.tokens.push(token);
537
897
  this.history(step, token, nodeId, "entered", viaFlowId ? { flow: viaFlowId } : null);
538
898
  return token;
539
899
  }
540
900
 
541
- private finished(step: Step, status: "completed" | "cancelled"): void {
901
+ private finished(step: Step, status: "completed" | "cancelled" | "timedOut"): void {
542
902
  step.events.push({
543
903
  event: WORKFLOW_INSTANCE_FINISHED as EventToken<unknown>,
544
904
  payload: { instanceId: step.instance.id, processKey: step.instance.processKey, partition: step.instance.partition, status },
@@ -580,15 +940,41 @@ export function correlationOf(config: Readonly<Record<string, unknown>>, data: I
580
940
  return value === undefined || value === null || value === "" || typeof value === "object" ? undefined : String(value);
581
941
  }
582
942
 
583
- /** What expressions read in an instance at this moment of the engine's clock. */
584
- export function dataOf(instance: WfInstance, now: Date): InstanceData {
943
+ /** What expressions read in an instance at this moment of the engine's clock; inside a loop body, with what the iteration has. */
944
+ export function dataOf(instance: WfInstance, now: Date, token?: Pick<Frame, "locals">): InstanceData {
945
+ const locals = token?.locals as unknown as Locals | null | undefined;
585
946
  return {
586
947
  input: instance.input,
587
- vars: instance.vars,
588
- nodes: instance.nodes,
948
+ vars: locals ? { ...instance.vars, ...locals.vars } : instance.vars,
949
+ nodes: locals ? { ...instance.nodes, ...locals.nodes } : instance.nodes,
950
+ ...(locals ? { item: locals.item, index: locals.index } : {}),
589
951
  event: instance.event,
590
952
  trigger: instance.trigger,
591
953
  run: { id: instance.id, processKey: instance.processKey, version: instance.version, partition: instance.partition, test: instance.test },
592
954
  now: now.toISOString(),
593
955
  };
594
956
  }
957
+
958
+ /** Who the steps of an instance act for: `settings.runAs`. */
959
+ export function runActorOf(document: ProcessDocument, instance: WfInstance): WorkflowActor {
960
+ return document.settings?.runAs === "process" ? { kind: "system", id: `process:${instance.processKey}` } : { kind: instance.startedByKind, id: instance.startedById };
961
+ }
962
+
963
+ function isLoopState(value: unknown): value is LoopState {
964
+ return typeof value === "object" && value !== null && Object.hasOwn(value, "next") && Object.hasOwn(value, "collected");
965
+ }
966
+
967
+ function detailsOf(error: StepError): { code: string; message: string; reason?: string } {
968
+ return { code: error.code, message: error.message, ...(error.reason === undefined ? {} : { reason: error.reason }) };
969
+ }
970
+
971
+ /** What several tokens of one iteration saw, together; the later token wins. */
972
+ function mergedLocals(tokens: readonly WfToken[]): WfToken["locals"] {
973
+ const all = tokens.map(localsOf).filter((locals): locals is Locals => locals !== null);
974
+ if (all.length === 0) return null;
975
+ return {
976
+ ...all[0]!,
977
+ vars: Object.assign({}, ...all.map((locals) => locals.vars)),
978
+ nodes: Object.assign({}, ...all.map((locals) => locals.nodes)),
979
+ };
980
+ }