bazis 0.133.0 → 0.135.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/access/contracts/References.ts +2 -0
- package/core/agent/AgentToolExecutor.ts +5 -0
- package/core/agent/Tool.contract.ts +2 -2
- package/core/agent/metadata.ts +11 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate.ts +8 -0
- package/core/tools/StandardTools.module.ts +14 -0
- package/core/tools/generated/deps.ts +10 -0
- package/core/tools/index.ts +8 -0
- package/core/tools/orm/OrmEntity.references.ts +75 -0
- package/core/tools/orm/ormEntityTools.ts +423 -0
- package/core/workflow/contracts/WorkflowError.ts +2 -1
- package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
- package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +9 -2
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
- package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +13 -1
- package/core/workflow/definitions_module/code/inspectProcesses.ts +15 -4
- package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
- package/core/workflow/definitions_module/http/Processes.controller.ts +19 -4
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +31 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +80 -6
- package/core/workflow/generated/deps.ts +13 -4
- package/core/workflow/index.ts +5 -0
- package/core/workflow/internal/WorkflowReferences.ts +69 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +5 -0
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +1 -1
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +15 -11
- package/core/workflow/runtime_module/events/Runtime.events.ts +7 -0
- package/core/workflow/runtime_module/http/Instances.controller.ts +7 -2
- package/core/workflow/runtime_module/internal/ChildCalls.ts +1 -0
- package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
- package/core/workflow/runtime_module/internal/RunLimits.ts +16 -5
- package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
- package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
- package/core/workflow/runtime_module/internal/ToolStep.ts +16 -2
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +48 -9
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +7 -0
- package/core/workflow/runtime_module/model/WfJob.model.ts +7 -6
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +34 -1
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +40 -41
- package/core/workflow/tasks_module/events/TaskEvents.ts +17 -1
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -0
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +2 -1
- package/library/workflow/cron.ts +103 -24
- package/library/workflow/index.ts +3 -2
- package/library/workflow/nodeKinds.ts +64 -9
- package/library/workflow/paths.ts +2 -2
- package/library/workflow/references.ts +106 -0
- package/library/workflow/validateProcess.ts +13 -0
- package/package.json +2 -1
|
@@ -1,43 +1,76 @@
|
|
|
1
|
-
import { inputSpecOf, parseCron, validateForm } from "../../../../library/workflow";
|
|
1
|
+
import { inputSpecOf, parseCron, validateForm, type ProcessNode } from "../../../../library/workflow";
|
|
2
|
+
import type { ServiceProvider } from "../../../di";
|
|
3
|
+
import { WorkflowPartitions, type ProcessPartition } from "../../contracts/WorkflowPartitions";
|
|
2
4
|
import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
5
|
+
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
3
6
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
4
7
|
import { WfJob } from "../model/WfJob.model";
|
|
5
8
|
import type { WorkflowEngine } from "./WorkflowEngine";
|
|
6
9
|
|
|
7
10
|
const SCHEDULE_ACTOR = { kind: "system", id: "schedule" } as const;
|
|
11
|
+
const MINUTE = 60_000;
|
|
12
|
+
|
|
13
|
+
/** The `config` of a `scheduleStart`, as validation lets it through. */
|
|
14
|
+
interface ScheduleConfig {
|
|
15
|
+
readonly cron: string;
|
|
16
|
+
readonly timezone?: string;
|
|
17
|
+
readonly from?: string;
|
|
18
|
+
readonly until?: string;
|
|
19
|
+
readonly overlap?: "skip" | "queue" | "parallel";
|
|
20
|
+
readonly catchUp?: boolean;
|
|
21
|
+
readonly partitions?: "none" | "all" | readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One place a node fires in: a partition with its time zone. */
|
|
25
|
+
interface Target {
|
|
26
|
+
readonly partition: string | null;
|
|
27
|
+
readonly timezone: string | undefined;
|
|
28
|
+
}
|
|
8
29
|
|
|
9
30
|
/**
|
|
10
|
-
* Scheduled starts (`scheduleStart
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* starts the
|
|
14
|
-
* transaction, so a crash never starts one firing twice.
|
|
15
|
-
*
|
|
31
|
+
* Scheduled starts (`scheduleStart`). Every node of a latest published version
|
|
32
|
+
* has a `schedule` job per partition it runs in, due at its next firing.
|
|
33
|
+
* `reconcile` keeps the jobs in line with the catalog and `WorkflowPartitions`;
|
|
34
|
+
* `fire` starts the instance of a due job and plans the next firing in the same
|
|
35
|
+
* transaction, so a crash never starts one firing twice. With `catchUp` the next
|
|
36
|
+
* firing is planned from the one just made, so every missed firing runs;
|
|
37
|
+
* otherwise from now, and those missed while no worker ran are gone.
|
|
16
38
|
*/
|
|
17
39
|
export class Schedules {
|
|
18
40
|
constructor(
|
|
19
41
|
private readonly db: RuntimeDbContext,
|
|
20
42
|
private readonly engine: WorkflowEngine,
|
|
21
43
|
private readonly definitions: IWorkflowDefinitions,
|
|
44
|
+
private readonly languages: WorkflowLanguages,
|
|
45
|
+
/** The scope: the application's own `WorkflowPartitions` replaces the default. */
|
|
46
|
+
private readonly services: ServiceProvider,
|
|
22
47
|
) {}
|
|
23
48
|
|
|
24
|
-
/** Adds jobs for new scheduled
|
|
49
|
+
/** Adds jobs for new scheduled nodes and partitions and drops those no longer wanted. */
|
|
25
50
|
async reconcile(now = this.engine.now()): Promise<void> {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
51
|
+
const wanted = new Map<string, { readonly version: VersionDetails; readonly nodeId: string; readonly config: ScheduleConfig; readonly target: Target }>();
|
|
52
|
+
const invalid: string[] = [];
|
|
53
|
+
for (const version of await this.definitions.published()) {
|
|
54
|
+
for (const [nodeId, config] of scheduleNodes(version)) {
|
|
55
|
+
for (const target of await this.targetsOf(version.key, config)) {
|
|
56
|
+
if (parseCron(config.cron, target.timezone) === undefined) invalid.push(`${version.key}: time zone ${String(target.timezone)} of partition ${String(target.partition)}`);
|
|
57
|
+
else wanted.set(keyOf(version.id, nodeId, target.partition), { version, nodeId, config, target });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
28
61
|
const added: WfJob[] = [];
|
|
29
62
|
await this.db.transactionScope(async () => {
|
|
30
63
|
// One reconciler at a time across application instances.
|
|
31
64
|
await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext('bazis.workflow.schedules'))");
|
|
32
65
|
const jobs = await this.db.jobs.asNoTracking().where((j) => j.kind.eq("schedule")).toList();
|
|
33
|
-
const stale = jobs.filter((job) =>
|
|
66
|
+
const stale = jobs.filter((job) => !wanted.has(keyOf(job.versionId, job.nodeId, job.partition))).map((job) => job.id);
|
|
34
67
|
if (stale.length > 0) await this.db.jobs.asNoTracking().where((j) => j.id.in(stale)).executeDelete();
|
|
35
|
-
const planned = new Set(jobs.map((job) => job.versionId));
|
|
36
|
-
for (const version of
|
|
37
|
-
if (planned.has(
|
|
38
|
-
const dueAt = nextFiring(
|
|
68
|
+
const planned = new Set(jobs.map((job) => keyOf(job.versionId, job.nodeId, job.partition)));
|
|
69
|
+
for (const [key, { version, nodeId, config, target }] of wanted) {
|
|
70
|
+
if (planned.has(key)) continue;
|
|
71
|
+
const dueAt = nextFiring(config, target, now);
|
|
39
72
|
if (dueAt === undefined) continue;
|
|
40
|
-
const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "schedule" as const, versionId: version.id, dueAt });
|
|
73
|
+
const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "schedule" as const, versionId: version.id, nodeId, partition: target.partition, dueAt });
|
|
41
74
|
this.db.jobs.add(job);
|
|
42
75
|
added.push(job);
|
|
43
76
|
}
|
|
@@ -45,40 +78,74 @@ export class Schedules {
|
|
|
45
78
|
});
|
|
46
79
|
// The jobs belong to the worker from now on: this context must not hold them.
|
|
47
80
|
for (const job of added) this.db.detach(job);
|
|
81
|
+
if (invalid.length > 0) throw new Error(`Scheduled starts are not planned for ${invalid.join("; ")}`);
|
|
48
82
|
}
|
|
49
83
|
|
|
50
|
-
/** A `schedule` job is due: start an instance
|
|
84
|
+
/** A `schedule` job is due: start an instance unless `overlap` says otherwise, and plan the next firing. */
|
|
51
85
|
async fire(job: WfJob): Promise<void> {
|
|
52
86
|
const version = job.versionId === null ? undefined : await this.definitions.versionById(job.versionId);
|
|
53
87
|
const latest = version === undefined ? undefined : (await this.definitions.published()).find((item) => item.id === version.id);
|
|
54
|
-
|
|
88
|
+
const config = latest === undefined || job.nodeId === null ? undefined : scheduleConfig(latest.document.nodes[job.nodeId]);
|
|
89
|
+
const target = latest === undefined || config === undefined ? undefined : (await this.targetsOf(latest.key, config)).find((item) => item.partition === job.partition);
|
|
90
|
+
if (!latest || !config || !target) {
|
|
55
91
|
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
56
92
|
return;
|
|
57
93
|
}
|
|
58
|
-
const due = new Date(Math.floor(job.dueAt.getTime() /
|
|
94
|
+
const due = new Date(Math.floor(job.dueAt.getTime() / MINUTE) * MINUTE);
|
|
59
95
|
const graph = this.engine.cache(latest);
|
|
96
|
+
const { overlap = "parallel" } = config;
|
|
60
97
|
await this.db.transactionScope(async () => {
|
|
61
|
-
// Planned from now, not from the missed firing: a stopped worker does not catch up.
|
|
62
98
|
const now = this.engine.now();
|
|
63
|
-
const next = nextFiring(
|
|
99
|
+
const next = nextFiring(config, target, config.catchUp === true || now <= due ? due : now);
|
|
64
100
|
if (next === undefined) await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
65
101
|
else await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ dueAt: next, lockedUntil: null, attempts: 0, lastError: null });
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
await this.engine.
|
|
102
|
+
let queued = false;
|
|
103
|
+
if (overlap !== "parallel") {
|
|
104
|
+
await this.engine.lockProcess(latest.key);
|
|
105
|
+
if (await this.engine.scheduledLive(latest.key, target.partition) > 0) {
|
|
106
|
+
if (overlap === "skip") return;
|
|
107
|
+
queued = true;
|
|
108
|
+
}
|
|
69
109
|
}
|
|
110
|
+
await this.engine.begin(graph, job.nodeId!, { input: validateForm(inputSpecOf(latest.document), {}).value, partition: target.partition, businessKey: null, startedVia: "schedule", actor: SCHEDULE_ACTOR, queued, scheduledAt: due });
|
|
70
111
|
});
|
|
71
112
|
}
|
|
113
|
+
|
|
114
|
+
/** Where a node runs: `none` without a partition, `all` in every enabled one, a list in those not switched off. */
|
|
115
|
+
private async targetsOf(processKey: string, config: ScheduleConfig): Promise<readonly Target[]> {
|
|
116
|
+
const { partitions = "none", timezone } = config;
|
|
117
|
+
if (partitions === "none") return [{ partition: null, timezone }];
|
|
118
|
+
const known = await this.partitionsOf(processKey);
|
|
119
|
+
if (partitions === "all") return known.filter((item) => item.enabled).map((item) => ({ partition: item.partition, timezone: timezone ?? item.timezone }));
|
|
120
|
+
return partitions.flatMap((partition) => {
|
|
121
|
+
const entry = known.find((item) => item.partition === partition);
|
|
122
|
+
return entry?.enabled === false ? [] : [{ partition, timezone: timezone ?? entry?.timezone }];
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async partitionsOf(processKey: string): Promise<readonly ProcessPartition[]> {
|
|
127
|
+
return await this.services.resolve(WorkflowPartitions).of(processKey, { partition: null, language: this.languages.default, actor: null });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function keyOf(versionId: string | null, nodeId: string | null, partition: string | null): string {
|
|
132
|
+
return JSON.stringify([versionId, nodeId, partition]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function scheduleConfig(node: ProcessNode | undefined): ScheduleConfig | undefined {
|
|
136
|
+
return node?.kind === "scheduleStart" && typeof node.config?.cron === "string" ? node.config as unknown as ScheduleConfig : undefined;
|
|
72
137
|
}
|
|
73
138
|
|
|
74
|
-
function scheduleNodes(version: VersionDetails) {
|
|
139
|
+
function scheduleNodes(version: VersionDetails): readonly (readonly [string, ScheduleConfig])[] {
|
|
75
140
|
return Object.entries(version.document.nodes).flatMap(([id, node]) => {
|
|
76
|
-
const
|
|
77
|
-
return
|
|
141
|
+
const config = scheduleConfig(node);
|
|
142
|
+
return config === undefined ? [] : [[id, config] as const];
|
|
78
143
|
});
|
|
79
144
|
}
|
|
80
145
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
146
|
+
/** The first firing after `after` inside the period of the node. */
|
|
147
|
+
function nextFiring(config: ScheduleConfig, target: Target, after: Date): Date | undefined {
|
|
148
|
+
const from = config.from === undefined ? undefined : new Date(Date.parse(config.from) - MINUTE);
|
|
149
|
+
const next = parseCron(config.cron, target.timezone)?.next(from !== undefined && from > after ? from : after);
|
|
150
|
+
return next === undefined || (config.until !== undefined && next.getTime() > Date.parse(config.until)) ? undefined : next;
|
|
84
151
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AgentSchemaContract, AgentToolResult } from "../../../agent";
|
|
2
|
-
import { durationMs, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, type ProcessDocument } from "../../../../library/workflow";
|
|
2
|
+
import { durationMs, inputReferences, maskByMapping, maskBySchema, masksPersonal, personalPaths, resolveInput, type ProcessDocument } from "../../../../library/workflow";
|
|
3
3
|
import type { WorkflowToolMetadata } from "../../contracts/types";
|
|
4
|
+
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
5
|
+
import type { WorkflowReferences } from "../../internal/WorkflowReferences";
|
|
4
6
|
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
5
7
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
6
8
|
import type { WfJob } from "../model/WfJob.model";
|
|
@@ -23,6 +25,8 @@ export class ToolStep {
|
|
|
23
25
|
private readonly db: RuntimeDbContext,
|
|
24
26
|
private readonly engine: WorkflowEngine,
|
|
25
27
|
private readonly tools: WorkflowTools,
|
|
28
|
+
private readonly references: WorkflowReferences,
|
|
29
|
+
private readonly languages: WorkflowLanguages,
|
|
26
30
|
) {}
|
|
27
31
|
|
|
28
32
|
async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
@@ -42,13 +46,22 @@ export class ToolStep {
|
|
|
42
46
|
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ lockedUntil: new Date(this.engine.now().getTime() + timeoutMs + LEASE_MS) });
|
|
43
47
|
}
|
|
44
48
|
const runner = this.tools.bound;
|
|
49
|
+
const trial = instance.test ? runner?.getTool(name)?.metadata.dryRun : undefined;
|
|
50
|
+
const missing = await this.references.missing(inputReferences(this.references.inputSchemaOf(name), input), { scope: instance.partition, language: this.languages.default });
|
|
45
51
|
const result: AgentToolResult = runner === undefined
|
|
46
52
|
? { callId: token.id, name, status: "error", error: { code: "TOOL_NOT_FOUND", message: `Tool ${name} is not available to processes` }, metadata: {} } as AgentToolResult
|
|
53
|
+
: missing.length > 0
|
|
54
|
+
// A value computed by the process, or one that is gone since the publication (WORKFLOW-002 §6.3).
|
|
55
|
+
? { callId: token.id, name, status: "error", error: { code: "ref_not_found", message: `${missing[0]!.kind} ${JSON.stringify(missing[0]!.id)} does not exist`, details: { reason: "ref_not_found", path: missing[0]!.path } }, metadata: {} } as AgentToolResult
|
|
56
|
+
: trial === "unsupported"
|
|
57
|
+
// A trial never calls a Tool that cannot run without changing anything.
|
|
58
|
+
? { callId: token.id, name, status: "success", output: { dryRun: true }, metadata: {} } as AgentToolResult
|
|
47
59
|
: await runner.run(name, input, {
|
|
48
60
|
idempotencyKey: token.id,
|
|
49
61
|
actor: runActorOf(graph.document, instance),
|
|
50
|
-
metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test:
|
|
62
|
+
metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: instance.test } satisfies WorkflowToolMetadata,
|
|
51
63
|
signal,
|
|
64
|
+
...(trial === "supported" ? { dryRun: true } : {}),
|
|
52
65
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
53
66
|
});
|
|
54
67
|
|
|
@@ -72,6 +85,7 @@ export class ToolStep {
|
|
|
72
85
|
status: result.status,
|
|
73
86
|
attempt: job.attempts,
|
|
74
87
|
durationMs: result.durationMs ?? null,
|
|
88
|
+
...(trial === undefined ? {} : { dryRun: trial === "supported" ? "called" : "not called" }),
|
|
75
89
|
...payloads,
|
|
76
90
|
...(result.error ? { error: { code: result.error.code ?? null, message: result.error.message, ...(reason === undefined ? {} : { reason }) } } : {}),
|
|
77
91
|
...(retryAt === undefined ? {} : { retryAt: retryAt.toISOString() }),
|
|
@@ -4,7 +4,7 @@ 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";
|
|
6
6
|
import type { WorkflowEvents } from "../../internal/WorkflowEvents";
|
|
7
|
-
import { WORKFLOW_INSTANCE_CANCELLED, WORKFLOW_INSTANCE_FINISHED, WORKFLOW_INSTANCE_RETRIED, WORKFLOW_INSTANCE_STARTED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED } from "../events/Runtime.events";
|
|
7
|
+
import { WORKFLOW_INSTANCE_CANCELLED, WORKFLOW_INSTANCE_FINISHED, WORKFLOW_INSTANCE_RETRIED, WORKFLOW_INSTANCE_STARTED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, WORKFLOW_TRIAL_TASK_REQUESTED, WORKFLOW_TRIAL_TOKEN_CLOSED } from "../events/Runtime.events";
|
|
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";
|
|
@@ -49,6 +49,12 @@ export interface BeginOptions {
|
|
|
49
49
|
readonly parent?: { readonly instanceId: string; readonly tokenId: string; readonly depth: number };
|
|
50
50
|
/** What led to the start: `$trigger.cause`. */
|
|
51
51
|
readonly cause?: Readonly<Record<string, unknown>>;
|
|
52
|
+
/** It waits as `pending` whatever the limits say: `overlap: "queue"`. */
|
|
53
|
+
readonly queued?: boolean;
|
|
54
|
+
/** The firing a scheduled start belongs to: `$trigger.scheduledAt`. */
|
|
55
|
+
readonly scheduledAt?: Date;
|
|
56
|
+
/** A trial run (WORKFLOW-002 §5.4): takes no place among real ones and tells the application nothing. */
|
|
57
|
+
readonly test?: boolean;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/** Why a step failed. `reason` is the business reason an error boundary may name. */
|
|
@@ -158,9 +164,10 @@ export class WorkflowEngine {
|
|
|
158
164
|
*/
|
|
159
165
|
async begin(graph: ProcessGraph, startNodeId: string, options: BeginOptions): Promise<Begun> {
|
|
160
166
|
return await this.db.transactionScope(async (transaction) => {
|
|
161
|
-
const admission = await this.admit(graph, options.businessKey);
|
|
167
|
+
const admission = options.test === true ? "start" : await this.admit(graph, options.businessKey);
|
|
162
168
|
if (typeof admission === "object") return { id: admission.skippedBy, skipped: true };
|
|
163
|
-
const
|
|
169
|
+
const waits = admission === "queue" || options.queued === true;
|
|
170
|
+
const instance = this.newInstance(graph, options, waits ? "pending" : "running");
|
|
164
171
|
this.db.instances.add(instance);
|
|
165
172
|
await this.db.saveChanges();
|
|
166
173
|
const step = this.stepOf(instance, [], graph);
|
|
@@ -170,7 +177,7 @@ export class WorkflowEngine {
|
|
|
170
177
|
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
178
|
});
|
|
172
179
|
const first = this.spawn(step, startNodeId, null, TOP);
|
|
173
|
-
if (
|
|
180
|
+
if (waits) {
|
|
174
181
|
first.status = "waiting";
|
|
175
182
|
this.history(step, first, startNodeId, "queued", null);
|
|
176
183
|
} else {
|
|
@@ -206,7 +213,7 @@ export class WorkflowEngine {
|
|
|
206
213
|
await this.lockProcess(graph.processKey);
|
|
207
214
|
if (keyed) {
|
|
208
215
|
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"])))
|
|
216
|
+
.where((i) => i.processKey.eq(graph.processKey).and(i.businessKey.eq(businessKey)).and(i.test.eq(false)).and(i.status.in(["pending", "running", "failed"])))
|
|
210
217
|
.firstOrDefault();
|
|
211
218
|
if (same) return sameKey === "skip" ? { skippedBy: same.id } : "queue";
|
|
212
219
|
}
|
|
@@ -219,7 +226,14 @@ export class WorkflowEngine {
|
|
|
219
226
|
}
|
|
220
227
|
|
|
221
228
|
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();
|
|
229
|
+
return await this.db.instances.asNoTracking().where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.status.eq("running"))).count();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Scheduled runs of a process that still go on in a partition, for `overlap`. A failed one waits for a person and takes no place. */
|
|
233
|
+
async scheduledLive(processKey: string, partition: string | null): Promise<number> {
|
|
234
|
+
return await this.db.instances.asNoTracking()
|
|
235
|
+
.where((i) => i.processKey.eq(processKey).and(i.test.eq(false)).and(i.startedVia.eq("schedule")).and(i.status.in(["pending", "running"])).and(partition === null ? i.partition.isNull() : i.partition.eq(partition)))
|
|
236
|
+
.count();
|
|
223
237
|
}
|
|
224
238
|
|
|
225
239
|
/** A `pending` instance got its place: its token moves from the start. */
|
|
@@ -243,6 +257,7 @@ export class WorkflowEngine {
|
|
|
243
257
|
processKey: graph.processKey,
|
|
244
258
|
version: graph.version,
|
|
245
259
|
partition: options.partition,
|
|
260
|
+
test: options.test === true,
|
|
246
261
|
input: options.input,
|
|
247
262
|
vars: initialVarsOf(graph.document),
|
|
248
263
|
event: options.event?.payload ?? null,
|
|
@@ -253,6 +268,7 @@ export class WorkflowEngine {
|
|
|
253
268
|
...(options.event === undefined ? {} : { eventKey: options.event.key }),
|
|
254
269
|
...(options.cause === undefined ? {} : { cause: options.cause }),
|
|
255
270
|
...(options.parent === undefined ? {} : { depth: options.parent.depth }),
|
|
271
|
+
...(options.scheduledAt === undefined ? {} : { scheduledAt: options.scheduledAt.toISOString() }),
|
|
256
272
|
},
|
|
257
273
|
businessKey: options.businessKey,
|
|
258
274
|
startedVia: options.startedVia,
|
|
@@ -418,6 +434,14 @@ export class WorkflowEngine {
|
|
|
418
434
|
return await this.fireBoundary(step, token, node, { firedAt }, "timer");
|
|
419
435
|
}
|
|
420
436
|
|
|
437
|
+
/** Whether the step takes this event: `eventWait.until` may leave it waiting for the next one. */
|
|
438
|
+
eventAccepted(step: Step, tokenId: string, payload: Record<string, unknown>): boolean {
|
|
439
|
+
const token = step.tokens.find((t) => t.id === tokenId);
|
|
440
|
+
const node = token === undefined ? undefined : step.graph.document.nodes[token.nodeId];
|
|
441
|
+
if (token === undefined || node?.kind !== "eventWait" || node.config?.until === undefined || token.status !== "waiting") return true;
|
|
442
|
+
return isTruthy(evaluateExpression(node.config.until as Expr, { ...dataOf(step.instance, this.now(), token), event: payload }));
|
|
443
|
+
}
|
|
444
|
+
|
|
421
445
|
/**
|
|
422
446
|
* An event the token waits for arrived. An `eventWait` step moves on with
|
|
423
447
|
* the payload (and `saveTo`); a `boundaryEvent` takes its flow like a
|
|
@@ -553,7 +577,7 @@ export class WorkflowEngine {
|
|
|
553
577
|
const { instance, graph } = step;
|
|
554
578
|
const settings = graph.document.settings;
|
|
555
579
|
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 } });
|
|
580
|
+
if (settings?.maxConcurrent !== undefined || settings?.sameKey === "queue" || (instance.startedVia === "schedule" && queuesSchedule(graph.document))) this.addJob(step, "release", { payload: { processKey: instance.processKey } });
|
|
557
581
|
// A handler that fails does not start a handler: a chain of them would never end.
|
|
558
582
|
if ((instance.status === "failed" || instance.status === "timedOut") && settings?.errorHandler !== undefined && instance.startedVia !== "errorHandler") {
|
|
559
583
|
this.addJob(step, "errorHandler", { payload: { process: settings.errorHandler } });
|
|
@@ -907,14 +931,24 @@ export class WorkflowEngine {
|
|
|
907
931
|
|
|
908
932
|
/** Events go out after the commit, so a handler never sees a step that rolled back. */
|
|
909
933
|
private publishAfter(transaction: { afterCommit(callback: () => void | Promise<void>): void }, step: Step): void {
|
|
910
|
-
|
|
911
|
-
|
|
934
|
+
const events = step.instance.test ? trialEvents(step) : [...step.events];
|
|
935
|
+
if (events.length === 0) return;
|
|
912
936
|
transaction.afterCommit(async () => {
|
|
913
937
|
for (const { event, payload } of events) await this.events.publish(event, payload);
|
|
914
938
|
});
|
|
915
939
|
}
|
|
916
940
|
}
|
|
917
941
|
|
|
942
|
+
/** What a trial run tells: only what its tasks need, and they go to who started it. */
|
|
943
|
+
function trialEvents(step: Step): PendingEvent[] {
|
|
944
|
+
const { instance } = step;
|
|
945
|
+
return step.events.flatMap(({ event, payload }): PendingEvent[] => {
|
|
946
|
+
if (event === (WORKFLOW_TOKEN_CLOSED as EventToken<unknown>)) return [{ event: WORKFLOW_TRIAL_TOKEN_CLOSED as EventToken<unknown>, payload }];
|
|
947
|
+
if (event !== (WORKFLOW_TASK_REQUESTED as EventToken<unknown>)) return [];
|
|
948
|
+
return [{ event: WORKFLOW_TRIAL_TASK_REQUESTED as EventToken<unknown>, payload: { ...(payload as object), assignee: { user: instance.startedById } } }];
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
918
952
|
/** When a timer fires: `duration` from now, or the date at the `at` path; `undefined` if neither is valid. */
|
|
919
953
|
export function timerDue(config: Readonly<Record<string, unknown>>, data: InstanceData, now: Date): Date | undefined {
|
|
920
954
|
if (typeof config.duration === "string") {
|
|
@@ -960,6 +994,11 @@ export function runActorOf(document: ProcessDocument, instance: WfInstance): Wor
|
|
|
960
994
|
return document.settings?.runAs === "process" ? { kind: "system", id: `process:${instance.processKey}` } : { kind: instance.startedByKind, id: instance.startedById };
|
|
961
995
|
}
|
|
962
996
|
|
|
997
|
+
/** Whether a scheduled run of the process waits for the one before it: `overlap: "queue"`. */
|
|
998
|
+
export function queuesSchedule(document: ProcessDocument): boolean {
|
|
999
|
+
return Object.values(document.nodes).some((node) => node.kind === "scheduleStart" && node.config?.overlap === "queue");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
963
1002
|
function isLoopState(value: unknown): value is LoopState {
|
|
964
1003
|
return typeof value === "object" && value !== null && Object.hasOwn(value, "next") && Object.hasOwn(value, "collected");
|
|
965
1004
|
}
|
|
@@ -4,6 +4,7 @@ import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
|
4
4
|
import type { WfJob } from "../model/WfJob.model";
|
|
5
5
|
import { ChildCalls } from "./ChildCalls";
|
|
6
6
|
import { RunLimits } from "./RunLimits";
|
|
7
|
+
import { EventDelivery } from "./EventDelivery";
|
|
7
8
|
import { Schedules } from "./Schedules";
|
|
8
9
|
import { TimerStep } from "./TimerStep";
|
|
9
10
|
import { LEASE_MS, ToolStep } from "./ToolStep";
|
|
@@ -81,6 +82,12 @@ export class WorkflowJobs {
|
|
|
81
82
|
case "errorHandler":
|
|
82
83
|
await scope.resolve(RunLimits).errorHandler(job);
|
|
83
84
|
break;
|
|
85
|
+
case "event":
|
|
86
|
+
await scope.resolve(EventDelivery).deliverLater(job);
|
|
87
|
+
break;
|
|
88
|
+
case "eventStart":
|
|
89
|
+
await scope.resolve(EventDelivery).startLater(job);
|
|
90
|
+
break;
|
|
84
91
|
default:
|
|
85
92
|
await scope.resolve(ToolStep).run(job, signal);
|
|
86
93
|
}
|
|
@@ -7,9 +7,10 @@ import { WfInstance } from "./WfInstance.model";
|
|
|
7
7
|
* `call` starts a called process, `returned` tells the caller it ended,
|
|
8
8
|
* `cancelChildren` closes what a closed step called. `runTimeout` ends a run that
|
|
9
9
|
* took too long, `release` starts instances that wait for a place, `errorHandler`
|
|
10
|
-
* starts the process that handles a failure.
|
|
10
|
+
* starts the process that handles a failure. `event` delivers what `emitLater`
|
|
11
|
+
* wrote, `eventStart` starts a process by an event after `delay` or `debounce`.
|
|
11
12
|
*/
|
|
12
|
-
export type JobKind = "tool" | "timer" | "schedule" | "loop" | "call" | "returned" | "cancelChildren" | "runTimeout" | "release" | "errorHandler";
|
|
13
|
+
export type JobKind = "tool" | "timer" | "schedule" | "loop" | "call" | "returned" | "cancelChildren" | "runTimeout" | "release" | "errorHandler" | "event" | "eventStart";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Deferred work of the worker, created in the same transaction as the waiting
|
|
@@ -26,7 +27,7 @@ export class WfJob {
|
|
|
26
27
|
@Column({ type: "text" })
|
|
27
28
|
kind: JobKind = "tool";
|
|
28
29
|
|
|
29
|
-
/** `null` for
|
|
30
|
+
/** `null` for `schedule`, `event` and `eventStart` jobs: the instance does not exist yet. */
|
|
30
31
|
@Index()
|
|
31
32
|
@Column({ type: "text", nullable: true })
|
|
32
33
|
instanceId: string | null = null;
|
|
@@ -35,7 +36,7 @@ export class WfJob {
|
|
|
35
36
|
@Column({ type: "text", nullable: true })
|
|
36
37
|
tokenId: string | null = null;
|
|
37
38
|
|
|
38
|
-
/** The version a `schedule` job starts. */
|
|
39
|
+
/** The version a `schedule` or `eventStart` job starts. */
|
|
39
40
|
@Column({ type: "text", nullable: true })
|
|
40
41
|
versionId: string | null = null;
|
|
41
42
|
|
|
@@ -47,12 +48,12 @@ export class WfJob {
|
|
|
47
48
|
@Column({ type: "json", nullable: true })
|
|
48
49
|
payload: unknown = null;
|
|
49
50
|
|
|
50
|
-
/** Jobs with one key merge into the last: `debounce` of an event start
|
|
51
|
+
/** Jobs with one key merge into the last: `debounce` of an event start. */
|
|
51
52
|
@Index()
|
|
52
53
|
@Column({ type: "text", nullable: true })
|
|
53
54
|
dedupeKey: string | null = null;
|
|
54
55
|
|
|
55
|
-
/** The partition and the start node of a `schedule`
|
|
56
|
+
/** The partition and the start node of a `schedule` or `eventStart` job. */
|
|
56
57
|
@Column({ type: "text", nullable: true })
|
|
57
58
|
partition: string | null = null;
|
|
58
59
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AccessFilter } from "../../../access";
|
|
2
2
|
import type { FormIssue } from "../../../../library/workflow";
|
|
3
|
+
import type { OrmTransaction } from "../../../orm";
|
|
3
4
|
import type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging } from "../../contracts/types";
|
|
4
5
|
import type { HistoryType } from "../model/WfHistory.model";
|
|
5
6
|
import type { InstanceStatus, StartedVia } from "../model/WfInstance.model";
|
|
@@ -15,6 +16,11 @@ export interface StartInstance extends WorkflowAuditOptions {
|
|
|
15
16
|
readonly actor: WorkflowActor;
|
|
16
17
|
/** The language of the request, for the application's validator; `workflow.language` by default. */
|
|
17
18
|
readonly language?: string;
|
|
19
|
+
/**
|
|
20
|
+
* A trial run (WORKFLOW-002 §5.4): Tools change nothing, the application hears
|
|
21
|
+
* nothing, tasks go only to who started it. It lives for a day.
|
|
22
|
+
*/
|
|
23
|
+
readonly test?: boolean;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
/** One element of a mass start. */
|
|
@@ -62,8 +68,14 @@ export interface InstanceSummary {
|
|
|
62
68
|
/** The instance whose `callProcess` started this one. */
|
|
63
69
|
readonly parentInstanceId: string | null;
|
|
64
70
|
readonly error: { readonly node: string; readonly code: string; readonly message: string } | null;
|
|
71
|
+
/** The step it failed on. */
|
|
72
|
+
readonly failedNode: string | null;
|
|
73
|
+
/** A trial run. */
|
|
74
|
+
readonly test: boolean;
|
|
65
75
|
readonly createdAt: Date;
|
|
66
76
|
readonly endedAt: Date | null;
|
|
77
|
+
/** How long it took by the engine's clock; `null` until it ends. */
|
|
78
|
+
readonly durationMs: number | null;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
export interface TokenDetails {
|
|
@@ -99,13 +111,23 @@ export interface HistoryEntry {
|
|
|
99
111
|
readonly at: Date;
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
/** What led to an event: `$trigger.cause` of the instances it starts. `instanceId` makes `ignoreOwn` work. */
|
|
115
|
+
export interface EmitCause {
|
|
116
|
+
readonly instanceId?: string;
|
|
117
|
+
readonly [key: string]: unknown;
|
|
118
|
+
}
|
|
119
|
+
|
|
102
120
|
export interface EmitOptions extends WorkflowAuditOptions {
|
|
103
121
|
/** Which waits it is for: `order:42`. Waits without a correlation take any event of the key. */
|
|
104
122
|
readonly correlation?: string | null;
|
|
105
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* Only the waits of this partition; it is also the partition of instances the
|
|
125
|
+
* event starts, and only processes `WorkflowPartitions` connects to it start.
|
|
126
|
+
*/
|
|
106
127
|
readonly partition?: string | null;
|
|
107
128
|
/** On whose behalf started instances run; `{ kind: "system", id: "event:<key>" }` by default. */
|
|
108
129
|
readonly actor?: WorkflowActor;
|
|
130
|
+
readonly cause?: EmitCause | null;
|
|
109
131
|
}
|
|
110
132
|
|
|
111
133
|
export interface EmitResult {
|
|
@@ -113,6 +135,8 @@ export interface EmitResult {
|
|
|
113
135
|
readonly started: readonly string[];
|
|
114
136
|
/** Processes with `sameKey: "skip"` that did not start: an instance with the key is not finished. */
|
|
115
137
|
readonly skipped: readonly { readonly processKey: string; readonly instanceId: string }[];
|
|
138
|
+
/** Processes that start later: `debounce` or `delay` of their `eventStart`. */
|
|
139
|
+
readonly deferred: readonly { readonly processKey: string }[];
|
|
116
140
|
/** Instances whose `eventWait` or `boundaryEvent` took the event. */
|
|
117
141
|
readonly continued: readonly string[];
|
|
118
142
|
/** Processes with an `eventStart` whose input the payload did not pass. */
|
|
@@ -128,6 +152,8 @@ export interface InstanceListQuery extends WorkflowPaging {
|
|
|
128
152
|
readonly businessKey?: string;
|
|
129
153
|
readonly startedById?: string;
|
|
130
154
|
readonly parentInstanceId?: string;
|
|
155
|
+
/** Trial runs are hidden unless asked for: `true` gives only them. Children of a named parent are shown as they are. */
|
|
156
|
+
readonly test?: boolean;
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
/**
|
|
@@ -150,6 +176,13 @@ export abstract class IWorkflowRuntime {
|
|
|
150
176
|
* `eventStart` for the key, and moves the instances waiting for it.
|
|
151
177
|
*/
|
|
152
178
|
abstract emit(eventKey: string, payload?: unknown, options?: EmitOptions): Promise<EmitResult>;
|
|
179
|
+
/**
|
|
180
|
+
* Writes an event in the application's transaction: it is delivered as `emit`
|
|
181
|
+
* after the commit and is gone with a rollback. Needs the same `DatabaseProvider`
|
|
182
|
+
* as the process tables. Who it reached is not known at this point: a payload a
|
|
183
|
+
* process refuses goes to the log.
|
|
184
|
+
*/
|
|
185
|
+
abstract emitLater(tx: OrmTransaction, eventKey: string, payload?: unknown, options?: EmitOptions): Promise<void>;
|
|
153
186
|
/** Instances, newest first. */
|
|
154
187
|
abstract instances(query?: InstanceListQuery): Promise<WorkflowPage<InstanceSummary>>;
|
|
155
188
|
abstract instance(instanceId: string): Promise<InstanceDetails>;
|