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.
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
- package/core/workflow/definitions_module/http/Processes.controller.ts +2 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +2 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -2
- package/core/workflow/generated/deps.ts +4 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
- package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
- package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
- package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
- package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
- package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +518 -132
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +37 -6
- package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
- package/core/workflow/runtime_module/model/WfJob.model.ts +13 -4
- package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +38 -3
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +48 -7
- package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
- package/library/workflow/ProcessDocument.ts +14 -0
- package/library/workflow/expressions.ts +5 -1
- package/library/workflow/index.ts +1 -1
- package/library/workflow/masking.ts +17 -3
- package/library/workflow/mermaid.ts +22 -6
- package/library/workflow/nodeKinds.ts +59 -0
- package/library/workflow/palette.ts +3 -1
- package/library/workflow/paths.ts +8 -2
- package/library/workflow/structure.ts +33 -4
- package/library/workflow/validateProcess.ts +129 -17
- 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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
291
|
+
const step = this.stepOf(instance, tokens, graph);
|
|
134
292
|
const result = await work(step);
|
|
135
|
-
await this.
|
|
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
|
-
|
|
316
|
+
this.record(step, token, token.nodeId, output ?? null);
|
|
152
317
|
const saveTo = node.config?.saveTo;
|
|
153
|
-
if (typeof saveTo === "string")
|
|
154
|
-
|
|
155
|
-
this.
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
162
|
-
* the token (first one whose `errorCode` equals the
|
|
163
|
-
* code, else one without `errorCode`); otherwise
|
|
164
|
-
* `{ skipped: true, error }`; otherwise
|
|
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:
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
185
|
-
this.history(step, token, token.nodeId, "error", { ...
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
246
|
-
fail(step: Step, token: WfToken, code: string, message: string): void {
|
|
247
|
-
this.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
+
}
|