bazis 0.133.0 → 0.135.0

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