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,16 +1,18 @@
1
1
  import { grantsToWhere } from "../../../access";
2
- import { paginate } from "../../../orm";
3
- import { inputSpecOf, type FormIssue, type FormSpec } from "../../../../library/workflow";
2
+ import { paginate, type OrmTransaction } from "../../../orm";
3
+ import { inputSpecOf, 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
9
  import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
10
+ import type { EventDelivery, EventMessage } from "../internal/EventDelivery";
10
11
  import type { WorkflowEngine } from "../internal/WorkflowEngine";
11
12
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
12
13
  import type { WfHistory } from "../model/WfHistory.model";
13
14
  import type { WfInstance } from "../model/WfInstance.model";
15
+ import { WfJob } from "../model/WfJob.model";
14
16
  import type { WfToken } from "../model/WfToken.model";
15
17
  import {
16
18
  IWorkflowRuntime,
@@ -35,6 +37,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
35
37
  constructor(
36
38
  private readonly db: RuntimeDbContext,
37
39
  private readonly engine: WorkflowEngine,
40
+ private readonly delivery: EventDelivery,
38
41
  private readonly definitions: IWorkflowDefinitions,
39
42
  /** The scope: the input validator is asked here, so that the application's own registration replaces the default. */
40
43
  private readonly services: ServiceProvider,
@@ -52,7 +55,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
52
55
  if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
53
56
  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 });
54
57
  if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
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 });
58
+ 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, ...(options.test === true ? { test: true } : {}) });
56
59
  if (skipped) throw new WorkflowError("conflict", `Instance ${id} with this businessKey is not finished`);
57
60
  return await this.instance(id);
58
61
  }
@@ -135,45 +138,14 @@ export class WorkflowRuntime extends IWorkflowRuntime {
135
138
  }
136
139
 
137
140
  async emit(eventKey: string, payload: unknown = {}, options: EmitOptions = {}): Promise<EmitResult> {
138
- if (typeof eventKey !== "string" || !EVENT_KEY.test(eventKey)) throw new WorkflowError("invalid_input", "eventKey must look like payments.received");
139
- if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new WorkflowError("invalid_input", "payload must be a JSON object");
140
- const data = payload as Record<string, unknown>;
141
- const correlation = optionalText(options.correlation, "correlation");
142
- const partition = optionalText(options.partition, "partition");
143
- const actor = options.actor ?? { kind: "system" as const, id: `event:${eventKey}` };
144
- actorOf(actor);
145
-
146
- let waits = this.db.subscriptions.asNoTracking().where((s) => s.eventKey.eq(eventKey));
147
- waits = correlation === null ? waits.where((s) => s.correlation.isNull()) : waits.where((s) => s.correlation.isNull().or(s.correlation.eq(correlation)));
148
- if (partition !== null) waits = waits.where((s) => s.partition.eq(partition));
149
- const continued = new Set<string>();
150
- for (const wait of await waits.orderBy((s) => s.id).toList()) {
151
- const moved = await this.engine.inStep(wait.instanceId, async (step) => {
152
- await this.db.subscriptions.asNoTracking().where((s) => s.id.eq(wait.id)).executeDelete();
153
- return await this.engine.eventReceived(step, wait.tokenId, eventKey, data);
154
- });
155
- if (moved === true) continued.add(wait.instanceId);
156
- }
141
+ return await this.delivery.emit(messageOf(eventKey, payload, options));
142
+ }
157
143
 
158
- const started: string[] = [];
159
- const skipped: { processKey: string; instanceId: string }[] = [];
160
- const rejected: { processKey: string; issues: readonly FormIssue[] }[] = [];
161
- for (const version of await this.definitions.published()) {
162
- for (const [nodeId, node] of Object.entries(version.document.nodes)) {
163
- if (node.kind !== "eventStart" || node.config?.event !== eventKey) continue;
164
- // An event carries more than a process needs: fields the input does not declare are dropped, not refused.
165
- const { value, issues: all } = await this.validator().validate(inputSpecOf(version.document), data, { purpose: "input", processKey: version.key, partition, language: this.languages.default, actor });
166
- const issues = all.filter((issue) => issue.code !== "unknown");
167
- if (issues.length > 0) {
168
- rejected.push({ processKey: version.key, issues });
169
- continue;
170
- }
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);
174
- }
175
- }
176
- return { started, skipped, continued: [...continued], rejected };
144
+ async emitLater(tx: OrmTransaction, eventKey: string, payload: unknown = {}, options: EmitOptions = {}): Promise<void> {
145
+ const message = messageOf(eventKey, payload, options);
146
+ const now = this.engine.now();
147
+ const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "event" as const, partition: message.partition, dueAt: now, payload: message, createdAt: now });
148
+ await tx.use(this.db, (db) => db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] }));
177
149
  }
178
150
 
179
151
  async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
@@ -188,6 +160,7 @@ export class WorkflowRuntime extends IWorkflowRuntime {
188
160
  if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
189
161
  if (query.startedById !== undefined) source = source.where((i) => i.startedById.eq(query.startedById!));
190
162
  if (query.parentInstanceId !== undefined) source = source.where((i) => i.parentInstanceId.eq(query.parentInstanceId!));
163
+ if (query.test !== undefined || query.parentInstanceId === undefined) source = source.where((i) => i.test.eq(query.test === true));
191
164
  const { items, total } = await paginate(source, pageQueryOf(query, { field: "id", dir: "desc" }));
192
165
  return { items: items.map(summaryOf), total };
193
166
  }
@@ -214,6 +187,23 @@ export class WorkflowRuntime extends IWorkflowRuntime {
214
187
  }
215
188
  }
216
189
 
190
+ /** An event as the delivery takes it; what cannot be delivered is refused here. */
191
+ function messageOf(eventKey: string, payload: unknown, options: EmitOptions): EventMessage {
192
+ if (typeof eventKey !== "string" || !EVENT_KEY.test(eventKey)) throw new WorkflowError("invalid_input", "eventKey must look like payments.received");
193
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new WorkflowError("invalid_input", "payload must be a JSON object");
194
+ const actor = options.actor ?? { kind: "system" as const, id: `event:${eventKey}` };
195
+ actorOf(actor);
196
+ return {
197
+ eventKey,
198
+ payload: payload as Record<string, unknown>,
199
+ correlation: optionalText(options.correlation, "correlation"),
200
+ partition: optionalText(options.partition, "partition"),
201
+ actor,
202
+ request: options.request ?? null,
203
+ cause: options.cause ?? null,
204
+ };
205
+ }
206
+
217
207
  function notFound(instanceId: string): WorkflowError {
218
208
  return new WorkflowError("not_found", `Instance ${instanceId} does not exist`);
219
209
  }
@@ -242,11 +232,20 @@ function summaryOf(row: WfInstance): InstanceSummary {
242
232
  startedBy: { kind: row.startedByKind, id: row.startedById },
243
233
  parentInstanceId: row.parentInstanceId,
244
234
  error: row.error,
235
+ failedNode: row.error === null || row.error.node === "" ? null : row.error.node,
236
+ test: row.test,
245
237
  createdAt: row.createdAt,
246
238
  endedAt: row.endedAt,
239
+ durationMs: durationOf(row),
247
240
  };
248
241
  }
249
242
 
243
+ /** From the start by the engine's clock: `createdAt` is the clock of the database. */
244
+ function durationOf(row: WfInstance): number | null {
245
+ const startedAt = typeof row.trigger?.at === "string" ? Date.parse(row.trigger.at) : Number.NaN;
246
+ return row.endedAt === null || Number.isNaN(startedAt) ? null : row.endedAt.getTime() - startedAt;
247
+ }
248
+
250
249
  function tokenOf(row: WfToken) {
251
250
  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 };
252
251
  }
@@ -1,5 +1,5 @@
1
1
  import { OnEvent } from "../../../kernel";
2
- import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
2
+ import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, WORKFLOW_TRIAL_TASK_REQUESTED, WORKFLOW_TRIAL_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
3
3
  import type { TasksDbContext } from "../model/Tasks.dbContext";
4
4
  import { WfTask } from "../model/WfTask.model";
5
5
 
@@ -10,6 +10,16 @@ export class TaskEvents {
10
10
  /** A step started waiting for a person: its task. Repeated events of one token make one task. */
11
11
  @OnEvent(WORKFLOW_TASK_REQUESTED)
12
12
  async requested(event: WorkflowTaskRequested): Promise<void> {
13
+ await this.add(event, false);
14
+ }
15
+
16
+ /** The task of a trial run: it goes to who started it, and is marked. */
17
+ @OnEvent(WORKFLOW_TRIAL_TASK_REQUESTED)
18
+ async trialRequested(event: WorkflowTaskRequested): Promise<void> {
19
+ await this.add(event, true);
20
+ }
21
+
22
+ private async add(event: WorkflowTaskRequested, test: boolean): Promise<void> {
13
23
  const assignee = "role" in event.assignee ? `role:${event.assignee.role}` : `user:${event.assignee.user}`;
14
24
  // Past the change tracker: the scope may go on to update tasks directly, which a context holding one would refuse.
15
25
  await this.db.tasks.insertIfAbsent(Object.assign(new WfTask(), {
@@ -18,6 +28,7 @@ export class TaskEvents {
18
28
  processKey: event.processKey,
19
29
  nodeId: event.nodeId,
20
30
  partition: event.partition,
31
+ test,
21
32
  title: event.title,
22
33
  assignee,
23
34
  form: event.form,
@@ -32,6 +43,11 @@ export class TaskEvents {
32
43
  await this.db.tasks.asNoTracking().where((t) => t.tokenId.eq(event.tokenId).and(t.status.in(["open", "claimed"]))).executeUpdate({ status: "cancelled" });
33
44
  }
34
45
 
46
+ @OnEvent(WORKFLOW_TRIAL_TOKEN_CLOSED)
47
+ async trialClosed(event: WorkflowTokenClosed): Promise<void> {
48
+ await this.closed(event);
49
+ }
50
+
35
51
  /** Retention deleted the instances: their tasks go too. */
36
52
  @OnEvent(WORKFLOW_INSTANCES_DELETED)
37
53
  async deleted(event: WorkflowInstancesDeleted): Promise<void> {
@@ -11,6 +11,8 @@ export interface TaskDetails {
11
11
  readonly nodeId: string;
12
12
  readonly partition: string | null;
13
13
  readonly title: LocalizedText;
14
+ /** A task of a trial run. */
15
+ readonly test: boolean;
14
16
  readonly assignee: string;
15
17
  readonly form: FormSpec | null;
16
18
  readonly dueAt: Date | null;
@@ -100,7 +100,7 @@ export class WorkflowTasks extends IWorkflowTasks {
100
100
  completedAt: this.clock.now(),
101
101
  output: typeof saved === "object" && saved !== null && !Array.isArray(saved) ? saved as Record<string, unknown> : null,
102
102
  });
103
- await this.events.publish(WORKFLOW_TASK_COMPLETED, { taskId: task.id, instanceId: task.instanceId, processKey: task.processKey, nodeId: task.nodeId, partition: task.partition, actor, request: options.request ?? null });
103
+ if (!task.test) await this.events.publish(WORKFLOW_TASK_COMPLETED, { taskId: task.id, instanceId: task.instanceId, processKey: task.processKey, nodeId: task.nodeId, partition: task.partition, actor, request: options.request ?? null });
104
104
  return await this.get(task.id);
105
105
  }
106
106
 
@@ -157,6 +157,7 @@ function detailsOf(row: WfTask): TaskDetails {
157
157
  nodeId: row.nodeId,
158
158
  partition: row.partition,
159
159
  title: row.title,
160
+ test: row.test,
160
161
  assignee: row.assignee,
161
162
  form: row.form,
162
163
  dueAt: row.dueAt,
@@ -1,4 +1,4 @@
1
- /** A parsed five-field cron expression, evaluated in UTC. */
1
+ /** A parsed five-field cron expression, evaluated in UTC or in a time zone. */
2
2
  export interface CronSchedule {
3
3
  /** Whether the expression fires at this minute. */
4
4
  matches(date: Date): boolean;
@@ -15,14 +15,53 @@ const FIELDS = [
15
15
  ] as const;
16
16
 
17
17
  const MINUTE = 60_000;
18
+ const DAY_MINUTES = 24 * 60;
19
+ /** Clock changes are never longer than this: a day is skipped up to here, the rest hour by hour. */
20
+ const CLOCK_CHANGE_MINUTES = 3 * 60;
21
+
22
+ const formats = new Map<string, Intl.DateTimeFormat>();
23
+
24
+ function formatOf(timezone: string): Intl.DateTimeFormat {
25
+ let format = formats.get(timezone);
26
+ if (format === undefined) {
27
+ format = new Intl.DateTimeFormat("en-US", { timeZone: timezone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });
28
+ formats.set(timezone, format);
29
+ }
30
+ return format;
31
+ }
32
+
33
+ /** Whether this is an IANA time zone: `Europe/Moscow`, `UTC`. */
34
+ export function isTimeZone(value: unknown): value is string {
35
+ if (typeof value !== "string" || value === "") return false;
36
+ try {
37
+ formatOf(value);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /** How far the clocks of the zone are ahead of UTC at this moment, in milliseconds. */
45
+ function offsetOf(timezone: string | undefined, time: number): number {
46
+ if (timezone === undefined) return 0;
47
+ const parts: Record<string, number> = {};
48
+ for (const { type, value } of formatOf(timezone).formatToParts(time)) parts[type] = Number(value);
49
+ return Date.UTC(parts.year!, parts.month! - 1, parts.day!, parts.hour!, parts.minute!, parts.second!) - Math.floor(time / 1000) * 1000;
50
+ }
18
51
 
19
52
  /**
20
53
  * Parses `minute hour day-of-month month day-of-week` with `*`, numbers,
21
54
  * ranges `a-b`, steps `/n` and lists `a,b`. Day of week 0 and 7 are Sunday.
22
55
  * When both day fields are restricted, either one matching is enough, as in
23
- * classic cron. Returns `undefined` for an invalid expression.
56
+ * classic cron. Returns `undefined` for an invalid expression or time zone.
57
+ *
58
+ * With a `timezone` the fields are read on its clocks. A schedule with a set
59
+ * hour fires once a day whatever the clocks do: a time skipped when they go
60
+ * forward fires right after the change, a time repeated when they go back fires
61
+ * the first time only. A schedule with `*` in the hour simply follows the clocks.
24
62
  */
25
- export function parseCron(expression: string): CronSchedule | undefined {
63
+ export function parseCron(expression: string, timezone?: string): CronSchedule | undefined {
64
+ if (timezone !== undefined && !isTimeZone(timezone)) return undefined;
26
65
  const parts = expression.trim().split(/\s+/);
27
66
  if (parts.length !== 5) return undefined;
28
67
  const sets: Set<number>[] = [];
@@ -35,34 +74,74 @@ export function parseCron(expression: string): CronSchedule | undefined {
35
74
  if (weekdays.has(7)) weekdays.add(0);
36
75
  const daysRestricted = parts[2] !== "*";
37
76
  const weekdaysRestricted = parts[4] !== "*";
77
+ const hourSet = parts[1] !== "*";
38
78
 
39
- const dayMatches = (date: Date): boolean => {
40
- const day = days.has(date.getUTCDate());
41
- const weekday = weekdays.has(date.getUTCDay());
42
- return daysRestricted && weekdaysRestricted ? day || weekday : day && weekday;
79
+ /** The date part of a time on the clocks of the zone, kept in the UTC fields of a `Date`. */
80
+ const dayMatches = (clock: Date): boolean => {
81
+ const day = days.has(clock.getUTCDate());
82
+ const weekday = weekdays.has(clock.getUTCDay());
83
+ return months.has(clock.getUTCMonth() + 1) && (daysRestricted && weekdaysRestricted ? day || weekday : day && weekday);
43
84
  };
44
85
 
45
- const matches = (date: Date): boolean =>
46
- minutes.has(date.getUTCMinutes()) && hours.has(date.getUTCHours()) && months.has(date.getUTCMonth() + 1) && dayMatches(date);
86
+ const clockMatches = (clock: Date): boolean => minutes.has(clock.getUTCMinutes()) && hours.has(clock.getUTCHours()) && dayMatches(clock);
87
+
88
+ /** Whether the clocks show this time for the second time: they went back a moment ago. */
89
+ const repeated = (time: number, offset: number): boolean => {
90
+ const before = offsetOf(timezone, time - CLOCK_CHANGE_MINUTES * MINUTE);
91
+ if (before <= offset) return false;
92
+ const first = time - (before - offset);
93
+ return offsetOf(timezone, first) === before;
94
+ };
95
+
96
+ /** When the clocks jumped forward between two moments, if a firing fell into the skipped time. */
97
+ const skippedFiring = (from: number, to: number): number | undefined => {
98
+ const before = offsetOf(timezone, from);
99
+ const after = offsetOf(timezone, to);
100
+ if (after <= before) return undefined;
101
+ let low = from;
102
+ let high = to;
103
+ while (high - low > MINUTE) {
104
+ const middle = Math.floor((low + high) / 2 / MINUTE) * MINUTE;
105
+ if (offsetOf(timezone, middle) === before) low = middle;
106
+ else high = middle;
107
+ }
108
+ for (let clock = high + before; clock < high + after; clock += MINUTE) {
109
+ if (clockMatches(new Date(clock))) return high;
110
+ }
111
+ return undefined;
112
+ };
47
113
 
48
114
  return {
49
- matches,
115
+ matches(date: Date): boolean {
116
+ const offset = offsetOf(timezone, date.getTime());
117
+ if (!clockMatches(new Date(date.getTime() + offset))) return false;
118
+ return !(hourSet && timezone !== undefined && repeated(Math.floor(date.getTime() / MINUTE) * MINUTE, offset));
119
+ },
50
120
  next(after: Date): Date | undefined {
51
- const limit = after.getTime() + 5 * 366 * 24 * 60 * MINUTE;
52
- const date = new Date(Math.floor(after.getTime() / MINUTE) * MINUTE + MINUTE);
53
- while (date.getTime() <= limit) {
54
- if (!months.has(date.getUTCMonth() + 1)) {
55
- date.setUTCMonth(date.getUTCMonth() + 1, 1);
56
- date.setUTCHours(0, 0, 0, 0);
57
- } else if (!dayMatches(date)) {
58
- date.setUTCDate(date.getUTCDate() + 1);
59
- date.setUTCHours(0, 0, 0, 0);
60
- } else if (!hours.has(date.getUTCHours())) {
61
- date.setUTCHours(date.getUTCHours() + 1, 0, 0, 0);
62
- } else if (!minutes.has(date.getUTCMinutes())) {
63
- date.setTime(date.getTime() + MINUTE);
121
+ const limit = after.getTime() + 5 * 366 * DAY_MINUTES * MINUTE;
122
+ let time = Math.floor(after.getTime() / MINUTE) * MINUTE + MINUTE;
123
+ let previous = time - MINUTE;
124
+ while (time <= limit) {
125
+ if (hourSet && timezone !== undefined) {
126
+ const skipped = skippedFiring(previous, time);
127
+ if (skipped !== undefined) return new Date(skipped);
128
+ }
129
+ previous = time;
130
+ const offset = offsetOf(timezone, time);
131
+ const clock = new Date(time + offset);
132
+ const minutesOfDay = clock.getUTCHours() * 60 + clock.getUTCMinutes();
133
+ if (!dayMatches(clock)) {
134
+ // Close to midnight hour by hour: the day may be shorter than its clocks say.
135
+ const left = DAY_MINUTES - minutesOfDay;
136
+ time += (timezone === undefined ? left : left > CLOCK_CHANGE_MINUTES ? left - CLOCK_CHANGE_MINUTES : 60 - clock.getUTCMinutes()) * MINUTE;
137
+ } else if (!hours.has(clock.getUTCHours())) {
138
+ time += (60 - clock.getUTCMinutes()) * MINUTE;
139
+ } else if (!minutes.has(clock.getUTCMinutes())) {
140
+ time += MINUTE;
141
+ } else if (hourSet && timezone !== undefined && repeated(time, offset)) {
142
+ time += MINUTE;
64
143
  } else {
65
- return date;
144
+ return new Date(time);
66
145
  }
67
146
  }
68
147
  return undefined;
@@ -20,7 +20,7 @@ export {
20
20
  type ProcessNode,
21
21
  type ProcessSettings,
22
22
  } from "./ProcessDocument";
23
- export { NODE_KINDS, nodeKindOf, ISO_DURATION, MAX_ITERATIONS, MAX_LOOP_PARALLEL, PROCESS_KEY, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
23
+ export { NODE_KINDS, nodeKindOf, ISO_DURATION, MAX_ITERATIONS, MAX_LOOP_PARALLEL, PROCESS_KEY, ISO_MOMENT, 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 {
@@ -42,7 +42,8 @@ export {
42
42
  } from "./expressions";
43
43
  export { ExpressionSyntaxError, parseExpression, printExpression } from "./expressionSyntax";
44
44
  export { durationMs } from "./duration";
45
- export { parseCron, type CronSchedule } from "./cron";
45
+ export { documentReferences, inputReferences, schemaReferenceKinds, type DocumentReference, type InputReference } from "./references";
46
+ export { isTimeZone, parseCron, type CronSchedule } from "./cron";
46
47
  export { processToMermaid } from "./mermaid";
47
48
  export { PERSONAL_MASK, maskByMapping, maskBySchema, masksPersonal, personalPaths, personalVariables, readsPersonal } from "./masking";
48
49
  export { allTexts, checkLocalizedText, textOf, type LocalizedText } from "./localized";
@@ -1,4 +1,4 @@
1
- import { parseCron } from "./cron";
1
+ import { isTimeZone, parseCron } from "./cron";
2
2
  import { FIELD_NAME, checkFormSpec } from "./formFields";
3
3
  import { DATA_PATH } from "./paths";
4
4
  import type { NodeKind } from "./ProcessDocument";
@@ -52,11 +52,42 @@ function first(...checks: (string | null)[]): string | null {
52
52
  return checks.find((check) => check !== null) ?? null;
53
53
  }
54
54
 
55
- /** Five cron fields: minute, hour, day of month, month, day of week. */
56
- function cron(config: Record<string, unknown>): string | null {
57
- const value = config.cron;
58
- if (typeof value !== "string") return "config.cron is required";
59
- return parseCron(value) === undefined ? "config.cron must be five valid fields: minute hour day month weekday (UTC)" : null;
55
+ /** A moment with its zone: `2026-11-01T09:00:00+03:00`. */
56
+ export const ISO_MOMENT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
57
+
58
+ function moment(config: Record<string, unknown>, key: string): string | null {
59
+ const value = config[key];
60
+ if (value === undefined) return null;
61
+ return typeof value === "string" && ISO_MOMENT.test(value) && !Number.isNaN(Date.parse(value)) ? null : `config.${key} must be a moment with its zone: 2026-11-01T09:00:00+03:00`;
62
+ }
63
+
64
+ /** A scheduled start: five cron fields (minute, hour, day of month, month, day of week), where and when it runs. */
65
+ function schedule(config: Record<string, unknown>): string | null {
66
+ const { cron, timezone, from, until, overlap, partitions } = config;
67
+ if (typeof cron !== "string") return "config.cron is required";
68
+ if (parseCron(cron) === undefined) return "config.cron must be five valid fields: minute hour day month weekday";
69
+ if (timezone !== undefined && !isTimeZone(timezone)) return "config.timezone must be an IANA time zone: Europe/Moscow";
70
+ const period = first(moment(config, "from"), moment(config, "until"), boolean(config, "catchUp"));
71
+ if (period !== null) return period;
72
+ if (typeof from === "string" && typeof until === "string" && Date.parse(from) >= Date.parse(until)) return "config.from must be before config.until";
73
+ if (overlap !== undefined && overlap !== "skip" && overlap !== "queue" && overlap !== "parallel") return "config.overlap must be skip, queue or parallel";
74
+ if (partitions !== undefined && partitions !== "none" && partitions !== "all") {
75
+ if (!Array.isArray(partitions) || partitions.length === 0 || partitions.length > 1000 || !partitions.every((item) => typeof item === "string" && item !== "" && item.length <= 200)) return "config.partitions must be none, all or a list of partitions";
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /** An event start; its expressions are checked with the whole document. */
81
+ function eventStart(config: Record<string, unknown>): string | null {
82
+ const { debounce } = config;
83
+ if (debounce !== undefined) {
84
+ if (typeof debounce !== "object" || debounce === null || Array.isArray(debounce)) return "config.debounce must be an object";
85
+ const { key } = debounce as { key?: unknown };
86
+ if (key === undefined) return "config.debounce.key is required";
87
+ const problem = string(debounce as Record<string, unknown>, "window", ISO_DURATION);
88
+ if (problem !== null) return problem.replace("config.", "config.debounce.");
89
+ }
90
+ return first(string(config, "event", DOTTED_KEY), string(config, "delay", ISO_DURATION, false), boolean(config, "ignoreOwn"));
60
91
  }
61
92
 
62
93
  /** A timer: either `duration` (ISO 8601) or `at`, a path to a date in instance data. */
@@ -158,8 +189,32 @@ const fields = { type: "object", properties: { fields: { type: "array", items: {
158
189
 
159
190
  export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
160
191
  { kind: "manualStart", role: "start", check: none, config: object({}) },
161
- { kind: "scheduleStart", role: "start", check: cron, config: object({ cron: text }, ["cron"]) },
162
- { kind: "eventStart", role: "start", check: (c) => string(c, "event", DOTTED_KEY), config: object({ event: text }, ["event"]) },
192
+ {
193
+ kind: "scheduleStart",
194
+ role: "start",
195
+ check: schedule,
196
+ config: object({
197
+ cron: text,
198
+ timezone: { type: "string", description: "An IANA time zone; UTC by default" },
199
+ from: { type: "string", format: "date-time" },
200
+ until: { type: "string", format: "date-time" },
201
+ overlap: { enum: ["skip", "queue", "parallel"] },
202
+ catchUp: flag,
203
+ partitions: { description: "none, all or a list of partitions", oneOf: [{ enum: ["none", "all"] }, { type: "array", items: text }] },
204
+ }, ["cron"]),
205
+ },
206
+ {
207
+ kind: "eventStart",
208
+ role: "start",
209
+ check: eventStart,
210
+ config: object({
211
+ event: text,
212
+ filter: expression,
213
+ debounce: object({ key: expression, window: duration }, ["key", "window"]),
214
+ delay: duration,
215
+ ignoreOwn: flag,
216
+ }, ["event"]),
217
+ },
163
218
  {
164
219
  kind: "userTask",
165
220
  role: "activity",
@@ -218,7 +273,7 @@ export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
218
273
  kind: "eventWait",
219
274
  role: "activity",
220
275
  check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), string(c, "saveTo", FIELD_NAME, false)),
221
- config: object({ event: text, correlation: dataPath, saveTo }, ["event"]),
276
+ config: object({ event: text, correlation: dataPath, saveTo, until: expression }, ["event"]),
222
277
  },
223
278
  { kind: "exclusiveGateway", role: "gateway", check: none, config: object({}) },
224
279
  { kind: "parallelGateway", role: "gateway", check: none, config: object({}) },
@@ -10,7 +10,7 @@ export interface InstanceData {
10
10
  readonly nodes: Readonly<Record<string, unknown>>;
11
11
  /** The data of the event that started or continued the process. */
12
12
  readonly event?: unknown;
13
- /** How it was started: `{ via, at, actor, eventKey?, cause? }`. */
13
+ /** How it was started: `{ via, at, actor, eventKey?, cause?, scheduledAt? }`. */
14
14
  readonly trigger?: unknown;
15
15
  /** `{ id, processKey, version, partition, test }`. */
16
16
  readonly run?: unknown;
@@ -31,7 +31,7 @@ export const DATA_PATHS: readonly { readonly path: string; readonly description:
31
31
  { path: "$vars.<name>", description: "A variable of the process" },
32
32
  { path: "$nodes.<node>.<field>", description: "The result of a passed step" },
33
33
  { path: "$event", description: "The data of the event that started or continued the process" },
34
- { path: "$trigger", description: "How it was started: { via, at, actor, eventKey?, cause? }" },
34
+ { path: "$trigger", description: "How it was started: { via, at, actor, eventKey?, cause?, scheduledAt? }" },
35
35
  { path: "$run", description: "This run: { id, processKey, version, partition, test }" },
36
36
  { path: "$now", description: "The engine's time" },
37
37
  { path: "$item", description: "Inside a loop body: the element of this iteration" },
@@ -0,0 +1,106 @@
1
+ import { isExpressionNode } from "./expressions";
2
+ import { isPath } from "./paths";
3
+ import type { ProcessDocument } from "./ProcessDocument";
4
+
5
+ /** One reference in a Tool input or a document: what `x-ref` and `x-ref-keys` of a schema mark. */
6
+ export interface InputReference {
7
+ readonly kind: string;
8
+ readonly id: string;
9
+ /** The area it is looked for in: `x-ref-scope`. */
10
+ readonly within?: string;
11
+ /** Where in the input: `/values/status`. */
12
+ readonly path: string;
13
+ }
14
+
15
+ /** A reference of a document, and whose it is. */
16
+ export interface DocumentReference extends InputReference {
17
+ readonly node?: string;
18
+ readonly variable?: string;
19
+ }
20
+
21
+ type Json = Record<string, unknown>;
22
+
23
+ function isObject(value: unknown): value is Json {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+
27
+ /** Whether a value is known only when the step runs. */
28
+ function isComputed(value: unknown): boolean {
29
+ return isPath(value) || isExpressionNode(value);
30
+ }
31
+
32
+ function isId(value: unknown): value is string | number {
33
+ return (typeof value === "string" && value !== "") || typeof value === "number";
34
+ }
35
+
36
+ /**
37
+ * The references in a Tool input by the JSON Schema of the input. A value computed
38
+ * when the step runs is left out: it is checked then. `x-ref-scope` beginning
39
+ * with `/` is a JSON Pointer to another field of the input; otherwise it is the area
40
+ * itself. A reference whose area is computed is left out too.
41
+ */
42
+ export function inputReferences(schema: unknown, input: unknown): InputReference[] {
43
+ const out: InputReference[] = [];
44
+ const within = (source: Json): { readonly known: boolean; readonly value?: string } => {
45
+ const scope = source["x-ref-scope"];
46
+ if (typeof scope !== "string") return { known: true };
47
+ if (!scope.startsWith("/")) return { known: true, value: scope };
48
+ let current: unknown = input;
49
+ for (const key of scope.slice(1).split("/")) {
50
+ current = isObject(current) && Object.hasOwn(current, key) ? current[key] : undefined;
51
+ }
52
+ return isId(current) && !isComputed(current) ? { known: true, value: String(current) } : { known: false };
53
+ };
54
+ const walk = (source: unknown, value: unknown, path: string): void => {
55
+ if (!isObject(source) || value === undefined || value === null || isComputed(value)) return;
56
+ const area = within(source);
57
+ const add = (kind: string, id: string, at: string) => out.push({ kind, id, ...(area.value === undefined ? {} : { within: area.value }), path: at });
58
+ if (typeof source["x-ref"] === "string" && isId(value) && area.known) add(source["x-ref"], String(value), path);
59
+ if (Array.isArray(value)) {
60
+ value.forEach((item, index) => walk(source.items, item, `${path}/${index}`));
61
+ return;
62
+ }
63
+ if (!isObject(value)) return;
64
+ if (typeof source["x-ref-keys"] === "string" && area.known) for (const key of Object.keys(value)) add(source["x-ref-keys"], key, `${path}/${key}`);
65
+ if (isObject(source.properties)) {
66
+ for (const [key, item] of Object.entries(source.properties)) if (Object.hasOwn(value, key)) walk(item, value[key], `${path}/${key}`);
67
+ }
68
+ };
69
+ walk(schema, input, "");
70
+ return out;
71
+ }
72
+
73
+ /** The kinds a schema refers to, whatever the values: each must have an owner. */
74
+ export function schemaReferenceKinds(schema: unknown): string[] {
75
+ const kinds = new Set<string>();
76
+ const walk = (source: unknown): void => {
77
+ if (!isObject(source)) return;
78
+ for (const key of ["x-ref", "x-ref-keys"]) if (typeof source[key] === "string") kinds.add(source[key]);
79
+ if (isObject(source.properties)) Object.values(source.properties).forEach(walk);
80
+ walk(source.items);
81
+ };
82
+ walk(schema);
83
+ return [...kinds];
84
+ }
85
+
86
+ /**
87
+ * The references a document holds as literals (WORKFLOW-002 §6.3): in the inputs of
88
+ * its Tools, by their schemas; the defaults of variables of type `ref`; the processes
89
+ * it calls. `inputSchemaOf` gives the input schema of a Tool, if it is known.
90
+ */
91
+ export function documentReferences(document: ProcessDocument, inputSchemaOf: (tool: string) => unknown): DocumentReference[] {
92
+ const out: DocumentReference[] = [];
93
+ for (const variable of document.variables ?? []) {
94
+ if (variable.type === "ref" && typeof variable.ref === "string" && isId(variable.default)) {
95
+ out.push({ kind: variable.ref, id: String(variable.default), path: "/default", variable: variable.name });
96
+ }
97
+ }
98
+ const handler = document.settings?.errorHandler;
99
+ if (handler !== undefined) out.push({ kind: "workflow.process", id: handler, path: "/settings/errorHandler" });
100
+ for (const [node, { kind, config }] of Object.entries(document.nodes)) {
101
+ if (kind === "callProcess" && typeof config?.process === "string") out.push({ kind: "workflow.process", id: config.process, path: "/process", node });
102
+ if (kind !== "serviceTask" || typeof config?.tool !== "string") continue;
103
+ for (const reference of inputReferences(inputSchemaOf(config.tool), config.input)) out.push({ ...reference, node });
104
+ }
105
+ return out;
106
+ }
@@ -33,6 +33,7 @@ export type ProcessIssueCode =
33
33
  | "config_invalid"
34
34
  | "unknown_tool"
35
35
  | "unknown_process"
36
+ | "broken_ref"
36
37
  | "container_invalid"
37
38
  | "flow_across_container"
38
39
  | "loop_body"
@@ -451,6 +452,18 @@ function configExpressionProblems(kind: string, config: Json, scope: ExpressionS
451
452
  const problem = checkExpression(config.over, scope);
452
453
  if (problem) out.push(`config.over: ${problem}`);
453
454
  }
455
+ if (kind === "eventWait" && config.until !== undefined) {
456
+ const problem = checkExpression(config.until, scope);
457
+ if (problem) out.push(`config.until: ${problem}`);
458
+ }
459
+ if (kind === "eventStart") {
460
+ // Before an instance exists there is only the event: nothing to read in the input, variables and steps.
461
+ const before: ExpressionScope = { input: new Set(), vars: new Set(), nodes: new Set(), loop: false };
462
+ const filter = config.filter === undefined ? null : checkExpression(config.filter, before);
463
+ if (filter) out.push(`config.filter: ${filter}`);
464
+ const debounceKey = isObject(config.debounce) ? checkExpression(config.debounce.key, before) : null;
465
+ if (debounceKey) out.push(`config.debounce.key: ${debounceKey}`);
466
+ }
454
467
  for (const key of ["at", "correlation"]) {
455
468
  if (typeof config[key] === "string") {
456
469
  const problem = checkPath(config[key], scope);