bazis 0.132.0 → 0.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/core/agent/AgentToolExecutor.ts +5 -0
  2. package/core/agent/Tool.contract.ts +2 -2
  3. package/core/agent/metadata.ts +11 -0
  4. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  5. package/core/scripts/di-generate.ts +8 -0
  6. package/core/tools/StandardTools.module.ts +14 -0
  7. package/core/tools/generated/deps.ts +10 -0
  8. package/core/tools/index.ts +8 -0
  9. package/core/tools/orm/OrmEntity.references.ts +71 -0
  10. package/core/tools/orm/ormEntityTools.ts +423 -0
  11. package/core/workflow/contracts/WorkflowError.ts +2 -1
  12. package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
  13. package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
  14. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -2
  15. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
  16. package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
  17. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
  18. package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
  19. package/core/workflow/definitions_module/http/Processes.controller.ts +21 -5
  20. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +10 -0
  21. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +28 -2
  22. package/core/workflow/generated/deps.ts +12 -2
  23. package/core/workflow/index.ts +5 -0
  24. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +9 -0
  25. package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
  26. package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
  27. package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
  28. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  29. package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
  30. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  31. package/core/workflow/runtime_module/internal/RunLimits.ts +134 -0
  32. package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
  33. package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
  34. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  35. package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
  36. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +544 -132
  37. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +44 -6
  38. package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
  39. package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
  40. package/core/workflow/runtime_module/model/WfJob.model.ts +18 -8
  41. package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
  42. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +59 -4
  43. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +72 -42
  44. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  45. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
  46. package/library/workflow/ProcessDocument.ts +14 -0
  47. package/library/workflow/cron.ts +103 -24
  48. package/library/workflow/expressions.ts +5 -1
  49. package/library/workflow/index.ts +2 -2
  50. package/library/workflow/masking.ts +17 -3
  51. package/library/workflow/mermaid.ts +22 -6
  52. package/library/workflow/nodeKinds.ts +123 -9
  53. package/library/workflow/palette.ts +3 -1
  54. package/library/workflow/paths.ts +10 -4
  55. package/library/workflow/structure.ts +33 -4
  56. package/library/workflow/validateProcess.ts +141 -17
  57. package/package.json +2 -1
@@ -0,0 +1,186 @@
1
+ import { inputSpecOf, resolveInput } from "../../../../library/workflow";
2
+ import type { ServiceProvider } from "../../../di";
3
+ import { WorkflowError } from "../../contracts/WorkflowError";
4
+ import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
5
+ import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
6
+ import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
7
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
8
+ import type { WfInstance } from "../model/WfInstance.model";
9
+ import type { WfJob } from "../model/WfJob.model";
10
+ import type { WfToken } from "../model/WfToken.model";
11
+ import { dataOf, runActorOf, type StepError, type WorkflowEngine } from "./WorkflowEngine";
12
+
13
+ /** How deep processes may call each other: a process that calls itself has to stop somewhere. */
14
+ const MAX_DEPTH = 16;
15
+
16
+ /**
17
+ * `callProcess`: the jobs between a caller and the process it called. Each runs in
18
+ * the transaction of one instance, so caller and child never wait for each other.
19
+ * The child runs on behalf of the caller's actor, in its partition.
20
+ */
21
+ export class ChildCalls {
22
+ constructor(
23
+ private readonly db: RuntimeDbContext,
24
+ private readonly engine: WorkflowEngine,
25
+ private readonly definitions: IWorkflowDefinitions,
26
+ /** The scope: the input validator is asked here, so that the application's own registration replaces the default. */
27
+ private readonly services: ServiceProvider,
28
+ private readonly languages: WorkflowLanguages,
29
+ ) {}
30
+
31
+ /**
32
+ * A `call` job: the step starts its process. If it has called already (a retry, a
33
+ * repeat after a crash), it goes on with that child: a failed one is retried, so
34
+ * that its finished steps do not run twice. A cancelled or timed out one is called
35
+ * anew only when the step was entered again (`fresh`).
36
+ */
37
+ async call(job: WfJob): Promise<void> {
38
+ const instance = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
39
+ const token = job.tokenId === null ? undefined : await this.db.tokens.asNoTracking().firstOrDefault((t) => t.id.eq(job.tokenId!));
40
+ if (!instance || !token || instance.status !== "running" || token.status !== "waiting") {
41
+ await this.dropJob(job);
42
+ return;
43
+ }
44
+ const child = await this.lastChild(instance.id, token.id);
45
+ if (child?.status === "running" || child?.status === "pending") {
46
+ await this.dropJob(job);
47
+ return;
48
+ }
49
+ if (child?.status === "failed") {
50
+ const actor = runActorOf((await this.engine.graphOf(instance.versionId)).document, instance);
51
+ await this.engine.inStep(child.id, async (step) => {
52
+ await this.dropJob(job);
53
+ if (step.instance.status === "failed") await this.engine.retry(step, actor);
54
+ });
55
+ return;
56
+ }
57
+ if (child?.status === "completed") {
58
+ await this.engine.inStep(instance.id, async (step) => {
59
+ await this.dropJob(job);
60
+ await this.engine.complete(step, token.id, child.output ?? {});
61
+ });
62
+ return;
63
+ }
64
+ if (child && (job.payload as { fresh?: boolean } | null)?.fresh !== true) {
65
+ await this.applyEnd(job, instance.id, token.id, child);
66
+ return;
67
+ }
68
+ await this.start(job, instance, token);
69
+ }
70
+
71
+ private async start(job: WfJob, instance: WfInstance, token: WfToken): Promise<void> {
72
+ const graph = await this.engine.graphOf(instance.versionId);
73
+ const config = graph.document.nodes[token.nodeId]?.config ?? {};
74
+ const key = String(config.process ?? "");
75
+ const depth = (typeof instance.trigger?.depth === "number" ? instance.trigger.depth : 0) + 1;
76
+ const actor = runActorOf(graph.document, instance);
77
+ const refuse = (error: StepError) => this.engine.inStep(instance.id, async (step) => {
78
+ await this.dropJob(job);
79
+ const current = step.tokens.find((t) => t.id === token.id);
80
+ if (current?.status === "waiting" && step.instance.status === "running") await this.engine.failStep(step, current, error);
81
+ });
82
+ if (depth > MAX_DEPTH) {
83
+ await refuse({ code: "call_depth", message: `Processes call each other deeper than ${MAX_DEPTH} levels` });
84
+ return;
85
+ }
86
+ let version: VersionDetails;
87
+ try {
88
+ version = await this.definitions.latest(key);
89
+ } catch (error) {
90
+ if (!(error instanceof WorkflowError)) throw error;
91
+ await refuse({ code: "process_unavailable", message: error.message, reason: error.code });
92
+ return;
93
+ }
94
+ const start = Object.entries(version.document.nodes).find(([, node]) => node.kind === "manualStart");
95
+ if (!start) {
96
+ await refuse({ code: "process_unavailable", message: `Process ${key} has no manual start` });
97
+ return;
98
+ }
99
+ const input = resolveInput(config.input ?? {}, dataOf(instance, this.engine.now(), token));
100
+ const { value, issues } = await this.services.resolve(WorkflowInputValidator).validate(inputSpecOf(version.document), input, { purpose: "input", processKey: key, partition: instance.partition, language: this.languages.default, actor });
101
+ if (issues.length > 0) {
102
+ await refuse({ code: "invalid_input", message: `The input of process ${key} is invalid: ${issues.map((issue) => `${issue.field}: ${issue.message}`).join("; ")}` });
103
+ return;
104
+ }
105
+ const childGraph = this.engine.cache(version);
106
+ await this.engine.inStep(instance.id, async (step) => {
107
+ await this.dropJob(job);
108
+ const current = step.tokens.find((t) => t.id === token.id);
109
+ if (current?.status !== "waiting" || step.instance.status !== "running") return;
110
+ const { id } = await this.engine.begin(childGraph, start[0], {
111
+ input: value,
112
+ partition: step.instance.partition,
113
+ businessKey: null,
114
+ startedVia: "callProcess",
115
+ actor,
116
+ parent: { instanceId: step.instance.id, tokenId: current.id, depth },
117
+ cause: { instanceId: step.instance.id, processKey: step.instance.processKey, nodeId: current.nodeId },
118
+ });
119
+ this.engine.history(step, current, current.nodeId, "called", { process: key, version: version.version, instanceId: id });
120
+ });
121
+ }
122
+
123
+ /** A `returned` job: a child stopped. If its caller no longer waits, the job drops; a caller that is itself stopped looks again on its retry. */
124
+ async returned(job: WfJob): Promise<void> {
125
+ const { parentInstanceId, parentTokenId } = job.payload as { parentInstanceId: string; parentTokenId: string };
126
+ const child = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
127
+ if (!child) {
128
+ await this.dropJob(job);
129
+ return;
130
+ }
131
+ await this.applyEnd(job, parentInstanceId, parentTokenId, child);
132
+ }
133
+
134
+ private async applyEnd(job: WfJob, instanceId: string, tokenId: string, child: WfInstance): Promise<void> {
135
+ const done = await this.engine.inStep(instanceId, async (step) => {
136
+ await this.dropJob(job);
137
+ const token = step.tokens.find((t) => t.id === tokenId);
138
+ if (token?.status !== "waiting" || step.instance.status !== "running") return;
139
+ if (child.status === "completed") {
140
+ this.engine.history(step, token, token.nodeId, "returned", { instanceId: child.id });
141
+ await this.engine.complete(step, tokenId, child.output ?? {});
142
+ return;
143
+ }
144
+ const error = errorOf(child);
145
+ if (error) await this.engine.failStep(step, token, error);
146
+ });
147
+ if (done === null) await this.dropJob(job);
148
+ }
149
+
150
+ /** A `cancelChildren` job: the step that called is closed, so is what it called. */
151
+ async cancelChildren(job: WfJob): Promise<void> {
152
+ const { tokenId, reason } = job.payload as { tokenId: string; reason: string };
153
+ const parent = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
154
+ if (parent) {
155
+ const children = await this.db.instances.asNoTracking()
156
+ .where((i) => i.parentInstanceId.eq(parent.id).and(i.parentTokenId.eq(tokenId)).and(i.status.in(["pending", "running", "failed"])))
157
+ .toList();
158
+ const actor = { kind: "system" as const, id: `process:${parent.processKey}` };
159
+ for (const child of children) {
160
+ await this.engine.inStep(child.id, async (step) => {
161
+ if (step.instance.status === "pending" || step.instance.status === "running" || step.instance.status === "failed") await this.engine.cancel(step, reason, actor);
162
+ });
163
+ }
164
+ }
165
+ await this.dropJob(job);
166
+ }
167
+
168
+ /** The newest child of a step. */
169
+ private async lastChild(instanceId: string, tokenId: string): Promise<WfInstance | undefined> {
170
+ const children = await this.db.instances.asNoTracking().where((i) => i.parentInstanceId.eq(instanceId).and(i.parentTokenId.eq(tokenId))).orderBy((i) => i.id).toList();
171
+ return children.at(-1);
172
+ }
173
+
174
+ private async dropJob(job: WfJob): Promise<void> {
175
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
176
+ }
177
+ }
178
+
179
+ /** The failure of a step whose child stopped otherwise than by completing; `undefined` while it runs again. */
180
+ function errorOf(child: WfInstance): StepError | undefined {
181
+ const about = `Instance ${child.id} of process ${child.processKey}`;
182
+ if (child.status === "failed") return { code: "child_failed", message: `${about} failed: ${child.error?.message ?? ""}`, ...(child.error ? { reason: child.error.code } : {}) };
183
+ if (child.status === "cancelled") return { code: "child_cancelled", message: `${about} was cancelled` };
184
+ if (child.status === "timedOut") return { code: "child_timed_out", message: `${about} took too long`, reason: "timeout" };
185
+ return undefined;
186
+ }
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { durationMs, evaluateExpression, inputSpecOf, isTruthy, type Expr, type FormIssue, type InstanceData, type ProcessNode } from "../../../../library/workflow";
3
+ import { LOGGER } from "../../../kernel";
4
+ import type { ServiceProvider } from "../../../di";
5
+ import { isUuid, type WorkflowActor, type WorkflowRequest } from "../../contracts/types";
6
+ import { WorkflowError } from "../../contracts/WorkflowError";
7
+ import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
8
+ import { WorkflowPartitions } from "../../contracts/WorkflowPartitions";
9
+ import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
10
+ import { canonicalJson } from "../../internal/canonicalJson";
11
+ import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
12
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
13
+ import { WfJob } from "../model/WfJob.model";
14
+ import type { EmitCause, EmitResult } from "../services/IWorkflowRuntime.service";
15
+ import type { WorkflowEngine } from "./WorkflowEngine";
16
+
17
+ /** An event on its way: what `emit` got, with the defaults in place. */
18
+ export interface EventMessage {
19
+ readonly eventKey: string;
20
+ readonly payload: Record<string, unknown>;
21
+ readonly correlation: string | null;
22
+ readonly partition: string | null;
23
+ readonly actor: WorkflowActor;
24
+ readonly request: WorkflowRequest | null;
25
+ readonly cause: EmitCause | null;
26
+ }
27
+
28
+ interface EventStartConfig {
29
+ readonly filter?: Expr;
30
+ readonly debounce?: { readonly key: Expr; readonly window: string };
31
+ readonly delay?: string;
32
+ readonly ignoreOwn?: boolean;
33
+ }
34
+
35
+ /** A process the event is to start, with its checked input. */
36
+ interface PlannedStart {
37
+ readonly version: VersionDetails;
38
+ readonly nodeId: string;
39
+ readonly config: EventStartConfig;
40
+ readonly input: Record<string, unknown>;
41
+ }
42
+
43
+ /**
44
+ * Takes an event to whoever waits for it and to the processes it starts
45
+ * (WORKFLOW-002 §5.1, §5.2). An `event` job (`emitLater`) turns into an
46
+ * `eventStart` job per process in one transaction, and each of those starts its
47
+ * instance in one transaction with its own removal: a repeat after a crash starts
48
+ * nothing twice. Waits are safe as they are: a subscription leaves with its step.
49
+ */
50
+ export class EventDelivery {
51
+ constructor(
52
+ private readonly db: RuntimeDbContext,
53
+ private readonly engine: WorkflowEngine,
54
+ private readonly definitions: IWorkflowDefinitions,
55
+ private readonly languages: WorkflowLanguages,
56
+ /** The scope: the application's own ports replace the defaults. */
57
+ private readonly services: ServiceProvider,
58
+ ) {}
59
+
60
+ /** Moves the waits and starts the processes, now or, under `debounce` and `delay`, by a job. */
61
+ async emit(message: EventMessage): Promise<EmitResult> {
62
+ const continued = await this.continueWaits(message);
63
+ const { planned, rejected } = await this.plan(message);
64
+ const started: string[] = [];
65
+ const skipped: { processKey: string; instanceId: string }[] = [];
66
+ const deferred: { processKey: string }[] = [];
67
+ for (const start of planned) {
68
+ if (start.config.debounce !== undefined || start.config.delay !== undefined) {
69
+ await this.defer(start, message);
70
+ deferred.push({ processKey: start.version.key });
71
+ continue;
72
+ }
73
+ const begun = await this.begin(start.version, start.nodeId, start.input, message);
74
+ if (begun.skipped) skipped.push({ processKey: start.version.key, instanceId: begun.id });
75
+ else started.push(begun.id);
76
+ }
77
+ return { started, skipped, deferred, continued, rejected };
78
+ }
79
+
80
+ /** An `event` job: the waits move, and the job becomes an `eventStart` job per process it starts. */
81
+ async deliverLater(job: WfJob): Promise<void> {
82
+ const message = job.payload as EventMessage;
83
+ await this.continueWaits(message);
84
+ const { planned, rejected } = await this.plan(message);
85
+ for (const { processKey, issues } of rejected) this.services.tryResolve(LOGGER)?.warn("workflow event did not start a process", { event: message.eventKey, processKey, issues });
86
+ await this.db.transactionScope(async () => {
87
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
88
+ for (const start of planned) await this.defer(start, message);
89
+ });
90
+ }
91
+
92
+ /** An `eventStart` job: its instance starts together with the removal of the job. An archived process takes nothing. */
93
+ async startLater(job: WfJob): Promise<void> {
94
+ const { message, input } = job.payload as { readonly message: EventMessage; readonly input: Record<string, unknown> };
95
+ const version = job.versionId === null ? undefined : await this.definitions.versionById(job.versionId);
96
+ if (version === undefined || job.nodeId === null || !await this.available(version.key)) {
97
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
98
+ return;
99
+ }
100
+ const graph = this.engine.cache(version);
101
+ await this.db.transactionScope(async () => {
102
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
103
+ await this.engine.begin(graph, job.nodeId!, this.beginOptions(input, message));
104
+ });
105
+ }
106
+
107
+ private async available(processKey: string): Promise<boolean> {
108
+ try {
109
+ await this.definitions.latest(processKey);
110
+ return true;
111
+ } catch (error) {
112
+ if (error instanceof WorkflowError) return false;
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ /** Every wait of the event: `eventWait.until` may leave a step waiting. */
118
+ private async continueWaits(message: EventMessage): Promise<string[]> {
119
+ const { eventKey, correlation, partition, payload } = message;
120
+ let waits = this.db.subscriptions.asNoTracking().where((s) => s.eventKey.eq(eventKey));
121
+ waits = correlation === null ? waits.where((s) => s.correlation.isNull()) : waits.where((s) => s.correlation.isNull().or(s.correlation.eq(correlation)));
122
+ if (partition !== null) waits = waits.where((s) => s.partition.eq(partition));
123
+ const continued = new Set<string>();
124
+ for (const wait of await waits.orderBy((s) => s.id).toList()) {
125
+ const moved = await this.engine.inStep(wait.instanceId, async (step) => {
126
+ if (!this.engine.eventAccepted(step, wait.tokenId, payload)) return false;
127
+ await this.db.subscriptions.asNoTracking().where((s) => s.id.eq(wait.id)).executeDelete();
128
+ return await this.engine.eventReceived(step, wait.tokenId, eventKey, payload);
129
+ });
130
+ if (moved === true) continued.add(wait.instanceId);
131
+ }
132
+ return [...continued];
133
+ }
134
+
135
+ /** The `eventStart` nodes that take the event: connected to its partition, past `ignoreOwn` and `filter`, with a valid input. */
136
+ private async plan(message: EventMessage): Promise<{ readonly planned: PlannedStart[]; readonly rejected: { processKey: string; issues: readonly FormIssue[] }[] }> {
137
+ const { eventKey, payload, partition, actor } = message;
138
+ const planned: PlannedStart[] = [];
139
+ const rejected: { processKey: string; issues: readonly FormIssue[] }[] = [];
140
+ const data = eventData(message, this.engine.now());
141
+ const own = await this.causeProcess(message.cause);
142
+ for (const version of await this.definitions.published()) {
143
+ for (const [nodeId, node] of Object.entries(version.document.nodes)) {
144
+ if (node.kind !== "eventStart" || node.config?.event !== eventKey) continue;
145
+ const config = eventStartConfig(node);
146
+ if (!await this.connected(version.key, partition)) continue;
147
+ if (config.ignoreOwn === true && own === version.key) continue;
148
+ if (config.filter !== undefined && !isTruthy(evaluateExpression(config.filter, data))) continue;
149
+ // An event carries more than a process needs: fields the input does not declare are dropped, not refused.
150
+ const { value, issues: all } = await this.services.resolve(WorkflowInputValidator).validate(inputSpecOf(version.document), payload, { purpose: "input", processKey: version.key, partition, language: this.languages.default, actor });
151
+ const issues = all.filter((issue) => issue.code !== "unknown");
152
+ if (issues.length > 0) rejected.push({ processKey: version.key, issues });
153
+ else planned.push({ version, nodeId, config, input: value });
154
+ }
155
+ }
156
+ return { planned, rejected };
157
+ }
158
+
159
+ /** Whether the process works in the partition of the event; an event without a partition is for processes without them. */
160
+ private async connected(processKey: string, partition: string | null): Promise<boolean> {
161
+ const partitions = await this.services.resolve(WorkflowPartitions).of(processKey, { partition, language: this.languages.default, actor: null });
162
+ return partitions.some((item) => item.enabled && item.partition === partition);
163
+ }
164
+
165
+ /** The process of the instance that caused the event. */
166
+ private async causeProcess(cause: EmitCause | null): Promise<string | undefined> {
167
+ const id = cause?.instanceId;
168
+ if (!isUuid(id)) return undefined;
169
+ const row = await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(id));
170
+ return row?.processKey;
171
+ }
172
+
173
+ private async begin(version: VersionDetails, nodeId: string, input: Record<string, unknown>, message: EventMessage) {
174
+ return await this.engine.begin(this.engine.cache(version), nodeId, this.beginOptions(input, message));
175
+ }
176
+
177
+ private beginOptions(input: Record<string, unknown>, message: EventMessage) {
178
+ return {
179
+ input,
180
+ partition: message.partition,
181
+ businessKey: message.correlation,
182
+ startedVia: "event" as const,
183
+ actor: message.actor,
184
+ request: message.request,
185
+ event: { key: message.eventKey, payload: message.payload },
186
+ ...(message.cause === null ? {} : { cause: message.cause }),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * An `eventStart` job due after `delay` and the `debounce` window. The window runs
192
+ * from its first event and later ones replace what the job carries, so a steady
193
+ * stream of events still starts a process once per window. A job a worker holds
194
+ * takes no more events: the next one opens a new window.
195
+ */
196
+ private async defer(start: PlannedStart, message: EventMessage): Promise<void> {
197
+ const { version, nodeId, config, input } = start;
198
+ const now = this.engine.now();
199
+ const dueAt = new Date(now.getTime() + (config.debounce === undefined ? 0 : durationMs(config.debounce.window) ?? 0) + (config.delay === undefined ? 0 : durationMs(config.delay) ?? 0));
200
+ const payload = { message, input };
201
+ const dedupeKey = config.debounce === undefined ? null : debounceKey(version.key, nodeId, message, config.debounce.key, now);
202
+ const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "eventStart" as const, versionId: version.id, nodeId, partition: message.partition, dueAt, payload, dedupeKey, createdAt: now });
203
+ if (dedupeKey === null) {
204
+ await this.db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] });
205
+ return;
206
+ }
207
+ await this.db.transactionScope(async () => {
208
+ await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext({0}))", dedupeKey);
209
+ const merged = await this.db.jobs.asNoTracking().where((j) => j.dedupeKey.eq(dedupeKey).and(j.lockedUntil.isNull())).executeUpdate({ payload, versionId: version.id });
210
+ if (merged.affectedRows === 0) await this.db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] });
211
+ });
212
+ }
213
+ }
214
+
215
+ function eventStartConfig(node: ProcessNode): EventStartConfig {
216
+ return (node.config ?? {}) as unknown as EventStartConfig;
217
+ }
218
+
219
+ /** What the expressions of an `eventStart` read: there is no instance yet. */
220
+ function eventData(message: EventMessage, now: Date): InstanceData {
221
+ return {
222
+ input: {},
223
+ vars: {},
224
+ nodes: {},
225
+ event: message.payload,
226
+ trigger: { via: "event", at: now.toISOString(), actor: message.actor, eventKey: message.eventKey, ...(message.cause === null ? {} : { cause: message.cause }) },
227
+ now: now.toISOString(),
228
+ };
229
+ }
230
+
231
+ function debounceKey(processKey: string, nodeId: string, message: EventMessage, key: Expr, now: Date): string {
232
+ const value = evaluateExpression(key, eventData(message, now)) ?? null;
233
+ return createHash("sha256").update(canonicalJson([processKey, nodeId, message.partition, value])).digest("hex");
234
+ }
@@ -10,6 +10,8 @@ export interface ProcessGraph {
10
10
  readonly incoming: ReadonlyMap<string, readonly (readonly [string, ProcessFlow])[]>;
11
11
  /** Boundary nodes by the step they are attached to. */
12
12
  readonly boundaries: ReadonlyMap<string, readonly (readonly [string, ProcessNode])[]>;
13
+ /** The node an iteration begins with, by loop. */
14
+ readonly entries: ReadonlyMap<string, string>;
13
15
  }
14
16
 
15
17
  export function buildGraph(versionId: string, processKey: string, version: number, document: ProcessDocument): ProcessGraph {
@@ -23,7 +25,11 @@ export function buildGraph(versionId: string, processKey: string, version: numbe
23
25
  for (const [id, node] of Object.entries(document.nodes)) {
24
26
  if (node.attachedTo !== undefined) boundaries.set(node.attachedTo, [...boundaries.get(node.attachedTo) ?? [], [id, node]]);
25
27
  }
26
- return { versionId, processKey, version, document, outgoing, incoming, boundaries };
28
+ const entries = new Map<string, string>();
29
+ for (const [id, node] of Object.entries(document.nodes)) {
30
+ if (node.parent !== undefined && node.attachedTo === undefined && !incoming.has(id)) entries.set(node.parent, id);
31
+ }
32
+ return { versionId, processKey, version, document, outgoing, incoming, boundaries, entries };
27
33
  }
28
34
 
29
35
  /** Graphs of versions in memory. Versions are immutable, so entries never go stale; the oldest go first past the limit. */
@@ -0,0 +1,134 @@
1
+ import { inputSpecOf, validateForm } from "../../../../library/workflow";
2
+ import { WorkflowError } from "../../contracts/WorkflowError";
3
+ import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
4
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
5
+ import type { WfJob } from "../model/WfJob.model";
6
+ import { queuesSchedule, type WorkflowEngine } from "./WorkflowEngine";
7
+
8
+ /** How many waiting instances of a process one pass looks at. */
9
+ const RELEASE_BATCH = 1000;
10
+
11
+ /** What a whole run is limited by and what follows its failure: `runTimeout`, `maxConcurrent`, `sameKey: "queue"`, `overlap: "queue"`, `errorHandler`. */
12
+ export class RunLimits {
13
+ constructor(
14
+ private readonly db: RuntimeDbContext,
15
+ private readonly engine: WorkflowEngine,
16
+ private readonly definitions: IWorkflowDefinitions,
17
+ ) {}
18
+
19
+ /** The run's time is out. A `failed` instance waits for a person; its retry plans the job again. */
20
+ async runTimeout(job: WfJob): Promise<void> {
21
+ const done = job.instanceId === null ? null : await this.engine.inStep(job.instanceId, async (step) => {
22
+ await this.dropJob(job);
23
+ if (step.instance.status === "running") await this.engine.timeOut(step);
24
+ return true;
25
+ });
26
+ if (done === null) await this.dropJob(job);
27
+ }
28
+
29
+ /** A place may have come free: instances of the process that wait for one start, oldest first. */
30
+ async release(job: WfJob): Promise<void> {
31
+ await this.releaseProcess((job.payload as { processKey: string }).processKey);
32
+ await this.dropJob(job);
33
+ }
34
+
35
+ /** Every process with waiting instances: the safety net for what no ending instance tells, such as a new version without a limit. */
36
+ async releaseWaiting(): Promise<void> {
37
+ const rows = await this.db.instances.asNoTracking().where((i) => i.status.eq("pending")).select((i) => ({ processKey: i.processKey })).toList();
38
+ for (const key of new Set(rows.map((row) => row.processKey))) await this.releaseProcess(key);
39
+ }
40
+
41
+ private async releaseProcess(processKey: string): Promise<void> {
42
+ const waiting = await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.status.eq("pending"))).orderBy((i) => i.id).take(RELEASE_BATCH).toList();
43
+ if (waiting.length === 0) return;
44
+ // Graphs live in the Definitions store, out of reach inside the transaction; `inStep` finds them cached.
45
+ const graphs = new Map(await Promise.all(waiting.map(async (row) => [row.id, await this.engine.graphOf(row.versionId)] as const)));
46
+ await this.db.transactionScope(async () => {
47
+ await this.engine.lockProcess(processKey);
48
+ let running = await this.engine.runningOf(processKey);
49
+ for (const row of waiting) {
50
+ const { document } = graphs.get(row.id)!;
51
+ const { maxConcurrent, sameKey } = document.settings ?? {};
52
+ if (maxConcurrent !== undefined && running >= maxConcurrent) continue;
53
+ if (sameKey === "queue" && row.businessKey !== null && await this.keyTaken(processKey, row.businessKey, row.id)) continue;
54
+ if (row.startedVia === "schedule" && queuesSchedule(document) && await this.scheduleTaken(processKey, row.partition, row.id)) continue;
55
+ const released = await this.engine.inStep(row.id, async (step) => {
56
+ if (step.instance.status !== "pending") return false;
57
+ await this.engine.release(step);
58
+ return true;
59
+ });
60
+ if (released === true) running += 1;
61
+ }
62
+ });
63
+ }
64
+
65
+ /** Whether an instance older than this one still works on the key: it goes first. */
66
+ private async keyTaken(processKey: string, businessKey: string, id: string): Promise<boolean> {
67
+ const same = await this.db.instances.asNoTracking()
68
+ .where((i) => i.processKey.eq(processKey).and(i.businessKey.eq(businessKey)).and(i.status.in(["pending", "running", "failed"])).and(i.id.lt(id)))
69
+ .count();
70
+ return same > 0;
71
+ }
72
+
73
+ /** Whether a scheduled run of the partition goes on, or waits since before this one. */
74
+ private async scheduleTaken(processKey: string, partition: string | null, id: string): Promise<boolean> {
75
+ const same = await this.db.instances.asNoTracking()
76
+ .where((i) => i.processKey.eq(processKey).and(i.startedVia.eq("schedule")).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)).and(i.status.eq("running").or(i.status.eq("pending").and(i.id.lt(id)))))
77
+ .count();
78
+ return same > 0;
79
+ }
80
+
81
+ /** An `errorHandler` job: the handler starts with `$event` telling what happened. What keeps it from starting goes to the history of the instance that failed. */
82
+ async errorHandler(job: WfJob): Promise<void> {
83
+ const failed = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
84
+ if (!failed) {
85
+ await this.dropJob(job);
86
+ return;
87
+ }
88
+ const process = (job.payload as { process: string }).process;
89
+ const note = (data: Record<string, unknown>) => this.engine.inStep(failed.id, async (step) => {
90
+ await this.dropJob(job);
91
+ this.engine.history(step, null, null, "handler", { process, ...data });
92
+ });
93
+ let version: VersionDetails;
94
+ try {
95
+ version = await this.definitions.latest(process);
96
+ } catch (error) {
97
+ if (!(error instanceof WorkflowError)) throw error;
98
+ await note({ error: error.message });
99
+ return;
100
+ }
101
+ const start = Object.entries(version.document.nodes).find(([, node]) => node.kind === "manualStart");
102
+ if (!start) {
103
+ await note({ error: "the process has no manual start" });
104
+ return;
105
+ }
106
+ const { value, issues } = validateForm(inputSpecOf(version.document), {});
107
+ if (issues.length > 0) {
108
+ await note({ error: "the process needs input", issues });
109
+ return;
110
+ }
111
+ const graph = this.engine.cache(version);
112
+ // In one transaction with the job, so that a repeat of the job does not start a second handler.
113
+ await this.engine.inStep(failed.id, async (step) => {
114
+ await this.dropJob(job);
115
+ const { id } = await this.engine.begin(graph, start[0], {
116
+ input: value,
117
+ partition: failed.partition,
118
+ businessKey: null,
119
+ startedVia: "errorHandler",
120
+ actor: { kind: "system", id: `process:${failed.processKey}` },
121
+ event: {
122
+ key: "workflow.instance-stopped",
123
+ payload: { instanceId: failed.id, processKey: failed.processKey, version: failed.version, partition: failed.partition, businessKey: failed.businessKey, status: failed.status, node: failed.error?.node ?? null, code: failed.error?.code ?? null, message: failed.error?.message ?? null },
124
+ },
125
+ cause: { instanceId: failed.id, processKey: failed.processKey },
126
+ });
127
+ this.engine.history(step, null, null, "handler", { process, instanceId: id });
128
+ });
129
+ }
130
+
131
+ private async dropJob(job: WfJob): Promise<void> {
132
+ await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
133
+ }
134
+ }
@@ -0,0 +1,13 @@
1
+ import { WorkflowProcessUsage } from "../../contracts/WorkflowProcessUsage";
2
+ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
3
+
4
+ /** Unfinished instances of a process: they stop it from being deleted. */
5
+ export class RuntimeProcessUsage extends WorkflowProcessUsage {
6
+ constructor(private readonly db: RuntimeDbContext) {
7
+ super();
8
+ }
9
+
10
+ async liveInstances(processKey: string): Promise<number> {
11
+ return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.status.in(["pending", "running", "failed"]))).count();
12
+ }
13
+ }