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.
Files changed (71) hide show
  1. package/cli/templates/process.ts +1 -1
  2. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  3. package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
  4. package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
  5. package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
  6. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
  7. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
  8. package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
  9. package/core/workflow/definitions_module/access/Process.references.ts +1 -1
  10. package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
  11. package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
  12. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +27 -16
  13. package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
  14. package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
  15. package/core/workflow/definitions_module/http/Processes.controller.ts +66 -23
  16. package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
  17. package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
  18. package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
  19. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +35 -10
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +90 -28
  21. package/core/workflow/generated/deps.ts +15 -3
  22. package/core/workflow/index.ts +4 -2
  23. package/core/workflow/internal/WorkflowLanguages.ts +35 -0
  24. package/core/workflow/internal/canonicalJson.ts +8 -0
  25. package/core/workflow/internal/http.ts +20 -1
  26. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -2
  27. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  28. package/core/workflow/runtime_module/background/WorkflowRetention.ts +3 -3
  29. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +6 -6
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +30 -13
  32. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  33. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  34. package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
  35. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  36. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  37. package/core/workflow/runtime_module/internal/ToolStep.ts +43 -12
  38. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +559 -123
  39. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +39 -10
  40. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  41. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  42. package/core/workflow/runtime_module/model/WfInstance.model.ts +34 -6
  43. package/core/workflow/runtime_module/model/WfJob.model.ts +29 -4
  44. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  45. package/core/workflow/runtime_module/model/WfToken.model.ts +15 -3
  46. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +43 -4
  47. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +56 -13
  48. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  49. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
  50. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  51. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  52. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
  53. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -4
  54. package/library/openapi/codegen.ts +3 -0
  55. package/library/validation/types/ValidatorOptions.ts +2 -0
  56. package/library/workflow/ProcessDocument.ts +73 -23
  57. package/library/workflow/expressionSyntax.ts +243 -0
  58. package/library/workflow/expressions.ts +347 -0
  59. package/library/workflow/formFields.ts +118 -33
  60. package/library/workflow/index.ts +48 -9
  61. package/library/workflow/localized.ts +35 -0
  62. package/library/workflow/masking.ts +72 -0
  63. package/library/workflow/mermaid.ts +39 -13
  64. package/library/workflow/nodeKinds.ts +147 -17
  65. package/library/workflow/palette.ts +110 -0
  66. package/library/workflow/paths.ts +37 -6
  67. package/library/workflow/structure.ts +311 -0
  68. package/library/workflow/upgradeDocument.ts +84 -0
  69. package/library/workflow/validateProcess.ts +246 -52
  70. package/package.json +1 -1
  71. 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 `bazis_wf_run_jobs`: the work of the worker, and
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.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))))
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
- if (job.kind === "timer") await scope.resolve(TimerStep).run(job);
57
- else if (job.kind === "schedule") await scope.resolve(Schedules).fire(job);
58
- 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
+ }
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: "bazis_wf_run_" },
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: "bazis_wf_run_history" })
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
- /** `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
10
- * does not touch it. Its data: the input, the variables `saveTo` writes and the
11
- * result of each step by node id.
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
- @Entity({ table: "bazis_wf_run_instances" })
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
- /** `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
- @Entity({ table: "bazis_wf_run_jobs" })
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: "bazis_wf_run_subscriptions" })
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 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
 
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: "bazis_wf_run_tokens" })
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 { FormIssue, FormSpec } from "../../../../library/workflow";
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.input, options.input, { purpose: "input", processKey, partition });
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.input, data, { purpose: "input", processKey: version.key, partition });
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
- started.push(await this.engine.begin(this.engine.cache(version), nodeId, { input: value, partition, businessKey: correlation, startedVia: "event", actor, request: options.request ?? null }));
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 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");
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
- 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. */