bazis 0.131.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 (71) hide show
  1. package/cli/templates/process.ts +1 -1
  2. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  3. package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
  4. package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
  5. package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
  6. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
  7. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
  8. package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
  9. package/core/workflow/definitions_module/access/Process.references.ts +1 -1
  10. package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
  11. package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
  12. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +27 -16
  13. package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
  14. package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
  15. package/core/workflow/definitions_module/http/Processes.controller.ts +66 -23
  16. package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
  17. package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
  18. package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
  19. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +35 -10
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +90 -28
  21. package/core/workflow/generated/deps.ts +15 -3
  22. package/core/workflow/index.ts +4 -2
  23. package/core/workflow/internal/WorkflowLanguages.ts +35 -0
  24. package/core/workflow/internal/canonicalJson.ts +8 -0
  25. package/core/workflow/internal/http.ts +20 -1
  26. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -2
  27. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  28. package/core/workflow/runtime_module/background/WorkflowRetention.ts +3 -3
  29. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +6 -6
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +30 -13
  32. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  33. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  34. package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
  35. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  36. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  37. package/core/workflow/runtime_module/internal/ToolStep.ts +43 -12
  38. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +559 -123
  39. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +39 -10
  40. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  41. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  42. package/core/workflow/runtime_module/model/WfInstance.model.ts +34 -6
  43. package/core/workflow/runtime_module/model/WfJob.model.ts +29 -4
  44. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  45. package/core/workflow/runtime_module/model/WfToken.model.ts +15 -3
  46. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +43 -4
  47. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +56 -13
  48. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  49. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
  50. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  51. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  52. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
  53. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -4
  54. package/library/openapi/codegen.ts +3 -0
  55. package/library/validation/types/ValidatorOptions.ts +2 -0
  56. package/library/workflow/ProcessDocument.ts +73 -23
  57. package/library/workflow/expressionSyntax.ts +243 -0
  58. package/library/workflow/expressions.ts +347 -0
  59. package/library/workflow/formFields.ts +118 -33
  60. package/library/workflow/index.ts +48 -9
  61. package/library/workflow/localized.ts +35 -0
  62. package/library/workflow/masking.ts +72 -0
  63. package/library/workflow/mermaid.ts +39 -13
  64. package/library/workflow/nodeKinds.ts +147 -17
  65. package/library/workflow/palette.ts +110 -0
  66. package/library/workflow/paths.ts +37 -6
  67. package/library/workflow/structure.ts +311 -0
  68. package/library/workflow/upgradeDocument.ts +84 -0
  69. package/library/workflow/validateProcess.ts +246 -52
  70. package/package.json +1 -1
  71. package/library/workflow/conditions.ts +0 -44
@@ -1,5 +1,5 @@
1
1
  import type { EventToken } from "../../../kernel";
2
- import { durationMs, evaluateCondition, nodeKindOf, readPath, 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 {
@@ -38,10 +43,79 @@ export interface BeginOptions {
38
43
  readonly actor: WorkflowActor;
39
44
  /** Where the start came from; `null` for code, schedules and the worker. */
40
45
  readonly request?: WorkflowRequest | null;
46
+ /** The event that starts it: `$event` and `$trigger.eventKey`. */
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>>;
41
52
  }
42
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
+
43
102
  const LIVE = new Set(["active", "waiting", "failed"]);
44
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
+
45
119
  /**
46
120
  * Moves tokens along the graph up to the steps that wait (a Tool, a task, a
47
121
  * join) and finishes the instance when no live token is left. No state lives in
@@ -77,34 +151,128 @@ export class WorkflowEngine {
77
151
  return graph;
78
152
  }
79
153
 
80
- /** Creates an instance of `graph` and moves its token from `startNodeId`. */
81
- async begin(graph: ProcessGraph, startNodeId: string, options: BeginOptions): Promise<string> {
82
- 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(), {
83
240
  id: Bun.randomUUIDv7(),
241
+ status,
84
242
  versionId: graph.versionId,
85
243
  processKey: graph.processKey,
86
244
  version: graph.version,
87
245
  partition: options.partition,
88
246
  input: options.input,
247
+ vars: initialVarsOf(graph.document),
248
+ event: options.event?.payload ?? null,
249
+ trigger: {
250
+ via: options.startedVia,
251
+ at: this.now().toISOString(),
252
+ actor: options.actor,
253
+ ...(options.event === undefined ? {} : { eventKey: options.event.key }),
254
+ ...(options.cause === undefined ? {} : { cause: options.cause }),
255
+ ...(options.parent === undefined ? {} : { depth: options.parent.depth }),
256
+ },
89
257
  businessKey: options.businessKey,
90
258
  startedVia: options.startedVia,
91
259
  startedByKind: options.actor.kind,
92
260
  startedById: options.actor.id,
261
+ parentInstanceId: options.parent?.instanceId ?? null,
262
+ parentTokenId: options.parent?.tokenId ?? null,
93
263
  });
94
- await this.db.transactionScope(async (transaction) => {
95
- this.db.instances.add(instance);
96
- await this.db.saveChanges();
97
- const step: Step = { instance, tokens: [], graph, events: [] };
98
- this.history(step, null, null, "started", { via: options.startedVia }, options.actor);
99
- step.events.push({
100
- event: WORKFLOW_INSTANCE_STARTED as EventToken<unknown>,
101
- 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 },
102
- });
103
- await this.advance(step, [this.spawn(step, startNodeId, null)]);
104
- await this.db.saveChanges();
105
- this.publishAfter(transaction, step);
106
- });
107
- 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 }));
108
276
  }
109
277
 
110
278
  /**
@@ -120,9 +288,9 @@ export class WorkflowEngine {
120
288
  const instance = await this.db.instances.where((i) => i.id.eq(instanceId)).forUpdate().firstOrDefault();
121
289
  if (!instance) return null;
122
290
  const tokens = await this.db.tokens.where((t) => t.instanceId.eq(instanceId).and(t.status.in(["active", "waiting", "failed"]))).toList();
123
- const step: Step = { instance, tokens, graph, events: [] };
291
+ const step = this.stepOf(instance, tokens, graph);
124
292
  const result = await work(step);
125
- await this.db.saveChanges();
293
+ await this.flush(step);
126
294
  this.publishAfter(transaction, step);
127
295
  return result;
128
296
  });
@@ -136,38 +304,100 @@ export class WorkflowEngine {
136
304
  async complete(step: Step, tokenId: string, output: unknown, actor?: WorkflowActor): Promise<boolean> {
137
305
  const token = step.tokens.find((t) => t.id === tokenId);
138
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[] {
139
314
  const node = step.graph.document.nodes[token.nodeId]!;
140
315
  token.output = output ?? null;
141
- step.instance.nodes = { ...step.instance.nodes, [token.nodeId]: output ?? null };
316
+ this.record(step, token, token.nodeId, output ?? null);
142
317
  const saveTo = node.config?.saveTo;
143
- if (typeof saveTo === "string") step.instance.vars = { ...step.instance.vars, [saveTo]: output ?? null };
144
- if (actor !== undefined) this.history(step, token, token.nodeId, "completed", null, actor);
145
- this.disarm(step, token.nodeId);
146
- await this.advance(step, this.leave(step, token, this.single(step, token.nodeId)));
147
- 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 } };
148
335
  }
149
336
 
150
337
  /**
151
- * A step's Tool failed for good. An error boundary attached to the step takes
152
- * the token (first one whose `errorCode` equals the Tool's `reason` or error
153
- * code, else one without `errorCode`); 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}).
154
343
  */
155
- async failStep(step: Step, token: WfToken, error: { readonly code: string; readonly message: string; readonly reason?: string }): Promise<void> {
156
- const boundaries = (step.graph.boundaries.get(token.nodeId) ?? []).filter(([, node]) => node.kind === "boundaryError");
157
- const matching = boundaries.find(([, node]) => node.config?.errorCode !== undefined && (node.config.errorCode === error.reason || node.config.errorCode === error.code))
158
- ?? boundaries.find(([, node]) => node.config?.errorCode === undefined);
159
- if (!matching) {
160
- 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));
348
+ return;
349
+ }
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 });
161
356
  return;
162
357
  }
163
- const [boundaryId] = matching;
164
- const caught = { code: error.code, message: error.message, ...(error.reason === undefined ? {} : { reason: error.reason }) };
165
- token.output = { error: caught };
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 };
166
373
  this.close(token, "done");
167
- this.disarm(step, token.nodeId);
168
- this.history(step, token, token.nodeId, "error", { ...caught, boundary: boundaryId });
169
- step.instance.nodes = { ...step.instance.nodes, [boundaryId]: caught };
170
- 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 [];
171
401
  }
172
402
 
173
403
  /**
@@ -197,6 +427,7 @@ export class WorkflowEngine {
197
427
  const token = step.tokens.find((t) => t.id === tokenId);
198
428
  if (!token || token.status !== "waiting" || step.instance.status !== "running") return false;
199
429
  const node = step.graph.document.nodes[token.nodeId];
430
+ if (node?.kind === "eventWait" || node?.kind === "boundaryEvent") step.instance.event = payload;
200
431
  if (node?.kind === "eventWait") {
201
432
  this.history(step, token, token.nodeId, "event", { event: eventKey });
202
433
  return await this.complete(step, tokenId, payload);
@@ -211,66 +442,122 @@ export class WorkflowEngine {
211
442
  const interrupting = node.config?.interrupting !== false;
212
443
  this.history(step, token, token.nodeId, type, { ...details, ...(type === "timer" ? output : {}), interrupting });
213
444
  if (interrupting) {
214
- for (const host of step.tokens.filter((t) => t.nodeId === node.attachedTo && LIVE.has(t.status))) {
215
- this.close(host, "cancelled");
216
- if (step.graph.document.nodes[host.nodeId]?.kind === "userTask") {
217
- step.events.push({ event: WORKFLOW_TOKEN_CLOSED as EventToken<unknown>, payload: { instanceId: step.instance.id, tokenId: host.id, reason: "interrupted" } });
218
- }
219
- }
220
- 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);
221
447
  }
222
- step.instance.nodes = { ...step.instance.nodes, [token.nodeId]: output };
448
+ this.record(step, token, token.nodeId, output);
223
449
  await this.advance(step, this.leave(step, token, this.single(step, token.nodeId)));
224
450
  return true;
225
451
  }
226
452
 
227
- /** A step failed: the token waits for a retry, the instance is `failed` with the reason. */
228
- fail(step: Step, token: WfToken, code: string, message: string): void {
229
- this.disarm(step, token.nodeId);
230
- token.status = "failed";
231
- step.instance.status = "failed";
232
- step.instance.error = { node: token.nodeId, code, message };
233
- 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 }));
234
456
  }
235
457
 
236
- /** 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
+ */
237
463
  async retry(step: Step, actor: WorkflowActor, request: WorkflowRequest | null = null): Promise<void> {
238
464
  const failed = step.tokens.filter((t) => t.status === "failed");
465
+ const left = step.tokens.filter((t) => t.status === "active");
239
466
  step.events.push({
240
467
  event: WORKFLOW_INSTANCE_RETRIED as EventToken<unknown>,
241
468
  payload: { instanceId: step.instance.id, processKey: step.instance.processKey, partition: step.instance.partition, actor, request },
242
469
  });
243
470
  step.instance.status = "running";
244
471
  step.instance.error = null;
472
+ await this.rearm(step);
245
473
  for (const token of failed) {
246
474
  token.status = "active";
247
475
  this.history(step, token, token.nodeId, "retried", null, actor);
248
476
  }
249
- 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)));
250
492
  }
251
493
 
252
494
  /** Closes every live token, job and subscription; the application drops the tasks on `WORKFLOW_TOKEN_CLOSED`. */
253
495
  async cancel(step: Step, reason: string | null, actor: WorkflowActor, request: WorkflowRequest | null = null): Promise<void> {
254
- const open = step.tokens.filter((t) => LIVE.has(t.status));
255
496
  step.events.push({
256
497
  event: WORKFLOW_INSTANCE_CANCELLED as EventToken<unknown>,
257
498
  payload: { instanceId: step.instance.id, processKey: step.instance.processKey, partition: step.instance.partition, reason, actor, request },
258
499
  });
259
- for (const token of open) {
260
- token.status = "cancelled";
261
- token.endedAt = this.now();
262
- }
263
- await this.db.jobs.asNoTracking().where((j) => j.instanceId.eq(step.instance.id)).executeDelete();
264
- await this.db.subscriptions.asNoTracking().where((s) => s.instanceId.eq(step.instance.id)).executeDelete();
500
+ await this.closeAll(step, "cancelled");
265
501
  step.instance.status = "cancelled";
266
502
  step.instance.endedAt = this.now();
267
503
  this.history(step, null, null, "cancelled", { reason }, actor);
268
- for (const token of open) {
269
- if (token.nodeId !== "" && step.graph.document.nodes[token.nodeId]?.kind === "userTask") {
270
- step.events.push({ event: WORKFLOW_TOKEN_CLOSED as EventToken<unknown>, payload: { instanceId: step.instance.id, tokenId: token.id, reason: "cancelled" } });
271
- }
272
- }
273
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
+ }
274
561
  }
275
562
 
276
563
  /** Adds a history entry to the step. */
@@ -298,27 +585,35 @@ export class WorkflowEngine {
298
585
  if (step.instance.status === "running" && !step.tokens.some((t) => LIVE.has(t.status))) {
299
586
  step.instance.status = "completed";
300
587
  step.instance.endedAt = this.now();
588
+ step.instance.output = outputOf(step.graph.document, step.instance.vars);
301
589
  this.history(step, null, null, "completed", null);
302
590
  this.finished(step, "completed");
591
+ if (step.graph.document.settings?.runTimeout !== undefined) await this.dropJobs(step, ["runTimeout"]);
592
+ this.stopped(step);
303
593
  }
304
594
  }
305
595
 
306
596
  /** The node under a token: returns the new tokens to move on. */
307
597
  private enter(step: Step, token: WfToken): WfToken[] {
308
598
  const node = step.graph.document.nodes[token.nodeId];
309
- if (!node) {
310
- this.fail(step, token, "unknown_node", `node ${token.nodeId} is not in the version`);
311
- return [];
312
- }
599
+ if (!node) return this.failed(step, token, { code: "unknown_node", message: `node ${token.nodeId} is not in the version` });
313
600
  const role = nodeKindOf(node.kind)?.role;
314
601
  if (role === "start") return this.leave(step, token, this.single(step, token.nodeId));
315
602
  if (role === "end") {
316
603
  this.close(token, "done");
317
- return [];
604
+ return token.parentTokenId === null ? [] : this.bodyEnded(step, token);
318
605
  }
319
606
  // An error boundary that caught a step's failure: it only leads on.
320
607
  if (role === "boundary") return this.leave(step, token, this.single(step, token.nodeId));
321
608
  switch (node.kind) {
609
+ case "assign":
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, []);
322
617
  case "serviceTask":
323
618
  return this.arm(step, token, this.waitForTool(step, token));
324
619
  case "userTask":
@@ -332,19 +627,35 @@ export class WorkflowEngine {
332
627
  case "parallelGateway":
333
628
  return this.parallel(step, token);
334
629
  default:
335
- this.fail(step, token, "not_supported", `${node.kind} cannot be entered by a flow`);
336
- return [];
630
+ return this.failed(step, token, { code: "not_supported", message: `${node.kind} cannot be entered by a flow` });
337
631
  }
338
632
  }
339
633
 
340
- private waitForTimer(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
341
- const dueAt = timerDue(node.config ?? {}, dataOf(step.instance), this.now());
342
- if (dueAt === undefined) {
343
- this.fail(step, token, "bad_timer", `the timer of ${token.nodeId} has no valid date`);
344
- return [];
634
+ /**
635
+ * An `assign` step: each value sees the ones before it. The history names the
636
+ * variables; their values go there only under `log.variables`, personal ones masked.
637
+ */
638
+ private assign(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
639
+ const { document } = step.graph;
640
+ const assignments = (node.config?.assignments ?? []) as readonly { readonly var: string; readonly value: Expr }[];
641
+ const personal = masksPersonal(document) ? personalVariables(document) : new Set<string>();
642
+ const reads = masksPersonal(document) ? personalPaths(document) : new Set<string>();
643
+ const logged: Record<string, unknown> = {};
644
+ for (const { var: name, value } of assignments) {
645
+ const result = evaluateExpression(value, dataOf(step.instance, this.now(), token)) ?? null;
646
+ step.instance.vars = { ...step.instance.vars, [name]: result };
647
+ logged[name] = personal.has(name) ? PERSONAL_MASK : maskByMapping(value, result, reads);
345
648
  }
649
+ const log = document.settings?.log?.variables === true;
650
+ this.history(step, token, token.nodeId, "assigned", log ? { values: logged } : { vars: assignments.map((item) => item.var) });
651
+ return this.leave(step, token, this.single(step, token.nodeId));
652
+ }
653
+
654
+ private waitForTimer(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
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` });
346
657
  token.status = "waiting";
347
- 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 });
348
659
  return [];
349
660
  }
350
661
 
@@ -356,63 +667,55 @@ export class WorkflowEngine {
356
667
  if (host.status !== "waiting") return next;
357
668
  for (const [id, node] of step.graph.boundaries.get(host.nodeId) ?? []) {
358
669
  if (node.kind === "boundaryEvent") {
359
- const correlation = correlationOf(node.config ?? {}, dataOf(step.instance));
360
- if (correlation === undefined) {
361
- this.fail(step, host, "bad_correlation", `the correlation of ${id} has no value`);
362
- return [];
363
- }
364
- 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);
365
673
  watcher.status = "waiting";
366
674
  this.subscribe(step, watcher, String(node.config?.event), correlation);
367
675
  continue;
368
676
  }
369
677
  if (node.kind !== "boundaryTimer") continue;
370
- const dueAt = timerDue(node.config ?? {}, dataOf(step.instance), this.now());
371
- if (dueAt === undefined) {
372
- this.fail(step, host, "bad_timer", `the timer of ${id} has no valid date`);
373
- return [];
374
- }
375
- 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);
376
681
  watcher.status = "waiting";
377
- 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 });
378
683
  }
379
684
  return next;
380
685
  }
381
686
 
382
- /** The step left or failed: its boundary tokens close; their jobs find the tokens closed and drop. */
383
- 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 {
384
689
  const boundaries = new Set((step.graph.boundaries.get(hostNodeId) ?? []).map(([id]) => id));
385
690
  if (boundaries.size === 0) return;
386
691
  for (const token of step.tokens) {
387
- 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");
388
693
  }
389
694
  }
390
695
 
391
696
  private waitForEvent(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
392
- const correlation = correlationOf(node.config ?? {}, dataOf(step.instance));
393
- if (correlation === undefined) {
394
- this.fail(step, token, "bad_correlation", `the correlation of ${token.nodeId} has no value`);
395
- return [];
396
- }
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` });
397
699
  token.status = "waiting";
398
700
  this.subscribe(step, token, String(node.config?.event), correlation);
399
701
  return [];
400
702
  }
401
703
 
402
704
  private subscribe(step: Step, token: WfToken, eventKey: string, correlation: string | null): void {
403
- this.db.subscriptions.add(Object.assign(new WfSubscription(), {
705
+ step.subscriptions.push(Object.assign(new WfSubscription(), {
404
706
  id: Bun.randomUUIDv7(),
405
707
  instanceId: step.instance.id,
406
708
  tokenId: token.id,
407
709
  eventKey,
408
710
  correlation,
409
711
  partition: step.instance.partition,
712
+ createdAt: this.now(),
410
713
  }));
411
714
  }
412
715
 
413
716
  private waitForTool(step: Step, token: WfToken): WfToken[] {
414
717
  token.status = "waiting";
415
- 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 });
416
719
  return [];
417
720
  }
418
721
 
@@ -441,13 +744,10 @@ export class WorkflowEngine {
441
744
  private choose(step: Step, token: WfToken): WfToken[] {
442
745
  const flows = step.graph.outgoing.get(token.nodeId) ?? [];
443
746
  if (flows.length === 1) return this.leave(step, token, flows[0]!);
444
- const data = dataOf(step.instance);
445
- const chosen = flows.find(([, flow]) => flow.condition !== undefined && evaluateCondition(flow.condition, data))
747
+ const data = dataOf(step.instance, this.now(), token);
748
+ const chosen = flows.find(([, flow]) => flow.condition !== undefined && isTruthy(evaluateExpression(flow.condition, data)))
446
749
  ?? flows.find(([, flow]) => flow.isDefault === true);
447
- if (!chosen) {
448
- this.fail(step, token, "no_branch", "no condition matched and there is no default flow");
449
- return [];
450
- }
750
+ if (!chosen) return this.failed(step, token, { code: "no_branch", message: "no condition matched and there is no default flow" });
451
751
  return this.leave(step, token, chosen);
452
752
  }
453
753
 
@@ -459,16 +759,17 @@ export class WorkflowEngine {
459
759
  token.status = "waiting";
460
760
  const arrived = new Map<string, WfToken>();
461
761
  for (const other of step.tokens) {
462
- 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);
463
763
  }
464
764
  if (!inputs.every(([id]) => arrived.has(id))) return [];
465
765
  for (const joined of arrived.values()) this.close(joined, "done");
466
- 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()]) });
467
768
  }
468
769
  const outputs = step.graph.outgoing.get(token.nodeId) ?? [];
469
770
  this.close(current, "done");
470
771
  this.history(step, current, current.nodeId, "left", { flows: outputs.map(([id]) => id) });
471
- return outputs.map(([id, flow]) => this.spawn(step, flow.target, id));
772
+ return outputs.map(([id, flow]) => this.spawn(step, flow.target, id, current));
472
773
  }
473
774
 
474
775
  /** The single outgoing flow of a node; validation guarantees it exists. */
@@ -481,7 +782,106 @@ export class WorkflowEngine {
481
782
  private leave(step: Step, token: WfToken, [flowId, flow]: readonly [string, ProcessFlow]): WfToken[] {
482
783
  this.close(token, "done");
483
784
  this.history(step, token, token.nodeId, "left", { flow: flowId });
484
- 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);
485
885
  }
486
886
 
487
887
  private close(token: WfToken, status: "done" | "cancelled"): void {
@@ -489,15 +889,16 @@ export class WorkflowEngine {
489
889
  token.endedAt = this.now();
490
890
  }
491
891
 
492
- private spawn(step: Step, nodeId: string, viaFlowId: string | null): WfToken {
493
- 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 });
494
895
  this.db.tokens.add(token);
495
896
  step.tokens.push(token);
496
897
  this.history(step, token, nodeId, "entered", viaFlowId ? { flow: viaFlowId } : null);
497
898
  return token;
498
899
  }
499
900
 
500
- private finished(step: Step, status: "completed" | "cancelled"): void {
901
+ private finished(step: Step, status: "completed" | "cancelled" | "timedOut"): void {
501
902
  step.events.push({
502
903
  event: WORKFLOW_INSTANCE_FINISHED as EventToken<unknown>,
503
904
  payload: { instanceId: step.instance.id, processKey: step.instance.processKey, partition: step.instance.partition, status },
@@ -539,6 +940,41 @@ export function correlationOf(config: Readonly<Record<string, unknown>>, data: I
539
940
  return value === undefined || value === null || value === "" || typeof value === "object" ? undefined : String(value);
540
941
  }
541
942
 
542
- export function dataOf(instance: WfInstance): InstanceData {
543
- return { input: instance.input, vars: instance.vars, nodes: instance.nodes };
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;
946
+ return {
947
+ input: instance.input,
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 } : {}),
951
+ event: instance.event,
952
+ trigger: instance.trigger,
953
+ run: { id: instance.id, processKey: instance.processKey, version: instance.version, partition: instance.partition, test: instance.test },
954
+ now: now.toISOString(),
955
+ };
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
+ };
544
980
  }