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.
Files changed (36) hide show
  1. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  2. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
  3. package/core/workflow/definitions_module/http/Processes.controller.ts +2 -1
  4. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +2 -0
  5. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -2
  6. package/core/workflow/generated/deps.ts +4 -0
  7. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -0
  8. package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
  9. package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
  10. package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
  11. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  12. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  13. package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
  14. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  15. package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
  16. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +518 -132
  17. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +37 -6
  18. package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
  19. package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
  20. package/core/workflow/runtime_module/model/WfJob.model.ts +13 -4
  21. package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
  22. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +38 -3
  23. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +48 -7
  24. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  25. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
  26. package/library/workflow/ProcessDocument.ts +14 -0
  27. package/library/workflow/expressions.ts +5 -1
  28. package/library/workflow/index.ts +1 -1
  29. package/library/workflow/masking.ts +17 -3
  30. package/library/workflow/mermaid.ts +22 -6
  31. package/library/workflow/nodeKinds.ts +59 -0
  32. package/library/workflow/palette.ts +3 -1
  33. package/library/workflow/paths.ts +8 -2
  34. package/library/workflow/structure.ts +33 -4
  35. package/library/workflow/validateProcess.ts +129 -17
  36. 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.kind.in(["tool", "timer", "schedule"]).and(j.dueAt.lte(now)).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
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
- if (job.kind === "timer") await scope.resolve(TimerStep).run(job);
55
- else if (job.kind === "schedule") await scope.resolve(Schedules).fire(job);
56
- else await scope.resolve(ToolStep).run(job, signal);
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
- /** `completed` and `cancelled` are finished: retention deletes them. `failed` waits for retry or cancel. */
4
- export type InstanceStatus = "running" | "completed" | "cancelled" | "failed";
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 (stage 3). */
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
- /** `tool` calls a service task's Tool; `timer`, `retry` and `schedule` come with their stages. */
5
- export type JobKind = "tool" | "timer" | "retry" | "schedule";
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: an event to deliver (stage 4). */
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 or a join, `done`
6
- * moved on, `cancelled` was closed by a cancel, `failed` waits for a retry.
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 (stage 3). */
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
- started.push(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 } }));
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 failed");
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
- this.db.tasks.add(Object.assign(new WfTask(), {
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
- try {
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.nodes[task.nodeId];
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 $now`;
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
- /** The paths of the personal variables: `$input.email`, `$vars.email`. */
18
- export function personalPaths(document: Pick<ProcessDocument, "variables">): ReadonlySet<string> {
19
- return new Set((document.variables ?? []).filter((variable) => variable.personal === true).map((variable) => `${variable.direction === "in" ? "$input" : "$vars"}.${variable.name}`));
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
- for (const [nodeId, node] of Object.entries(document.nodes)) {
47
- const [open, close] = SHAPES[node.kind] ?? ["[", "]"];
48
- const detail = detailOf(node.kind, node.config ?? {});
49
- lines.push(` ${idOf(nodeId)}${open}"${label(textOf(node.title, "en") || nodeId)}${detail === undefined ? "" : `<br/><small>${label(detail)}</small>`}"${close}`);
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":