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.
- package/cli/templates/process.ts +1 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
- package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
- package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
- package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
- package/core/workflow/definitions_module/access/Process.references.ts +1 -1
- package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
- package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +27 -16
- package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
- package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
- package/core/workflow/definitions_module/http/Processes.controller.ts +66 -23
- package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
- package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
- package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +35 -10
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +90 -28
- package/core/workflow/generated/deps.ts +15 -3
- package/core/workflow/index.ts +4 -2
- package/core/workflow/internal/WorkflowLanguages.ts +35 -0
- package/core/workflow/internal/canonicalJson.ts +8 -0
- package/core/workflow/internal/http.ts +20 -1
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -2
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +3 -3
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +6 -6
- package/core/workflow/runtime_module/http/Instances.controller.ts +30 -13
- 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/Schedules.ts +2 -2
- package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +43 -12
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +559 -123
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +39 -10
- package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
- package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
- package/core/workflow/runtime_module/model/WfInstance.model.ts +34 -6
- package/core/workflow/runtime_module/model/WfJob.model.ts +29 -4
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfToken.model.ts +15 -3
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +43 -4
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +56 -13
- package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
- package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
- package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
- package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -4
- package/library/openapi/codegen.ts +3 -0
- package/library/validation/types/ValidatorOptions.ts +2 -0
- package/library/workflow/ProcessDocument.ts +73 -23
- package/library/workflow/expressionSyntax.ts +243 -0
- package/library/workflow/expressions.ts +347 -0
- package/library/workflow/formFields.ts +118 -33
- package/library/workflow/index.ts +48 -9
- package/library/workflow/localized.ts +35 -0
- package/library/workflow/masking.ts +72 -0
- package/library/workflow/mermaid.ts +39 -13
- package/library/workflow/nodeKinds.ts +147 -17
- package/library/workflow/palette.ts +110 -0
- package/library/workflow/paths.ts +37 -6
- package/library/workflow/structure.ts +311 -0
- package/library/workflow/upgradeDocument.ts +84 -0
- package/library/workflow/validateProcess.ts +246 -52
- package/package.json +1 -1
- package/library/workflow/conditions.ts +0 -44
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EventToken } from "../../../kernel";
|
|
2
|
-
import { durationMs,
|
|
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
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
291
|
+
const step = this.stepOf(instance, tokens, graph);
|
|
124
292
|
const result = await work(step);
|
|
125
|
-
await this.
|
|
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
|
-
|
|
316
|
+
this.record(step, token, token.nodeId, output ?? null);
|
|
142
317
|
const saveTo = node.config?.saveTo;
|
|
143
|
-
if (typeof saveTo === "string")
|
|
144
|
-
|
|
145
|
-
this.
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
152
|
-
* the token (first one whose `errorCode` equals the
|
|
153
|
-
* code, else one without `errorCode`); 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}).
|
|
154
343
|
*/
|
|
155
|
-
async failStep(step: Step, token: WfToken, error:
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
168
|
-
this.history(step, token, token.nodeId, "error", { ...
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
228
|
-
fail(step: Step, token: WfToken, code: string, message: string): void {
|
|
229
|
-
this.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
}
|