bazis 0.132.0 → 0.133.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
- package/core/workflow/definitions_module/http/Processes.controller.ts +2 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +2 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -2
- package/core/workflow/generated/deps.ts +4 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
- package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
- package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
- package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
- package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
- package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +518 -132
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +37 -6
- package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
- package/core/workflow/runtime_module/model/WfJob.model.ts +13 -4
- package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +38 -3
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +48 -7
- package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
- package/library/workflow/ProcessDocument.ts +14 -0
- package/library/workflow/expressions.ts +5 -1
- package/library/workflow/index.ts +1 -1
- package/library/workflow/masking.ts +17 -3
- package/library/workflow/mermaid.ts +22 -6
- package/library/workflow/nodeKinds.ts +59 -0
- package/library/workflow/palette.ts +3 -1
- package/library/workflow/paths.ts +8 -2
- package/library/workflow/structure.ts +33 -4
- package/library/workflow/validateProcess.ts +129 -17
- package/package.json +1 -1
|
@@ -2,6 +2,8 @@ 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
9
|
import { LEASE_MS, ToolStep } from "./ToolStep";
|
|
@@ -31,7 +33,7 @@ export class WorkflowJobs {
|
|
|
31
33
|
const now = scope.resolve(WorkflowClock).now();
|
|
32
34
|
return await db.transactionScope(async () => {
|
|
33
35
|
const jobs = await db.jobs
|
|
34
|
-
.where((j) => j.
|
|
36
|
+
.where((j) => j.dueAt.lte(now).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
|
|
35
37
|
.orderBy((j) => j.id)
|
|
36
38
|
.take(batchSize)
|
|
37
39
|
.forUpdate({ skipLocked: true })
|
|
@@ -51,9 +53,37 @@ export class WorkflowJobs {
|
|
|
51
53
|
async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
52
54
|
const scope = this.services.createScope();
|
|
53
55
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
87
|
} catch (error) {
|
|
58
88
|
this.report("job failed", error, { job: job.id, instance: job.instanceId, attempts: job.attempts });
|
|
59
89
|
await this.giveUpOrDelay(job, error).catch((failure: unknown) => this.report("job failure handling failed", failure, { job: job.id }));
|
|
@@ -62,11 +92,12 @@ export class WorkflowJobs {
|
|
|
62
92
|
}
|
|
63
93
|
}
|
|
64
94
|
|
|
65
|
-
/** 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. */
|
|
66
96
|
async reconcile(): Promise<void> {
|
|
67
97
|
const scope = this.services.createScope();
|
|
68
98
|
try {
|
|
69
99
|
await scope.resolve(Schedules).reconcile();
|
|
100
|
+
await scope.resolve(RunLimits).releaseWaiting();
|
|
70
101
|
} catch (error) {
|
|
71
102
|
this.report("scheduled starts could not be planned", error);
|
|
72
103
|
} finally {
|
|
@@ -89,7 +120,7 @@ export class WorkflowJobs {
|
|
|
89
120
|
await engine.inStep(job.instanceId, async (step) => {
|
|
90
121
|
await db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
91
122
|
const token = step.tokens.find((t) => t.id === job.tokenId);
|
|
92
|
-
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));
|
|
93
124
|
});
|
|
94
125
|
} finally {
|
|
95
126
|
await scope.dispose();
|
|
@@ -1,7 +1,7 @@
|
|
|
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" | "assigned" | "skipped" | "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" })
|
|
@@ -1,9 +1,12 @@
|
|
|
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
|
|
@@ -62,7 +65,7 @@ export class WfInstance {
|
|
|
62
65
|
@Column({ type: "boolean" })
|
|
63
66
|
test = false;
|
|
64
67
|
|
|
65
|
-
/** The instance whose `callProcess` started this one
|
|
68
|
+
/** The instance whose `callProcess` started this one, and the waiting token there. */
|
|
66
69
|
@Index()
|
|
67
70
|
@Column({ type: "text", nullable: true })
|
|
68
71
|
parentInstanceId: string | null = null;
|
|
@@ -1,12 +1,21 @@
|
|
|
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
21
|
@Entity({ table: "bazis_wf2_run_jobs" })
|
|
@@ -34,7 +43,7 @@ export class WfJob {
|
|
|
34
43
|
@Column({ type: "datetime" })
|
|
35
44
|
dueAt = new Date(0);
|
|
36
45
|
|
|
37
|
-
/** What a job carries besides its token:
|
|
46
|
+
/** What a job carries besides its token: the caller to tell, the process to release. */
|
|
38
47
|
@Column({ type: "json", nullable: true })
|
|
39
48
|
payload: unknown = null;
|
|
40
49
|
|
|
@@ -2,8 +2,9 @@ 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
|
|
|
@@ -28,10 +29,11 @@ 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
|
|
|
34
|
-
/** The loop token this one runs inside, with its iteration and what only the body sees
|
|
36
|
+
/** The loop token this one runs inside, with its iteration and what only the body sees: `Locals` of the engine. */
|
|
35
37
|
@Column({ type: "text", nullable: true })
|
|
36
38
|
parentTokenId: string | null = null;
|
|
37
39
|
|
|
@@ -17,6 +17,30 @@ export interface StartInstance extends WorkflowAuditOptions {
|
|
|
17
17
|
readonly language?: string;
|
|
18
18
|
}
|
|
19
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
|
+
|
|
20
44
|
export interface CancelInstance extends WorkflowAuditOptions {
|
|
21
45
|
readonly reason?: string | null;
|
|
22
46
|
readonly actor: WorkflowActor;
|
|
@@ -35,6 +59,8 @@ export interface InstanceSummary {
|
|
|
35
59
|
readonly businessKey: string | null;
|
|
36
60
|
readonly startedVia: StartedVia;
|
|
37
61
|
readonly startedBy: WorkflowActor;
|
|
62
|
+
/** The instance whose `callProcess` started this one. */
|
|
63
|
+
readonly parentInstanceId: string | null;
|
|
38
64
|
readonly error: { readonly node: string; readonly code: string; readonly message: string } | null;
|
|
39
65
|
readonly createdAt: Date;
|
|
40
66
|
readonly endedAt: Date | null;
|
|
@@ -44,6 +70,9 @@ export interface TokenDetails {
|
|
|
44
70
|
readonly id: string;
|
|
45
71
|
readonly nodeId: string;
|
|
46
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;
|
|
47
76
|
readonly status: TokenStatus;
|
|
48
77
|
readonly output: unknown;
|
|
49
78
|
readonly createdAt: Date;
|
|
@@ -82,6 +111,8 @@ export interface EmitOptions extends WorkflowAuditOptions {
|
|
|
82
111
|
export interface EmitResult {
|
|
83
112
|
/** Instances started by an `eventStart`. */
|
|
84
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 }[];
|
|
85
116
|
/** Instances whose `eventWait` or `boundaryEvent` took the event. */
|
|
86
117
|
readonly continued: readonly string[];
|
|
87
118
|
/** Processes with an `eventStart` whose input the payload did not pass. */
|
|
@@ -96,17 +127,21 @@ export interface InstanceListQuery extends WorkflowPaging {
|
|
|
96
127
|
readonly processKey?: string;
|
|
97
128
|
readonly businessKey?: string;
|
|
98
129
|
readonly startedById?: string;
|
|
130
|
+
readonly parentInstanceId?: string;
|
|
99
131
|
}
|
|
100
132
|
|
|
101
133
|
/**
|
|
102
134
|
* Runs published versions (WORKFLOW-001 §4). Errors are {@link WorkflowError}:
|
|
103
|
-
* `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.
|
|
104
137
|
*/
|
|
105
138
|
export abstract class IWorkflowRuntime {
|
|
106
|
-
/** 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"`). */
|
|
107
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[]>;
|
|
108
143
|
abstract cancel(instanceId: string, options: CancelInstance): Promise<InstanceDetails>;
|
|
109
|
-
/** 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. */
|
|
110
145
|
abstract retry(instanceId: string, options: RetryInstance): Promise<InstanceDetails>;
|
|
111
146
|
/** Finishes a user task's wait with the form output and moves on; `wrong_status` when the token no longer waits. */
|
|
112
147
|
abstract completeToken(tokenId: string, output: unknown, actor: WorkflowActor, options?: { readonly language?: string }): Promise<InstanceDetails>;
|
|
@@ -23,9 +23,12 @@ import {
|
|
|
23
23
|
type InstanceSummary,
|
|
24
24
|
type RetryInstance,
|
|
25
25
|
type StartInstance,
|
|
26
|
+
type StartMany,
|
|
27
|
+
type StartManyResult,
|
|
26
28
|
} from "./IWorkflowRuntime.service";
|
|
27
29
|
|
|
28
|
-
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;
|
|
29
32
|
const EVENT_KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
|
|
30
33
|
|
|
31
34
|
export class WorkflowRuntime extends IWorkflowRuntime {
|
|
@@ -49,16 +52,48 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
49
52
|
if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
|
|
50
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 });
|
|
51
54
|
if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
|
|
52
|
-
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`);
|
|
53
57
|
return await this.instance(id);
|
|
54
58
|
}
|
|
55
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
|
+
|
|
56
91
|
async cancel(instanceId: string, options: CancelInstance): Promise<InstanceDetails> {
|
|
57
92
|
actorOf(options.actor);
|
|
58
93
|
const reason = optionalText(options.reason, "reason", 1000);
|
|
59
94
|
if (!isUuid(instanceId)) throw notFound(instanceId);
|
|
60
95
|
const done = await this.engine.inStep(instanceId, async (step) => {
|
|
61
|
-
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}`);
|
|
62
97
|
await this.engine.cancel(step, reason, options.actor, options.request ?? null);
|
|
63
98
|
return true;
|
|
64
99
|
});
|
|
@@ -71,6 +106,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
71
106
|
if (!isUuid(instanceId)) throw notFound(instanceId);
|
|
72
107
|
const done = await this.engine.inStep(instanceId, async (step) => {
|
|
73
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`);
|
|
74
110
|
await this.engine.retry(step, options.actor, options.request ?? null);
|
|
75
111
|
return true;
|
|
76
112
|
});
|
|
@@ -120,6 +156,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
120
156
|
}
|
|
121
157
|
|
|
122
158
|
const started: string[] = [];
|
|
159
|
+
const skipped: { processKey: string; instanceId: string }[] = [];
|
|
123
160
|
const rejected: { processKey: string; issues: readonly FormIssue[] }[] = [];
|
|
124
161
|
for (const version of await this.definitions.published()) {
|
|
125
162
|
for (const [nodeId, node] of Object.entries(version.document.nodes)) {
|
|
@@ -131,14 +168,16 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
131
168
|
rejected.push({ processKey: version.key, issues });
|
|
132
169
|
continue;
|
|
133
170
|
}
|
|
134
|
-
|
|
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);
|
|
135
174
|
}
|
|
136
175
|
}
|
|
137
|
-
return { started, continued: [...continued], rejected };
|
|
176
|
+
return { started, skipped, continued: [...continued], rejected };
|
|
138
177
|
}
|
|
139
178
|
|
|
140
179
|
async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
|
|
141
|
-
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");
|
|
142
181
|
const where = query.access === undefined ? "all" : grantsToWhere(query.access);
|
|
143
182
|
if (where === "none") return { items: [], total: 0 };
|
|
144
183
|
let source = this.db.instances.asNoTracking();
|
|
@@ -148,6 +187,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
148
187
|
if (query.processKey !== undefined) source = source.where((i) => i.processKey.eq(query.processKey!));
|
|
149
188
|
if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
|
|
150
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!));
|
|
151
191
|
const { items, total } = await paginate(source, pageQueryOf(query, { field: "id", dir: "desc" }));
|
|
152
192
|
return { items: items.map(summaryOf), total };
|
|
153
193
|
}
|
|
@@ -200,6 +240,7 @@ function summaryOf(row: WfInstance): InstanceSummary {
|
|
|
200
240
|
businessKey: row.businessKey,
|
|
201
241
|
startedVia: row.startedVia,
|
|
202
242
|
startedBy: { kind: row.startedByKind, id: row.startedById },
|
|
243
|
+
parentInstanceId: row.parentInstanceId,
|
|
203
244
|
error: row.error,
|
|
204
245
|
createdAt: row.createdAt,
|
|
205
246
|
endedAt: row.endedAt,
|
|
@@ -207,7 +248,7 @@ function summaryOf(row: WfInstance): InstanceSummary {
|
|
|
207
248
|
}
|
|
208
249
|
|
|
209
250
|
function tokenOf(row: WfToken) {
|
|
210
|
-
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 };
|
|
211
252
|
}
|
|
212
253
|
|
|
213
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. */
|
|
@@ -93,7 +93,7 @@ export class WorkflowTasks extends IWorkflowTasks {
|
|
|
93
93
|
if (error instanceof WorkflowError && error.code === "wrong_status") await this.close(task.tokenId, "cancelled");
|
|
94
94
|
throw error;
|
|
95
95
|
}
|
|
96
|
-
const saved = instance.
|
|
96
|
+
const saved = instance.tokens.find((t) => t.id === task.tokenId)?.output;
|
|
97
97
|
await this.db.tasks.asNoTracking().where((t) => t.id.eq(task.id)).executeUpdate({
|
|
98
98
|
status: "completed",
|
|
99
99
|
completedById: actor.id,
|
|
@@ -18,6 +18,8 @@ export type NodeKind =
|
|
|
18
18
|
| "userTask"
|
|
19
19
|
| "serviceTask"
|
|
20
20
|
| "assign"
|
|
21
|
+
| "loop"
|
|
22
|
+
| "callProcess"
|
|
21
23
|
| "timerCatch"
|
|
22
24
|
| "eventWait"
|
|
23
25
|
| "exclusiveGateway"
|
|
@@ -32,6 +34,8 @@ export interface ProcessNode {
|
|
|
32
34
|
readonly title: LocalizedText;
|
|
33
35
|
/** Boundary nodes only: the step the node is attached to. */
|
|
34
36
|
readonly attachedTo?: string;
|
|
37
|
+
/** The `loop` whose body the node belongs to. */
|
|
38
|
+
readonly parent?: string;
|
|
35
39
|
readonly config?: Readonly<Record<string, unknown>>;
|
|
36
40
|
/** The application's data by namespace; the engine does not read it. */
|
|
37
41
|
readonly extensions?: Readonly<Record<string, unknown>>;
|
|
@@ -77,8 +81,18 @@ export interface ProcessLogSettings {
|
|
|
77
81
|
|
|
78
82
|
/** Settings of a process: they are part of the version. */
|
|
79
83
|
export interface ProcessSettings {
|
|
84
|
+
/** On whose behalf steps run: who started the instance (the default) or the process's own system actor. */
|
|
85
|
+
readonly runAs?: "starter" | "process";
|
|
86
|
+
/** How long the whole run may take, waits included: an ISO duration. */
|
|
87
|
+
readonly runTimeout?: string;
|
|
80
88
|
/** The default `timeout` of steps with a Tool: an ISO duration. */
|
|
81
89
|
readonly stepTimeout?: string;
|
|
90
|
+
/** How many instances of the process run at once; the rest wait as `pending`. */
|
|
91
|
+
readonly maxConcurrent?: number;
|
|
92
|
+
/** A new start with the `businessKey` of an unfinished instance: run as well, do not start, or wait for it. */
|
|
93
|
+
readonly sameKey?: "allow" | "skip" | "queue";
|
|
94
|
+
/** The key of the process to start when an instance fails or times out. */
|
|
95
|
+
readonly errorHandler?: string;
|
|
82
96
|
readonly log?: ProcessLogSettings;
|
|
83
97
|
}
|
|
84
98
|
|
|
@@ -265,6 +265,8 @@ export interface ExpressionScope {
|
|
|
265
265
|
readonly vars?: ReadonlySet<string>;
|
|
266
266
|
/** Nodes: `$nodes.<node>`. */
|
|
267
267
|
readonly nodes?: ReadonlySet<string>;
|
|
268
|
+
/** Whether it is read inside a loop body: `$item` and `$index` exist only there. */
|
|
269
|
+
readonly loop?: boolean;
|
|
268
270
|
}
|
|
269
271
|
|
|
270
272
|
/** What is wrong with an expression, or `null`: its shape, functions, argument types, paths and limits. */
|
|
@@ -331,9 +333,11 @@ function literalArgumentProblem(name: ExprFunctionName, args: readonly unknown[]
|
|
|
331
333
|
|
|
332
334
|
/** What is wrong with a `$` path, or `null`. */
|
|
333
335
|
export function checkPath(path: unknown, scope: ExpressionScope = {}): string | null {
|
|
334
|
-
if (typeof path !== "string" || !DATA_PATH.test(path)) return `path ${JSON.stringify(path)} must look like $input.field, $vars.name, $nodes.Node.field, $event.field, $trigger, $run.id or $
|
|
336
|
+
if (typeof path !== "string" || !DATA_PATH.test(path)) return `path ${JSON.stringify(path)} must look like $input.field, $vars.name, $nodes.Node.field, $event.field, $trigger, $run.id, $now, $item.field or $index`;
|
|
335
337
|
const [root, first] = path.split(".");
|
|
336
338
|
if (root === "$now" && first !== undefined) return "$now has no fields";
|
|
339
|
+
if (root === "$index" && first !== undefined) return "$index has no fields";
|
|
340
|
+
if ((root === "$item" || root === "$index") && scope.loop === false) return `path ${path}: ${root} exists only inside a loop body`;
|
|
337
341
|
if (root === "$nodes" && first === undefined) return "$nodes needs a node: $nodes.Node.field";
|
|
338
342
|
if (first === undefined) return null;
|
|
339
343
|
if (root === "$input" && scope.input && !scope.input.has(first)) return `path ${path}: the input has no field ${first}`;
|
|
@@ -20,7 +20,7 @@ export {
|
|
|
20
20
|
type ProcessNode,
|
|
21
21
|
type ProcessSettings,
|
|
22
22
|
} from "./ProcessDocument";
|
|
23
|
-
export { NODE_KINDS, nodeKindOf, ISO_DURATION, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
|
|
23
|
+
export { NODE_KINDS, nodeKindOf, ISO_DURATION, MAX_ITERATIONS, MAX_LOOP_PARALLEL, PROCESS_KEY, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
|
|
24
24
|
export { PROCESS_LIMITS, validateProcess, hasErrors, type ProcessIssue, type ProcessIssueCode, type ValidateProcessOptions } from "./validateProcess";
|
|
25
25
|
export { DATA_PATH, DATA_PATHS, isPath, readPath, resolveInput, type InstanceData } from "./paths";
|
|
26
26
|
export {
|
|
@@ -14,9 +14,23 @@ export function masksPersonal(document: Pick<ProcessDocument, "settings">): bool
|
|
|
14
14
|
return document.settings?.log?.maskPersonal !== false;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The paths of the personal variables: `$input.email`, `$vars.email`. A loop over
|
|
19
|
+
* personal data adds its element: `$item` and the name the loop gives it.
|
|
20
|
+
*/
|
|
21
|
+
export function personalPaths(document: Pick<ProcessDocument, "variables"> & Partial<Pick<ProcessDocument, "nodes">>): ReadonlySet<string> {
|
|
22
|
+
const paths = new Set((document.variables ?? []).filter((variable) => variable.personal === true).map((variable) => `${variable.direction === "in" ? "$input" : "$vars"}.${variable.name}`));
|
|
23
|
+
const loops = Object.values(document.nodes ?? {}).filter((node) => node.kind === "loop" && node.config?.over !== undefined);
|
|
24
|
+
// A loop over the element of another loop: as many passes as it takes.
|
|
25
|
+
for (let size = -1; size !== paths.size;) {
|
|
26
|
+
size = paths.size;
|
|
27
|
+
for (const { config } of loops) {
|
|
28
|
+
if (!readsPersonal(config!.over, paths)) continue;
|
|
29
|
+
paths.add("$item");
|
|
30
|
+
if (typeof config!.item === "string") paths.add(`$vars.${config!.item}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return paths;
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
/** The names of the personal `local` and `out` variables. */
|
|
@@ -13,6 +13,8 @@ const SHAPES: Record<NodeKind, readonly [open: string, close: string]> = {
|
|
|
13
13
|
end: ["((", "))"],
|
|
14
14
|
userTask: ["[", "]"],
|
|
15
15
|
serviceTask: ["[[", "]]"],
|
|
16
|
+
callProcess: ["[[", "]]"],
|
|
17
|
+
loop: ["[", "]"],
|
|
16
18
|
assign: ["[/", "/]"],
|
|
17
19
|
timerCatch: ["{{", "}}"],
|
|
18
20
|
eventWait: ["{{", "}}"],
|
|
@@ -25,7 +27,7 @@ const SHAPES: Record<NodeKind, readonly [open: string, close: string]> = {
|
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* A process as a Mermaid `flowchart` (WORKFLOW-001 §14): starts are stadiums,
|
|
28
|
-
* Tool steps subroutines, tasks rectangles, waits hexagons, gateways rhombi,
|
|
30
|
+
* Tool steps and called processes subroutines, a loop a subgraph around its body, tasks rectangles, waits hexagons, gateways rhombi,
|
|
29
31
|
* ends and boundary events circles. A boundary event hangs on its step by a
|
|
30
32
|
* dotted link. Labels show the node kind's detail: the Tool, the cron, the
|
|
31
33
|
* duration, the event key; flows show their title or condition.
|
|
@@ -43,11 +45,23 @@ export function processToMermaid(document: ProcessDocument, direction: "LR" | "T
|
|
|
43
45
|
return id;
|
|
44
46
|
};
|
|
45
47
|
const lines = [`flowchart ${direction}`];
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
// A loop is a subgraph around its body; flows and boundaries link to it by its id.
|
|
49
|
+
const write = (parent: string | undefined, indent: string): void => {
|
|
50
|
+
for (const [nodeId, node] of Object.entries(document.nodes)) {
|
|
51
|
+
if (node.parent !== parent) continue;
|
|
52
|
+
const detail = detailOf(node.kind, node.config ?? {});
|
|
53
|
+
const text = `${label(textOf(node.title, "en") || nodeId)}${detail === undefined ? "" : `<br/><small>${label(detail)}</small>`}`;
|
|
54
|
+
if (node.kind === "loop") {
|
|
55
|
+
lines.push(`${indent}subgraph ${idOf(nodeId)}["${text}"]`);
|
|
56
|
+
write(nodeId, `${indent} `);
|
|
57
|
+
lines.push(`${indent}end`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const [open, close] = SHAPES[node.kind] ?? ["[", "]"];
|
|
61
|
+
lines.push(`${indent}${idOf(nodeId)}${open}"${text}"${close}`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
write(undefined, " ");
|
|
51
65
|
for (const [nodeId, node] of Object.entries(document.nodes)) {
|
|
52
66
|
if (node.attachedTo !== undefined && document.nodes[node.attachedTo] !== undefined) lines.push(` ${idOf(node.attachedTo)} -.- ${idOf(nodeId)}`);
|
|
53
67
|
}
|
|
@@ -63,6 +77,8 @@ function detailOf(kind: NodeKind, config: Readonly<Record<string, unknown>>): st
|
|
|
63
77
|
const text = (value: unknown) => (typeof value === "string" && value !== "" ? value : undefined);
|
|
64
78
|
switch (kind) {
|
|
65
79
|
case "serviceTask": return text(config.tool);
|
|
80
|
+
case "callProcess": return text(config.process);
|
|
81
|
+
case "loop": return typeof config.times === "number" ? `${config.times} times` : config.while !== undefined ? "while" : "for each";
|
|
66
82
|
case "scheduleStart": return text(config.cron);
|
|
67
83
|
case "eventStart":
|
|
68
84
|
case "eventWait":
|