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
|
@@ -2,13 +2,13 @@ import type { ServiceProvider } from "../../../di";
|
|
|
2
2
|
import { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
3
3
|
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
4
4
|
import type { WfJob } from "../model/WfJob.model";
|
|
5
|
+
import { ChildCalls } from "./ChildCalls";
|
|
6
|
+
import { RunLimits } from "./RunLimits";
|
|
5
7
|
import { Schedules } from "./Schedules";
|
|
6
8
|
import { TimerStep } from "./TimerStep";
|
|
7
|
-
import { ToolStep } from "./ToolStep";
|
|
9
|
+
import { LEASE_MS, ToolStep } from "./ToolStep";
|
|
8
10
|
import { WorkflowEngine } from "./WorkflowEngine";
|
|
9
11
|
|
|
10
|
-
/** How long a claimed job belongs to its worker; if it dies, another one takes the job after that. */
|
|
11
|
-
const LEASE_MS = 5 * 60_000;
|
|
12
12
|
/** A job that keeps failing outside its Tool (the database, a lost version) fails its step after this many tries. */
|
|
13
13
|
const MAX_JOB_ATTEMPTS = 5;
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ const MAX_JOB_ATTEMPTS = 5;
|
|
|
16
16
|
export type JobFailureReport = (message: string, error: unknown, fields?: Readonly<Record<string, unknown>>) => void;
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Claiming and running jobs of `
|
|
19
|
+
* Claiming and running jobs of `bazis_wf2_run_jobs`: the work of the worker, and
|
|
20
20
|
* of `drain` in tests. Every job runs in its own DI scope.
|
|
21
21
|
*/
|
|
22
22
|
export class WorkflowJobs {
|
|
@@ -33,7 +33,7 @@ export class WorkflowJobs {
|
|
|
33
33
|
const now = scope.resolve(WorkflowClock).now();
|
|
34
34
|
return await db.transactionScope(async () => {
|
|
35
35
|
const jobs = await db.jobs
|
|
36
|
-
.where((j) => j.
|
|
36
|
+
.where((j) => j.dueAt.lte(now).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
|
|
37
37
|
.orderBy((j) => j.id)
|
|
38
38
|
.take(batchSize)
|
|
39
39
|
.forUpdate({ skipLocked: true })
|
|
@@ -53,9 +53,37 @@ export class WorkflowJobs {
|
|
|
53
53
|
async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
54
54
|
const scope = this.services.createScope();
|
|
55
55
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
switch (job.kind) {
|
|
57
|
+
case "timer":
|
|
58
|
+
await scope.resolve(TimerStep).run(job);
|
|
59
|
+
break;
|
|
60
|
+
case "loop":
|
|
61
|
+
await scope.resolve(TimerStep).continueLoop(job);
|
|
62
|
+
break;
|
|
63
|
+
case "schedule":
|
|
64
|
+
await scope.resolve(Schedules).fire(job);
|
|
65
|
+
break;
|
|
66
|
+
case "call":
|
|
67
|
+
await scope.resolve(ChildCalls).call(job);
|
|
68
|
+
break;
|
|
69
|
+
case "returned":
|
|
70
|
+
await scope.resolve(ChildCalls).returned(job);
|
|
71
|
+
break;
|
|
72
|
+
case "cancelChildren":
|
|
73
|
+
await scope.resolve(ChildCalls).cancelChildren(job);
|
|
74
|
+
break;
|
|
75
|
+
case "runTimeout":
|
|
76
|
+
await scope.resolve(RunLimits).runTimeout(job);
|
|
77
|
+
break;
|
|
78
|
+
case "release":
|
|
79
|
+
await scope.resolve(RunLimits).release(job);
|
|
80
|
+
break;
|
|
81
|
+
case "errorHandler":
|
|
82
|
+
await scope.resolve(RunLimits).errorHandler(job);
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
await scope.resolve(ToolStep).run(job, signal);
|
|
86
|
+
}
|
|
59
87
|
} catch (error) {
|
|
60
88
|
this.report("job failed", error, { job: job.id, instance: job.instanceId, attempts: job.attempts });
|
|
61
89
|
await this.giveUpOrDelay(job, error).catch((failure: unknown) => this.report("job failure handling failed", failure, { job: job.id }));
|
|
@@ -64,11 +92,12 @@ export class WorkflowJobs {
|
|
|
64
92
|
}
|
|
65
93
|
}
|
|
66
94
|
|
|
67
|
-
/** Matches the scheduled starts against the published versions. */
|
|
95
|
+
/** Matches the scheduled starts against the published versions and gives a free place to whoever still waits for one. */
|
|
68
96
|
async reconcile(): Promise<void> {
|
|
69
97
|
const scope = this.services.createScope();
|
|
70
98
|
try {
|
|
71
99
|
await scope.resolve(Schedules).reconcile();
|
|
100
|
+
await scope.resolve(RunLimits).releaseWaiting();
|
|
72
101
|
} catch (error) {
|
|
73
102
|
this.report("scheduled starts could not be planned", error);
|
|
74
103
|
} finally {
|
|
@@ -91,7 +120,7 @@ export class WorkflowJobs {
|
|
|
91
120
|
await engine.inStep(job.instanceId, async (step) => {
|
|
92
121
|
await db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
93
122
|
const token = step.tokens.find((t) => t.id === job.tokenId);
|
|
94
|
-
if (token && token.status === "waiting" && step.instance.status === "running") engine.fail(step, token, "job_failed", message.slice(0, 1000));
|
|
123
|
+
if (token && token.status === "waiting" && step.instance.status === "running") await engine.fail(step, token, "job_failed", message.slice(0, 1000));
|
|
95
124
|
});
|
|
96
125
|
} finally {
|
|
97
126
|
await scope.dispose();
|
|
@@ -19,7 +19,7 @@ export const RUNTIME_ENTITIES = [WfInstance, WfToken, WfJob, WfSubscription, WfH
|
|
|
19
19
|
/** The tables of the Runtime part: bazis creates and checks them in the application database. */
|
|
20
20
|
export const runtimeStore = defineOrmOwnedStoreV1({
|
|
21
21
|
contract: "bazis.orm-owned-store/v1",
|
|
22
|
-
storeKey: "bazis.workflow.runtime",
|
|
22
|
+
storeKey: "bazis.workflow.runtime.v2",
|
|
23
23
|
formatVersion: 1,
|
|
24
|
-
ownedScope: { schema: "public", tablePrefix: "
|
|
24
|
+
ownedScope: { schema: "public", tablePrefix: "bazis_wf2_run_" },
|
|
25
25
|
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
|
|
2
2
|
import { WfInstance } from "./WfInstance.model";
|
|
3
3
|
|
|
4
|
-
export type HistoryType = "started" | "entered" | "left" | "tool" | "timer" | "event" | "error" | "retried" | "cancelled" | "completed";
|
|
4
|
+
export type HistoryType = "started" | "queued" | "released" | "entered" | "left" | "tool" | "assigned" | "iteration" | "called" | "returned" | "skipped" | "timer" | "event" | "error" | "retried" | "cancelled" | "timedOut" | "handler" | "completed";
|
|
5
5
|
|
|
6
6
|
/** The journal of an instance, append only. Its v7 ids give the order. */
|
|
7
7
|
@ForeignKey(() => WfInstance, { properties: ["instanceId"], onDelete: "cascade" })
|
|
8
8
|
@Index(["instanceId", "id"])
|
|
9
|
-
@Entity({ table: "
|
|
9
|
+
@Entity({ table: "bazis_wf2_run_history" })
|
|
10
10
|
export class WfHistory {
|
|
11
11
|
@UUID({ version: "v7" })
|
|
12
12
|
id = "";
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { Column, Entity, Index, UUID } from "../../../orm";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `completed`, `cancelled` and `timedOut` are finished: retention deletes them.
|
|
5
|
+
* `failed` waits for retry or cancel, `pending` for a free place (`maxConcurrent`, `sameKey: "queue"`).
|
|
6
|
+
*/
|
|
7
|
+
export type InstanceStatus = "pending" | "running" | "completed" | "cancelled" | "failed" | "timedOut";
|
|
5
8
|
|
|
6
|
-
export type StartedVia = "manual" | "event" | "schedule";
|
|
9
|
+
export type StartedVia = "manual" | "event" | "schedule" | "callProcess" | "errorHandler";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* One run of a process. Bound to the version it started with: a new publication
|
|
10
|
-
* does not touch it. Its data: the input, the variables
|
|
11
|
-
*
|
|
13
|
+
* does not touch it. Its data: the input, the variables and the result of each
|
|
14
|
+
* step by node id.
|
|
12
15
|
*/
|
|
13
16
|
@Index(["partition", "status"])
|
|
14
17
|
@Index(["processKey", "status"])
|
|
15
18
|
@Index(["status", "endedAt"])
|
|
16
|
-
@
|
|
19
|
+
@Index(["test", "createdAt"])
|
|
20
|
+
@Entity({ table: "bazis_wf2_run_instances" })
|
|
17
21
|
export class WfInstance {
|
|
18
22
|
@UUID({ version: "v7" })
|
|
19
23
|
id = "";
|
|
@@ -45,6 +49,30 @@ export class WfInstance {
|
|
|
45
49
|
@Column({ type: "json" })
|
|
46
50
|
nodes: Record<string, unknown> = {};
|
|
47
51
|
|
|
52
|
+
/** What the process gave back: its `out` variables when it completed. */
|
|
53
|
+
@Column({ type: "json", nullable: true })
|
|
54
|
+
output: Record<string, unknown> | null = null;
|
|
55
|
+
|
|
56
|
+
/** `$event`: the data of the event that started or last continued it. */
|
|
57
|
+
@Column({ type: "json", nullable: true })
|
|
58
|
+
event: unknown = null;
|
|
59
|
+
|
|
60
|
+
/** `$trigger`: how it was started. */
|
|
61
|
+
@Column({ type: "json", nullable: true })
|
|
62
|
+
trigger: Record<string, unknown> | null = null;
|
|
63
|
+
|
|
64
|
+
/** A trial run (WORKFLOW-002 §5.4, stage 5). */
|
|
65
|
+
@Column({ type: "boolean" })
|
|
66
|
+
test = false;
|
|
67
|
+
|
|
68
|
+
/** The instance whose `callProcess` started this one, and the waiting token there. */
|
|
69
|
+
@Index()
|
|
70
|
+
@Column({ type: "text", nullable: true })
|
|
71
|
+
parentInstanceId: string | null = null;
|
|
72
|
+
|
|
73
|
+
@Column({ type: "text", nullable: true })
|
|
74
|
+
parentTokenId: string | null = null;
|
|
75
|
+
|
|
48
76
|
@Index()
|
|
49
77
|
@Column({ type: "text", nullable: true })
|
|
50
78
|
businessKey: string | null = null;
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
|
|
2
2
|
import { WfInstance } from "./WfInstance.model";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* `tool` calls a service task's Tool, `timer` fires a timer, `schedule` starts a
|
|
6
|
+
* scheduled process, `loop` goes on with a loop after a batch of iterations.
|
|
7
|
+
* `call` starts a called process, `returned` tells the caller it ended,
|
|
8
|
+
* `cancelChildren` closes what a closed step called. `runTimeout` ends a run that
|
|
9
|
+
* took too long, `release` starts instances that wait for a place, `errorHandler`
|
|
10
|
+
* starts the process that handles a failure.
|
|
11
|
+
*/
|
|
12
|
+
export type JobKind = "tool" | "timer" | "schedule" | "loop" | "call" | "returned" | "cancelChildren" | "runTimeout" | "release" | "errorHandler";
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* Deferred work of the worker, created in the same transaction as the waiting
|
|
9
|
-
* token. Application instances claim it with `FOR UPDATE SKIP LOCKED`.
|
|
16
|
+
* token. Application instances claim it with `FOR UPDATE SKIP LOCKED`. A
|
|
17
|
+
* transaction holds the row of one instance only: what another instance must do
|
|
18
|
+
* goes through a job, so that two instances never wait for each other.
|
|
10
19
|
*/
|
|
11
20
|
@ForeignKey(() => WfInstance, { properties: ["instanceId"], onDelete: "cascade" })
|
|
12
|
-
@Entity({ table: "
|
|
21
|
+
@Entity({ table: "bazis_wf2_run_jobs" })
|
|
13
22
|
export class WfJob {
|
|
14
23
|
@UUID({ version: "v7" })
|
|
15
24
|
id = "";
|
|
@@ -34,6 +43,22 @@ export class WfJob {
|
|
|
34
43
|
@Column({ type: "datetime" })
|
|
35
44
|
dueAt = new Date(0);
|
|
36
45
|
|
|
46
|
+
/** What a job carries besides its token: the caller to tell, the process to release. */
|
|
47
|
+
@Column({ type: "json", nullable: true })
|
|
48
|
+
payload: unknown = null;
|
|
49
|
+
|
|
50
|
+
/** Jobs with one key merge into the last: `debounce` of an event start (stage 4). */
|
|
51
|
+
@Index()
|
|
52
|
+
@Column({ type: "text", nullable: true })
|
|
53
|
+
dedupeKey: string | null = null;
|
|
54
|
+
|
|
55
|
+
/** The partition and the start node of a `schedule` job (stage 4). */
|
|
56
|
+
@Column({ type: "text", nullable: true })
|
|
57
|
+
partition: string | null = null;
|
|
58
|
+
|
|
59
|
+
@Column({ type: "text", nullable: true })
|
|
60
|
+
nodeId: string | null = null;
|
|
61
|
+
|
|
37
62
|
@Column({ type: "integer" })
|
|
38
63
|
attempts = 0;
|
|
39
64
|
|
|
@@ -4,7 +4,7 @@ import { WfInstance } from "./WfInstance.model";
|
|
|
4
4
|
/** A token waiting for an event: `eventWait` or `boundaryEvent`. Matched by key and correlation. */
|
|
5
5
|
@ForeignKey(() => WfInstance, { properties: ["instanceId"], onDelete: "cascade" })
|
|
6
6
|
@Index(["eventKey", "correlation"])
|
|
7
|
-
@Entity({ table: "
|
|
7
|
+
@Entity({ table: "bazis_wf2_run_subscriptions" })
|
|
8
8
|
export class WfSubscription {
|
|
9
9
|
@UUID({ version: "v7" })
|
|
10
10
|
id = "";
|
|
@@ -2,15 +2,16 @@ import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
|
|
|
2
2
|
import { WfInstance } from "./WfInstance.model";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `active` passes a node, `waiting` waits for a Tool, a task
|
|
6
|
-
* moved on, `cancelled` was closed by a cancel, `failed`
|
|
5
|
+
* `active` passes a node, `waiting` waits for a Tool, a task, a join, a loop or a
|
|
6
|
+
* called process, `done` moved on, `cancelled` was closed by a cancel, `failed`
|
|
7
|
+
* waits for a retry.
|
|
7
8
|
*/
|
|
8
9
|
export type TokenStatus = "active" | "waiting" | "done" | "cancelled" | "failed";
|
|
9
10
|
|
|
10
11
|
/** Where execution is. After a parallel gateway an instance has several. Its id is the idempotency key of a Tool call. */
|
|
11
12
|
@ForeignKey(() => WfInstance, { properties: ["instanceId"], onDelete: "cascade" })
|
|
12
13
|
@Index(["instanceId", "status"])
|
|
13
|
-
@Entity({ table: "
|
|
14
|
+
@Entity({ table: "bazis_wf2_run_tokens" })
|
|
14
15
|
export class WfToken {
|
|
15
16
|
@UUID({ version: "v7" })
|
|
16
17
|
id = "";
|
|
@@ -28,9 +29,20 @@ export class WfToken {
|
|
|
28
29
|
@Column({ type: "text" })
|
|
29
30
|
status: TokenStatus = "active";
|
|
30
31
|
|
|
32
|
+
/** A step's result; a loop keeps its progress here while it runs. */
|
|
31
33
|
@Column({ type: "json", nullable: true })
|
|
32
34
|
output: unknown = null;
|
|
33
35
|
|
|
36
|
+
/** The loop token this one runs inside, with its iteration and what only the body sees: `Locals` of the engine. */
|
|
37
|
+
@Column({ type: "text", nullable: true })
|
|
38
|
+
parentTokenId: string | null = null;
|
|
39
|
+
|
|
40
|
+
@Column({ type: "integer", nullable: true })
|
|
41
|
+
iteration: number | null = null;
|
|
42
|
+
|
|
43
|
+
@Column({ type: "json", nullable: true })
|
|
44
|
+
locals: Record<string, unknown> | null = null;
|
|
45
|
+
|
|
34
46
|
@Column({ type: "createdAt" })
|
|
35
47
|
createdAt = new Date(0);
|
|
36
48
|
|
|
@@ -13,8 +13,34 @@ export interface StartInstance extends WorkflowAuditOptions {
|
|
|
13
13
|
readonly businessKey?: string | null;
|
|
14
14
|
/** On whose behalf the steps run. */
|
|
15
15
|
readonly actor: WorkflowActor;
|
|
16
|
+
/** The language of the request, for the application's validator; `workflow.language` by default. */
|
|
17
|
+
readonly language?: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/** One element of a mass start. */
|
|
21
|
+
export interface StartManyItem {
|
|
22
|
+
readonly input?: unknown;
|
|
23
|
+
/** What the instance is about: required, it is what `sameKey` compares. */
|
|
24
|
+
readonly businessKey: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StartMany extends WorkflowAuditOptions {
|
|
28
|
+
readonly items: readonly StartManyItem[];
|
|
29
|
+
readonly partition?: string | null;
|
|
30
|
+
readonly actor: WorkflowActor;
|
|
31
|
+
readonly language?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What became of one element: `started` (the instance may wait as `pending`),
|
|
36
|
+
* `skipped` (`sameKey: "skip"` and `instanceId` is the unfinished one) or `rejected`
|
|
37
|
+
* (no `businessKey`, invalid input).
|
|
38
|
+
*/
|
|
39
|
+
export type StartManyResult =
|
|
40
|
+
| { readonly status: "started"; readonly businessKey: string; readonly instanceId: string; readonly instanceStatus: InstanceStatus }
|
|
41
|
+
| { readonly status: "skipped"; readonly businessKey: string; readonly instanceId: string; readonly reason: "same_key" }
|
|
42
|
+
| { readonly status: "rejected"; readonly businessKey: string | null; readonly reason: "business_key_required" | "invalid_input"; readonly issues?: readonly FormIssue[] };
|
|
43
|
+
|
|
18
44
|
export interface CancelInstance extends WorkflowAuditOptions {
|
|
19
45
|
readonly reason?: string | null;
|
|
20
46
|
readonly actor: WorkflowActor;
|
|
@@ -33,6 +59,8 @@ export interface InstanceSummary {
|
|
|
33
59
|
readonly businessKey: string | null;
|
|
34
60
|
readonly startedVia: StartedVia;
|
|
35
61
|
readonly startedBy: WorkflowActor;
|
|
62
|
+
/** The instance whose `callProcess` started this one. */
|
|
63
|
+
readonly parentInstanceId: string | null;
|
|
36
64
|
readonly error: { readonly node: string; readonly code: string; readonly message: string } | null;
|
|
37
65
|
readonly createdAt: Date;
|
|
38
66
|
readonly endedAt: Date | null;
|
|
@@ -42,6 +70,9 @@ export interface TokenDetails {
|
|
|
42
70
|
readonly id: string;
|
|
43
71
|
readonly nodeId: string;
|
|
44
72
|
readonly viaFlowId: string | null;
|
|
73
|
+
/** Inside a loop body: the token of the loop and the number of the iteration. */
|
|
74
|
+
readonly parentTokenId: string | null;
|
|
75
|
+
readonly iteration: number | null;
|
|
45
76
|
readonly status: TokenStatus;
|
|
46
77
|
readonly output: unknown;
|
|
47
78
|
readonly createdAt: Date;
|
|
@@ -52,6 +83,8 @@ export interface InstanceDetails extends InstanceSummary {
|
|
|
52
83
|
readonly input: Record<string, unknown>;
|
|
53
84
|
readonly vars: Record<string, unknown>;
|
|
54
85
|
readonly nodes: Record<string, unknown>;
|
|
86
|
+
/** What the process gave back: its `out` variables; `null` until it completes. */
|
|
87
|
+
readonly output: Record<string, unknown> | null;
|
|
55
88
|
/** Every token, oldest first. */
|
|
56
89
|
readonly tokens: readonly TokenDetails[];
|
|
57
90
|
}
|
|
@@ -78,6 +111,8 @@ export interface EmitOptions extends WorkflowAuditOptions {
|
|
|
78
111
|
export interface EmitResult {
|
|
79
112
|
/** Instances started by an `eventStart`. */
|
|
80
113
|
readonly started: readonly string[];
|
|
114
|
+
/** Processes with `sameKey: "skip"` that did not start: an instance with the key is not finished. */
|
|
115
|
+
readonly skipped: readonly { readonly processKey: string; readonly instanceId: string }[];
|
|
81
116
|
/** Instances whose `eventWait` or `boundaryEvent` took the event. */
|
|
82
117
|
readonly continued: readonly string[];
|
|
83
118
|
/** Processes with an `eventStart` whose input the payload did not pass. */
|
|
@@ -92,20 +127,24 @@ export interface InstanceListQuery extends WorkflowPaging {
|
|
|
92
127
|
readonly processKey?: string;
|
|
93
128
|
readonly businessKey?: string;
|
|
94
129
|
readonly startedById?: string;
|
|
130
|
+
readonly parentInstanceId?: string;
|
|
95
131
|
}
|
|
96
132
|
|
|
97
133
|
/**
|
|
98
134
|
* Runs published versions (WORKFLOW-001 §4). Errors are {@link WorkflowError}:
|
|
99
|
-
* `not_found`, `archived`, `invalid_input` with the issues, `wrong_status
|
|
135
|
+
* `not_found`, `archived`, `invalid_input` with the issues, `wrong_status`,
|
|
136
|
+
* `conflict` when `sameKey: "skip"` refuses a start.
|
|
100
137
|
*/
|
|
101
138
|
export abstract class IWorkflowRuntime {
|
|
102
|
-
/** Starts the latest version of a process from its manual start. */
|
|
139
|
+
/** Starts the latest version of a process from its manual start. It may wait as `pending` (`maxConcurrent`, `sameKey: "queue"`). */
|
|
103
140
|
abstract start(processKey: string, options: StartInstance): Promise<InstanceDetails>;
|
|
141
|
+
/** Starts an instance per element, each in its own transaction, and says what became of each. */
|
|
142
|
+
abstract startMany(processKey: string, options: StartMany): Promise<readonly StartManyResult[]>;
|
|
104
143
|
abstract cancel(instanceId: string, options: CancelInstance): Promise<InstanceDetails>;
|
|
105
|
-
/** Retries the failed steps of a `failed` instance. */
|
|
144
|
+
/** Retries the failed steps of a `failed` instance. An instance called by another one is retried through its caller. */
|
|
106
145
|
abstract retry(instanceId: string, options: RetryInstance): Promise<InstanceDetails>;
|
|
107
146
|
/** Finishes a user task's wait with the form output and moves on; `wrong_status` when the token no longer waits. */
|
|
108
|
-
abstract completeToken(tokenId: string, output: unknown, actor: WorkflowActor): Promise<InstanceDetails>;
|
|
147
|
+
abstract completeToken(tokenId: string, output: unknown, actor: WorkflowActor, options?: { readonly language?: string }): Promise<InstanceDetails>;
|
|
109
148
|
/**
|
|
110
149
|
* Delivers an event: starts processes whose latest version has an
|
|
111
150
|
* `eventStart` for the key, and moves the instances waiting for it.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { grantsToWhere } from "../../../access";
|
|
2
2
|
import { paginate } from "../../../orm";
|
|
3
|
-
import type
|
|
3
|
+
import { inputSpecOf, type FormIssue, type FormSpec } from "../../../../library/workflow";
|
|
4
4
|
import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
|
|
5
5
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
6
6
|
import type { ServiceProvider } from "../../../di";
|
|
7
7
|
import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
|
|
8
8
|
import type { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
9
|
+
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
9
10
|
import type { WorkflowEngine } from "../internal/WorkflowEngine";
|
|
10
11
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
11
12
|
import type { WfHistory } from "../model/WfHistory.model";
|
|
@@ -22,9 +23,12 @@ import {
|
|
|
22
23
|
type InstanceSummary,
|
|
23
24
|
type RetryInstance,
|
|
24
25
|
type StartInstance,
|
|
26
|
+
type StartMany,
|
|
27
|
+
type StartManyResult,
|
|
25
28
|
} from "./IWorkflowRuntime.service";
|
|
26
29
|
|
|
27
|
-
const STATUSES = new Set(["running", "completed", "cancelled", "failed"]);
|
|
30
|
+
const STATUSES = new Set(["pending", "running", "completed", "cancelled", "failed", "timedOut"]);
|
|
31
|
+
const MAX_START_MANY = 1000;
|
|
28
32
|
const EVENT_KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
|
|
29
33
|
|
|
30
34
|
export class WorkflowRuntime extends IWorkflowRuntime {
|
|
@@ -34,6 +38,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
34
38
|
private readonly definitions: IWorkflowDefinitions,
|
|
35
39
|
/** The scope: the input validator is asked here, so that the application's own registration replaces the default. */
|
|
36
40
|
private readonly services: ServiceProvider,
|
|
41
|
+
private readonly languages: WorkflowLanguages,
|
|
37
42
|
) {
|
|
38
43
|
super();
|
|
39
44
|
}
|
|
@@ -45,18 +50,50 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
45
50
|
const version = await this.definitions.latest(processKey);
|
|
46
51
|
const start = Object.entries(version.document.nodes).find(([, node]) => node.kind === "manualStart");
|
|
47
52
|
if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
|
|
48
|
-
const { value, issues } = await this.validator().validate(version.document
|
|
53
|
+
const { value, issues } = await this.validator().validate(inputSpecOf(version.document), options.input, { purpose: "input", processKey, partition, language: options.language ?? this.languages.default, actor: options.actor });
|
|
49
54
|
if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
|
|
50
|
-
const id = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null });
|
|
55
|
+
const { id, skipped } = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null });
|
|
56
|
+
if (skipped) throw new WorkflowError("conflict", `Instance ${id} with this businessKey is not finished`);
|
|
51
57
|
return await this.instance(id);
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
async startMany(processKey: string, options: StartMany): Promise<readonly StartManyResult[]> {
|
|
61
|
+
actorOf(options.actor);
|
|
62
|
+
const partition = optionalText(options.partition, "partition");
|
|
63
|
+
if (!Array.isArray(options.items) || options.items.length === 0 || options.items.length > MAX_START_MANY) throw new WorkflowError("invalid_input", `items must be a list of 1–${MAX_START_MANY}`);
|
|
64
|
+
const version = await this.definitions.latest(processKey);
|
|
65
|
+
const start = Object.entries(version.document.nodes).find(([, node]) => node.kind === "manualStart");
|
|
66
|
+
if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
|
|
67
|
+
const graph = this.engine.cache(version);
|
|
68
|
+
const results: StartManyResult[] = [];
|
|
69
|
+
for (const item of options.items) {
|
|
70
|
+
const businessKey = typeof item?.businessKey === "string" && item.businessKey !== "" && item.businessKey.length <= 200 ? item.businessKey : null;
|
|
71
|
+
if (businessKey === null) {
|
|
72
|
+
results.push({ status: "rejected", businessKey: null, reason: "business_key_required" });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const { value, issues } = await this.validator().validate(inputSpecOf(version.document), item.input, { purpose: "input", processKey, partition, language: options.language ?? this.languages.default, actor: options.actor });
|
|
76
|
+
if (issues.length > 0) {
|
|
77
|
+
results.push({ status: "rejected", businessKey, reason: "invalid_input", issues });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const { id, skipped } = await this.engine.begin(graph, start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null });
|
|
81
|
+
if (skipped) {
|
|
82
|
+
results.push({ status: "skipped", businessKey, instanceId: id, reason: "same_key" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const row = await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(id));
|
|
86
|
+
results.push({ status: "started", businessKey, instanceId: id, instanceStatus: row?.status ?? "running" });
|
|
87
|
+
}
|
|
88
|
+
return results;
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
async cancel(instanceId: string, options: CancelInstance): Promise<InstanceDetails> {
|
|
55
92
|
actorOf(options.actor);
|
|
56
93
|
const reason = optionalText(options.reason, "reason", 1000);
|
|
57
94
|
if (!isUuid(instanceId)) throw notFound(instanceId);
|
|
58
95
|
const done = await this.engine.inStep(instanceId, async (step) => {
|
|
59
|
-
if (step.instance.status !== "running" && step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Instance is ${step.instance.status}`);
|
|
96
|
+
if (step.instance.status !== "pending" && step.instance.status !== "running" && step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Instance is ${step.instance.status}`);
|
|
60
97
|
await this.engine.cancel(step, reason, options.actor, options.request ?? null);
|
|
61
98
|
return true;
|
|
62
99
|
});
|
|
@@ -69,6 +106,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
69
106
|
if (!isUuid(instanceId)) throw notFound(instanceId);
|
|
70
107
|
const done = await this.engine.inStep(instanceId, async (step) => {
|
|
71
108
|
if (step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Only a failed instance is retried; this one is ${step.instance.status}`);
|
|
109
|
+
if (step.instance.parentInstanceId !== null) throw new WorkflowError("wrong_status", `This instance was called by ${step.instance.parentInstanceId}: retry that one`);
|
|
72
110
|
await this.engine.retry(step, options.actor, options.request ?? null);
|
|
73
111
|
return true;
|
|
74
112
|
});
|
|
@@ -76,7 +114,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
76
114
|
return await this.instance(instanceId);
|
|
77
115
|
}
|
|
78
116
|
|
|
79
|
-
async completeToken(tokenId: string, output: unknown, actor: WorkflowActor): Promise<InstanceDetails> {
|
|
117
|
+
async completeToken(tokenId: string, output: unknown, actor: WorkflowActor, options: { readonly language?: string } = {}): Promise<InstanceDetails> {
|
|
80
118
|
actorOf(actor);
|
|
81
119
|
if (!isUuid(tokenId)) throw new WorkflowError("not_found", `Token ${tokenId} does not exist`);
|
|
82
120
|
const token = await this.db.tokens.asNoTracking().firstOrDefault((t) => t.id.eq(tokenId));
|
|
@@ -87,7 +125,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
87
125
|
if (node?.kind !== "userTask" || !current || current.status !== "waiting" || step.instance.status !== "running") {
|
|
88
126
|
throw new WorkflowError("wrong_status", "The token does not wait for a task");
|
|
89
127
|
}
|
|
90
|
-
const { value, issues } = await this.validator().validate(node.config?.form as FormSpec | undefined, output, { purpose: "task", processKey: step.instance.processKey, partition: step.instance.partition });
|
|
128
|
+
const { value, issues } = await this.validator().validate(node.config?.form as FormSpec | undefined, output, { purpose: "task", processKey: step.instance.processKey, partition: step.instance.partition, language: options.language ?? this.languages.default, actor });
|
|
91
129
|
if (issues.length > 0) throw new WorkflowError("invalid_input", "The task form is invalid", issues);
|
|
92
130
|
await this.engine.complete(step, tokenId, value, actor);
|
|
93
131
|
return true;
|
|
@@ -118,25 +156,28 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
118
156
|
}
|
|
119
157
|
|
|
120
158
|
const started: string[] = [];
|
|
159
|
+
const skipped: { processKey: string; instanceId: string }[] = [];
|
|
121
160
|
const rejected: { processKey: string; issues: readonly FormIssue[] }[] = [];
|
|
122
161
|
for (const version of await this.definitions.published()) {
|
|
123
162
|
for (const [nodeId, node] of Object.entries(version.document.nodes)) {
|
|
124
163
|
if (node.kind !== "eventStart" || node.config?.event !== eventKey) continue;
|
|
125
164
|
// An event carries more than a process needs: fields the input does not declare are dropped, not refused.
|
|
126
|
-
const { value, issues: all } = await this.validator().validate(version.document
|
|
165
|
+
const { value, issues: all } = await this.validator().validate(inputSpecOf(version.document), data, { purpose: "input", processKey: version.key, partition, language: this.languages.default, actor });
|
|
127
166
|
const issues = all.filter((issue) => issue.code !== "unknown");
|
|
128
167
|
if (issues.length > 0) {
|
|
129
168
|
rejected.push({ processKey: version.key, issues });
|
|
130
169
|
continue;
|
|
131
170
|
}
|
|
132
|
-
|
|
171
|
+
const begun = await this.engine.begin(this.engine.cache(version), nodeId, { input: value, partition, businessKey: correlation, startedVia: "event", actor, request: options.request ?? null, event: { key: eventKey, payload: data } });
|
|
172
|
+
if (begun.skipped) skipped.push({ processKey: version.key, instanceId: begun.id });
|
|
173
|
+
else started.push(begun.id);
|
|
133
174
|
}
|
|
134
175
|
}
|
|
135
|
-
return { started, continued: [...continued], rejected };
|
|
176
|
+
return { started, skipped, continued: [...continued], rejected };
|
|
136
177
|
}
|
|
137
178
|
|
|
138
179
|
async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
|
|
139
|
-
if (query.status !== undefined && !STATUSES.has(query.status)) throw new WorkflowError("invalid_input", "status must be running, completed, cancelled or
|
|
180
|
+
if (query.status !== undefined && !STATUSES.has(query.status)) throw new WorkflowError("invalid_input", "status must be pending, running, completed, cancelled, failed or timedOut");
|
|
140
181
|
const where = query.access === undefined ? "all" : grantsToWhere(query.access);
|
|
141
182
|
if (where === "none") return { items: [], total: 0 };
|
|
142
183
|
let source = this.db.instances.asNoTracking();
|
|
@@ -146,6 +187,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
146
187
|
if (query.processKey !== undefined) source = source.where((i) => i.processKey.eq(query.processKey!));
|
|
147
188
|
if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
|
|
148
189
|
if (query.startedById !== undefined) source = source.where((i) => i.startedById.eq(query.startedById!));
|
|
190
|
+
if (query.parentInstanceId !== undefined) source = source.where((i) => i.parentInstanceId.eq(query.parentInstanceId!));
|
|
149
191
|
const { items, total } = await paginate(source, pageQueryOf(query, { field: "id", dir: "desc" }));
|
|
150
192
|
return { items: items.map(summaryOf), total };
|
|
151
193
|
}
|
|
@@ -155,7 +197,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
155
197
|
const row = await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(instanceId));
|
|
156
198
|
if (!row) throw notFound(instanceId);
|
|
157
199
|
const tokens = await this.db.tokens.asNoTracking().where((t) => t.instanceId.eq(instanceId)).orderBy((t) => t.id).toList();
|
|
158
|
-
return { ...summaryOf(row), input: row.input, vars: row.vars, nodes: row.nodes, tokens: tokens.map(tokenOf) };
|
|
200
|
+
return { ...summaryOf(row), input: row.input, vars: row.vars, nodes: row.nodes, output: row.output, tokens: tokens.map(tokenOf) };
|
|
159
201
|
}
|
|
160
202
|
|
|
161
203
|
async history(instanceId: string): Promise<readonly HistoryEntry[]> {
|
|
@@ -198,6 +240,7 @@ function summaryOf(row: WfInstance): InstanceSummary {
|
|
|
198
240
|
businessKey: row.businessKey,
|
|
199
241
|
startedVia: row.startedVia,
|
|
200
242
|
startedBy: { kind: row.startedByKind, id: row.startedById },
|
|
243
|
+
parentInstanceId: row.parentInstanceId,
|
|
201
244
|
error: row.error,
|
|
202
245
|
createdAt: row.createdAt,
|
|
203
246
|
endedAt: row.endedAt,
|
|
@@ -205,7 +248,7 @@ function summaryOf(row: WfInstance): InstanceSummary {
|
|
|
205
248
|
}
|
|
206
249
|
|
|
207
250
|
function tokenOf(row: WfToken) {
|
|
208
|
-
return { id: row.id, nodeId: row.nodeId, viaFlowId: row.viaFlowId, status: row.status, output: row.output, createdAt: row.createdAt, endedAt: row.endedAt };
|
|
251
|
+
return { id: row.id, nodeId: row.nodeId, viaFlowId: row.viaFlowId, parentTokenId: row.parentTokenId, iteration: row.iteration, status: row.status, output: row.output, createdAt: row.createdAt, endedAt: row.endedAt };
|
|
209
252
|
}
|
|
210
253
|
|
|
211
254
|
function historyOf(row: WfHistory): HistoryEntry {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { OnEvent } from "../../../kernel";
|
|
2
|
-
import { UniqueViolationError } from "../../../orm";
|
|
3
2
|
import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
|
|
4
3
|
import type { TasksDbContext } from "../model/Tasks.dbContext";
|
|
5
4
|
import { WfTask } from "../model/WfTask.model";
|
|
@@ -12,7 +11,8 @@ export class TaskEvents {
|
|
|
12
11
|
@OnEvent(WORKFLOW_TASK_REQUESTED)
|
|
13
12
|
async requested(event: WorkflowTaskRequested): Promise<void> {
|
|
14
13
|
const assignee = "role" in event.assignee ? `role:${event.assignee.role}` : `user:${event.assignee.user}`;
|
|
15
|
-
|
|
14
|
+
// Past the change tracker: the scope may go on to update tasks directly, which a context holding one would refuse.
|
|
15
|
+
await this.db.tasks.insertIfAbsent(Object.assign(new WfTask(), {
|
|
16
16
|
tokenId: event.tokenId,
|
|
17
17
|
instanceId: event.instanceId,
|
|
18
18
|
processKey: event.processKey,
|
|
@@ -22,12 +22,8 @@ export class TaskEvents {
|
|
|
22
22
|
assignee,
|
|
23
23
|
form: event.form,
|
|
24
24
|
dueAt: event.dueAt,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
await this.db.saveChanges();
|
|
28
|
-
} catch (error) {
|
|
29
|
-
if (!(error instanceof UniqueViolationError)) throw error;
|
|
30
|
-
}
|
|
25
|
+
createdAt: new Date(),
|
|
26
|
+
}), { conflictBy: (t) => [t.tokenId] });
|
|
31
27
|
}
|
|
32
28
|
|
|
33
29
|
/** The step closed otherwise than by its task: the task is cancelled. */
|