bazis 0.132.0 → 0.134.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 (57) hide show
  1. package/core/agent/AgentToolExecutor.ts +5 -0
  2. package/core/agent/Tool.contract.ts +2 -2
  3. package/core/agent/metadata.ts +11 -0
  4. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  5. package/core/scripts/di-generate.ts +8 -0
  6. package/core/tools/StandardTools.module.ts +14 -0
  7. package/core/tools/generated/deps.ts +10 -0
  8. package/core/tools/index.ts +8 -0
  9. package/core/tools/orm/OrmEntity.references.ts +71 -0
  10. package/core/tools/orm/ormEntityTools.ts +423 -0
  11. package/core/workflow/contracts/WorkflowError.ts +2 -1
  12. package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
  13. package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
  14. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -2
  15. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
  16. package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
  17. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
  18. package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
  19. package/core/workflow/definitions_module/http/Processes.controller.ts +21 -5
  20. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +10 -0
  21. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +28 -2
  22. package/core/workflow/generated/deps.ts +12 -2
  23. package/core/workflow/index.ts +5 -0
  24. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +9 -0
  25. package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
  26. package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
  27. package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
  28. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  29. package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
  30. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  31. package/core/workflow/runtime_module/internal/RunLimits.ts +134 -0
  32. package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
  33. package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
  34. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  35. package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
  36. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +544 -132
  37. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +44 -6
  38. package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
  39. package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
  40. package/core/workflow/runtime_module/model/WfJob.model.ts +18 -8
  41. package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
  42. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +59 -4
  43. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +72 -42
  44. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  45. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
  46. package/library/workflow/ProcessDocument.ts +14 -0
  47. package/library/workflow/cron.ts +103 -24
  48. package/library/workflow/expressions.ts +5 -1
  49. package/library/workflow/index.ts +2 -2
  50. package/library/workflow/masking.ts +17 -3
  51. package/library/workflow/mermaid.ts +22 -6
  52. package/library/workflow/nodeKinds.ts +123 -9
  53. package/library/workflow/palette.ts +3 -1
  54. package/library/workflow/paths.ts +10 -4
  55. package/library/workflow/structure.ts +33 -4
  56. package/library/workflow/validateProcess.ts +141 -17
  57. package/package.json +2 -1
@@ -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;
@@ -265,6 +265,8 @@ export interface ExpressionScope {
265
265
  readonly vars?: ReadonlySet<string>;
266
266
  /** Nodes: `$nodes.<node>`. */
267
267
  readonly nodes?: ReadonlySet<string>;
268
+ /** Whether it is read inside a loop body: `$item` and `$index` exist only there. */
269
+ readonly loop?: boolean;
268
270
  }
269
271
 
270
272
  /** What is wrong with an expression, or `null`: its shape, functions, argument types, paths and limits. */
@@ -331,9 +333,11 @@ function literalArgumentProblem(name: ExprFunctionName, args: readonly unknown[]
331
333
 
332
334
  /** What is wrong with a `$` path, or `null`. */
333
335
  export function checkPath(path: unknown, scope: ExpressionScope = {}): string | null {
334
- if (typeof path !== "string" || !DATA_PATH.test(path)) return `path ${JSON.stringify(path)} must look like $input.field, $vars.name, $nodes.Node.field, $event.field, $trigger, $run.id or $now`;
336
+ if (typeof path !== "string" || !DATA_PATH.test(path)) return `path ${JSON.stringify(path)} must look like $input.field, $vars.name, $nodes.Node.field, $event.field, $trigger, $run.id, $now, $item.field or $index`;
335
337
  const [root, first] = path.split(".");
336
338
  if (root === "$now" && first !== undefined) return "$now has no fields";
339
+ if (root === "$index" && first !== undefined) return "$index has no fields";
340
+ if ((root === "$item" || root === "$index") && scope.loop === false) return `path ${path}: ${root} exists only inside a loop body`;
337
341
  if (root === "$nodes" && first === undefined) return "$nodes needs a node: $nodes.Node.field";
338
342
  if (first === undefined) return null;
339
343
  if (root === "$input" && scope.input && !scope.input.has(first)) return `path ${path}: the input has no field ${first}`;
@@ -20,7 +20,7 @@ export {
20
20
  type ProcessNode,
21
21
  type ProcessSettings,
22
22
  } from "./ProcessDocument";
23
- export { NODE_KINDS, nodeKindOf, ISO_DURATION, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
23
+ export { NODE_KINDS, nodeKindOf, ISO_DURATION, MAX_ITERATIONS, MAX_LOOP_PARALLEL, PROCESS_KEY, 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,7 @@ 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 { isTimeZone, parseCron, type CronSchedule } from "./cron";
46
46
  export { processToMermaid } from "./mermaid";
47
47
  export { PERSONAL_MASK, maskByMapping, maskBySchema, masksPersonal, personalPaths, personalVariables, readsPersonal } from "./masking";
48
48
  export { allTexts, checkLocalizedText, textOf, type LocalizedText } from "./localized";
@@ -14,9 +14,23 @@ export function masksPersonal(document: Pick<ProcessDocument, "settings">): bool
14
14
  return document.settings?.log?.maskPersonal !== false;
15
15
  }
16
16
 
17
- /** The paths of the personal variables: `$input.email`, `$vars.email`. */
18
- export function personalPaths(document: Pick<ProcessDocument, "variables">): ReadonlySet<string> {
19
- return new Set((document.variables ?? []).filter((variable) => variable.personal === true).map((variable) => `${variable.direction === "in" ? "$input" : "$vars"}.${variable.name}`));
17
+ /**
18
+ * The paths of the personal variables: `$input.email`, `$vars.email`. A loop over
19
+ * personal data adds its element: `$item` and the name the loop gives it.
20
+ */
21
+ export function personalPaths(document: Pick<ProcessDocument, "variables"> & Partial<Pick<ProcessDocument, "nodes">>): ReadonlySet<string> {
22
+ const paths = new Set((document.variables ?? []).filter((variable) => variable.personal === true).map((variable) => `${variable.direction === "in" ? "$input" : "$vars"}.${variable.name}`));
23
+ const loops = Object.values(document.nodes ?? {}).filter((node) => node.kind === "loop" && node.config?.over !== undefined);
24
+ // A loop over the element of another loop: as many passes as it takes.
25
+ for (let size = -1; size !== paths.size;) {
26
+ size = paths.size;
27
+ for (const { config } of loops) {
28
+ if (!readsPersonal(config!.over, paths)) continue;
29
+ paths.add("$item");
30
+ if (typeof config!.item === "string") paths.add(`$vars.${config!.item}`);
31
+ }
32
+ }
33
+ return paths;
20
34
  }
21
35
 
22
36
  /** The names of the personal `local` and `out` variables. */
@@ -13,6 +13,8 @@ const SHAPES: Record<NodeKind, readonly [open: string, close: string]> = {
13
13
  end: ["((", "))"],
14
14
  userTask: ["[", "]"],
15
15
  serviceTask: ["[[", "]]"],
16
+ callProcess: ["[[", "]]"],
17
+ loop: ["[", "]"],
16
18
  assign: ["[/", "/]"],
17
19
  timerCatch: ["{{", "}}"],
18
20
  eventWait: ["{{", "}}"],
@@ -25,7 +27,7 @@ const SHAPES: Record<NodeKind, readonly [open: string, close: string]> = {
25
27
 
26
28
  /**
27
29
  * A process as a Mermaid `flowchart` (WORKFLOW-001 §14): starts are stadiums,
28
- * Tool steps subroutines, tasks rectangles, waits hexagons, gateways rhombi,
30
+ * Tool steps and called processes subroutines, a loop a subgraph around its body, tasks rectangles, waits hexagons, gateways rhombi,
29
31
  * ends and boundary events circles. A boundary event hangs on its step by a
30
32
  * dotted link. Labels show the node kind's detail: the Tool, the cron, the
31
33
  * duration, the event key; flows show their title or condition.
@@ -43,11 +45,23 @@ export function processToMermaid(document: ProcessDocument, direction: "LR" | "T
43
45
  return id;
44
46
  };
45
47
  const lines = [`flowchart ${direction}`];
46
- for (const [nodeId, node] of Object.entries(document.nodes)) {
47
- const [open, close] = SHAPES[node.kind] ?? ["[", "]"];
48
- const detail = detailOf(node.kind, node.config ?? {});
49
- lines.push(` ${idOf(nodeId)}${open}"${label(textOf(node.title, "en") || nodeId)}${detail === undefined ? "" : `<br/><small>${label(detail)}</small>`}"${close}`);
50
- }
48
+ // A loop is a subgraph around its body; flows and boundaries link to it by its id.
49
+ const write = (parent: string | undefined, indent: string): void => {
50
+ for (const [nodeId, node] of Object.entries(document.nodes)) {
51
+ if (node.parent !== parent) continue;
52
+ const detail = detailOf(node.kind, node.config ?? {});
53
+ const text = `${label(textOf(node.title, "en") || nodeId)}${detail === undefined ? "" : `<br/><small>${label(detail)}</small>`}`;
54
+ if (node.kind === "loop") {
55
+ lines.push(`${indent}subgraph ${idOf(nodeId)}["${text}"]`);
56
+ write(nodeId, `${indent} `);
57
+ lines.push(`${indent}end`);
58
+ continue;
59
+ }
60
+ const [open, close] = SHAPES[node.kind] ?? ["[", "]"];
61
+ lines.push(`${indent}${idOf(nodeId)}${open}"${text}"${close}`);
62
+ }
63
+ };
64
+ write(undefined, " ");
51
65
  for (const [nodeId, node] of Object.entries(document.nodes)) {
52
66
  if (node.attachedTo !== undefined && document.nodes[node.attachedTo] !== undefined) lines.push(` ${idOf(node.attachedTo)} -.- ${idOf(nodeId)}`);
53
67
  }
@@ -63,6 +77,8 @@ function detailOf(kind: NodeKind, config: Readonly<Record<string, unknown>>): st
63
77
  const text = (value: unknown) => (typeof value === "string" && value !== "" ? value : undefined);
64
78
  switch (kind) {
65
79
  case "serviceTask": return text(config.tool);
80
+ case "callProcess": return text(config.process);
81
+ case "loop": return typeof config.times === "number" ? `${config.times} times` : config.while !== undefined ? "while" : "for each";
66
82
  case "scheduleStart": return text(config.cron);
67
83
  case "eventStart":
68
84
  case "eventWait":
@@ -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";
@@ -12,6 +12,8 @@ export type JsonSchema = Readonly<Record<string, unknown>>;
12
12
  export interface NodeKindDefinition {
13
13
  readonly kind: NodeKind;
14
14
  readonly role: NodeRole;
15
+ /** A container: other nodes name it as their `parent`. */
16
+ readonly container?: boolean;
15
17
  /** Checks the node `config`; returns a problem or `null`. */
16
18
  readonly check: (config: Record<string, unknown>) => string | null;
17
19
  /** The node `config` as a JSON Schema: what a designer builds the form of a step from. */
@@ -22,6 +24,12 @@ export interface NodeKindDefinition {
22
24
  export const ISO_DURATION = /^P(?!$)(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
23
25
  /** An event key or a Tool name: `payments.received`, `payments.charge`. */
24
26
  export const DOTTED_KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
27
+ /** A process key. */
28
+ export const PROCESS_KEY = /^[a-z][a-z0-9_.-]{0,63}$/;
29
+ /** The most iterations a loop may have. */
30
+ export const MAX_ITERATIONS = 10_000;
31
+ /** The most iterations of a loop that run at once. */
32
+ export const MAX_LOOP_PARALLEL = 100;
25
33
  /** A parameter of a Tool. */
26
34
  const PARAMETER = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
27
35
  const ROLE = /^[a-z][a-z0-9_.-]{0,63}$/;
@@ -44,11 +52,42 @@ function first(...checks: (string | null)[]): string | null {
44
52
  return checks.find((check) => check !== null) ?? null;
45
53
  }
46
54
 
47
- /** Five cron fields: minute, hour, day of month, month, day of week. */
48
- function cron(config: Record<string, unknown>): string | null {
49
- const value = config.cron;
50
- if (typeof value !== "string") return "config.cron is required";
51
- 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"));
52
91
  }
53
92
 
54
93
  /** A timer: either `duration` (ISO 8601) or `at`, a path to a date in instance data. */
@@ -115,6 +154,29 @@ function assignments(config: Record<string, unknown>): string | null {
115
154
  return null;
116
155
  }
117
156
 
157
+ /** What a loop runs over and how; its expressions are checked with the whole document. */
158
+ function loop(config: Record<string, unknown>): string | null {
159
+ const sources = ["over", "while", "times"].filter((key) => config[key] !== undefined);
160
+ if (sources.length !== 1) return "config needs exactly one of over, while or times";
161
+ const { maxIterations, times, parallel, onItemError: onItem, collect } = config;
162
+ if (!Number.isSafeInteger(maxIterations) || (maxIterations as number) < 1 || (maxIterations as number) > MAX_ITERATIONS) return `config.maxIterations must be 1–${MAX_ITERATIONS}`;
163
+ if (times !== undefined && (!Number.isSafeInteger(times) || (times as number) < 1 || (times as number) > (maxIterations as number))) return "config.times must be an integer from 1 to maxIterations";
164
+ if (parallel !== undefined) {
165
+ if (typeof parallel !== "object" || parallel === null || Array.isArray(parallel)) return "config.parallel must be an object";
166
+ const { limit } = parallel as { limit?: unknown };
167
+ if (!Number.isSafeInteger(limit) || (limit as number) < 1 || (limit as number) > MAX_LOOP_PARALLEL) return `config.parallel.limit must be 1–${MAX_LOOP_PARALLEL}`;
168
+ if (config.while !== undefined) return "config.parallel does not go with while: its condition depends on the iteration before";
169
+ }
170
+ if (onItem !== undefined && onItem !== "stop" && onItem !== "skip") return "config.onItemError must be stop or skip";
171
+ if (collect !== undefined) {
172
+ if (typeof collect !== "object" || collect === null || Array.isArray(collect)) return "config.collect must be an object";
173
+ const { from, to } = collect as { from?: unknown; to?: unknown };
174
+ if (from === undefined) return "config.collect.from is required";
175
+ if (typeof to !== "string" || !FIELD_NAME.test(to)) return "config.collect.to is invalid";
176
+ }
177
+ return first(string(config, "item", FIELD_NAME, false), string(config, "index", FIELD_NAME, false));
178
+ }
179
+
118
180
  const duration = { type: "string", format: "duration" };
119
181
  const text = { type: "string" };
120
182
  const dataPath = { type: "string", pattern: DATA_PATH.source };
@@ -127,8 +189,32 @@ const fields = { type: "object", properties: { fields: { type: "array", items: {
127
189
 
128
190
  export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
129
191
  { kind: "manualStart", role: "start", check: none, config: object({}) },
130
- { kind: "scheduleStart", role: "start", check: cron, config: object({ cron: text }, ["cron"]) },
131
- { 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
+ },
132
218
  {
133
219
  kind: "userTask",
134
220
  role: "activity",
@@ -154,12 +240,40 @@ export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
154
240
  check: assignments,
155
241
  config: object({ assignments: { type: "array", minItems: 1, items: object({ var: { type: "string", pattern: FIELD_NAME.source }, value: expression }, ["var", "value"]) } }, ["assignments"]),
156
242
  },
243
+ {
244
+ kind: "loop",
245
+ role: "activity",
246
+ container: true,
247
+ check: loop,
248
+ config: object({
249
+ over: expression,
250
+ while: expression,
251
+ times: { type: "integer", minimum: 1, maximum: MAX_ITERATIONS },
252
+ item: saveTo,
253
+ index: saveTo,
254
+ maxIterations: { type: "integer", minimum: 1, maximum: MAX_ITERATIONS },
255
+ parallel: object({ limit: { type: "integer", minimum: 1, maximum: MAX_LOOP_PARALLEL } }, ["limit"]),
256
+ onItemError: { enum: ["stop", "skip"] },
257
+ collect: object({ from: expression, to: saveTo }, ["from", "to"]),
258
+ }, ["maxIterations"]),
259
+ },
260
+ {
261
+ kind: "callProcess",
262
+ role: "activity",
263
+ check: (c) => first(string(c, "process", PROCESS_KEY), inputMapping(c), string(c, "saveTo", FIELD_NAME, false), onError(c)),
264
+ config: object({
265
+ process: { type: "string", pattern: PROCESS_KEY.source },
266
+ input: { type: "object", description: "Parameter → a literal, a path string or an expression" },
267
+ saveTo,
268
+ onError: { enum: ["fail", "skip"] },
269
+ }, ["process"]),
270
+ },
157
271
  { kind: "timerCatch", role: "activity", check: timer, config: object(timerConfig) },
158
272
  {
159
273
  kind: "eventWait",
160
274
  role: "activity",
161
275
  check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), string(c, "saveTo", FIELD_NAME, false)),
162
- config: object({ event: text, correlation: dataPath, saveTo }, ["event"]),
276
+ config: object({ event: text, correlation: dataPath, saveTo, until: expression }, ["event"]),
163
277
  },
164
278
  { kind: "exclusiveGateway", role: "gateway", check: none, config: object({}) },
165
279
  { kind: "parallelGateway", role: "gateway", check: none, config: object({}) },
@@ -17,6 +17,8 @@ export interface PaletteTool {
17
17
  export interface PaletteNodeKind {
18
18
  readonly kind: NodeKind;
19
19
  readonly role: NodeRole;
20
+ /** A container: other nodes name it as their `parent`. */
21
+ readonly container?: boolean;
20
22
  readonly config: JsonSchema;
21
23
  }
22
24
 
@@ -31,7 +33,7 @@ export interface ProcessPalette {
31
33
  /** The palette with the application's Tools: a new Tool appears in a designer without a release of the framework. */
32
34
  export function processPalette(tools: readonly PaletteTool[]): ProcessPalette {
33
35
  return {
34
- nodeKinds: NODE_KINDS.map(({ kind, role, config }) => ({ kind, role, config })),
36
+ nodeKinds: NODE_KINDS.map(({ kind, role, container, config }) => ({ kind, role, ...(container === undefined ? {} : { container }), config })),
35
37
  functions: EXPRESSION_FUNCTIONS,
36
38
  paths: DATA_PATHS,
37
39
  tools,
@@ -10,16 +10,20 @@ 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;
17
17
  /** The engine's time as an ISO string. */
18
18
  readonly now?: string;
19
+ /** Inside a loop body: the element of this iteration. */
20
+ readonly item?: unknown;
21
+ /** Inside a loop body: the number of this iteration, from 0. */
22
+ readonly index?: number;
19
23
  }
20
24
 
21
- /** `$input.amount`, `$vars.payment.id`, `$nodes.Pay.paymentId`, `$event.record.id`, `$run.partition`, `$now`. */
22
- export const DATA_PATH = /^\$(input|vars|nodes|event|trigger|run|now)((\.[A-Za-z_][A-Za-z0-9_]*)*)$/;
25
+ /** `$input.amount`, `$vars.payment.id`, `$nodes.Pay.paymentId`, `$event.record.id`, `$run.partition`, `$now`, `$item.id`, `$index`. */
26
+ export const DATA_PATH = /^\$(input|vars|nodes|event|trigger|run|now|item|index)((\.[A-Za-z_][A-Za-z0-9_]*)*)$/;
23
27
 
24
28
  /** The roots of `$` paths, for a designer. */
25
29
  export const DATA_PATHS: readonly { readonly path: string; readonly description: string }[] = Object.freeze([
@@ -27,9 +31,11 @@ export const DATA_PATHS: readonly { readonly path: string; readonly description:
27
31
  { path: "$vars.<name>", description: "A variable of the process" },
28
32
  { path: "$nodes.<node>.<field>", description: "The result of a passed step" },
29
33
  { path: "$event", description: "The data of the event that started or continued the process" },
30
- { 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? }" },
31
35
  { path: "$run", description: "This run: { id, processKey, version, partition, test }" },
32
36
  { path: "$now", description: "The engine's time" },
37
+ { path: "$item", description: "Inside a loop body: the element of this iteration" },
38
+ { path: "$index", description: "Inside a loop body: the number of this iteration, from 0" },
33
39
  ]);
34
40
 
35
41
  /** `true` if the value is a path to instance data. */
@@ -30,8 +30,20 @@ export interface StructureBoundary {
30
30
  readonly exitTitle?: LocalizedText;
31
31
  }
32
32
 
33
- /** A step of the list: a single node, an exclusive fork or a parallel block. */
33
+ /** A step of the list: a single node, a loop around a list, an exclusive fork or a parallel block. */
34
34
  export type StructureStep =
35
+ | {
36
+ readonly type: "loop";
37
+ readonly id: string;
38
+ readonly node: ProcessNode;
39
+ /** The steps of an iteration. */
40
+ readonly body: readonly StructureStep[];
41
+ /** The end of the body. */
42
+ readonly bodyEnd: { readonly id: string; readonly node: ProcessNode };
43
+ readonly boundaries?: readonly StructureBoundary[];
44
+ readonly next: string;
45
+ readonly nextTitle?: LocalizedText;
46
+ }
35
47
  | {
36
48
  readonly type: "step";
37
49
  readonly id: string;
@@ -141,7 +153,8 @@ class Reader {
141
153
  this.visit(current);
142
154
  const [flowId, flow] = this.single(current);
143
155
  const boundaries = (this.boundaries.get(current) ?? []).map(([id, boundary]) => this.boundary(id, boundary, flow.target));
144
- steps.push({ type: "step", id: current, node, ...(boundaries.length === 0 ? {} : { boundaries }), next: flowId, ...(flow.title === undefined ? {} : { nextTitle: flow.title }) });
156
+ const common = { id: current, node, ...(boundaries.length === 0 ? {} : { boundaries }), next: flowId, ...(flow.title === undefined ? {} : { nextTitle: flow.title }) };
157
+ steps.push(node.kind === "loop" ? { type: "loop", ...common, ...this.body(current) } : { type: "step", ...common });
145
158
  exit = { flow: flowId, ...(flow.title === undefined ? {} : { title: flow.title }) };
146
159
  current = flow.target;
147
160
  continue;
@@ -158,6 +171,18 @@ class Reader {
158
171
  }
159
172
  }
160
173
 
174
+ /** The body of a loop: a list from its one entry to its one end. */
175
+ private body(loop: string): { readonly body: StructureStep[]; readonly bodyEnd: { readonly id: string; readonly node: ProcessNode } } {
176
+ const members = Object.entries(this.document.nodes).filter(([, node]) => node.parent === loop && node.attachedTo === undefined);
177
+ const entries = members.filter(([id]) => (this.incoming.get(id)?.length ?? 0) === 0);
178
+ if (entries.length !== 1) throw new NotStructured();
179
+ const { steps, stop } = this.sequence(entries[0]![0]);
180
+ const end = this.node(stop);
181
+ if (nodeKindOf(end.kind)?.role !== "end" || end.parent !== loop || steps.length === 0) throw new NotStructured();
182
+ this.visited.add(stop);
183
+ return { body: steps, bodyEnd: { id: stop, node: end } };
184
+ }
185
+
161
186
  private fork(id: string, node: ProcessNode, outs: readonly (readonly [string, ProcessFlow])[]): { readonly step: Extract<StructureStep, { type: "choice" | "parallel" }>; readonly stop: string } {
162
187
  if ((this.incoming.get(id)?.length ?? 0) !== 1) throw new NotStructured();
163
188
  this.visit(id);
@@ -207,7 +232,7 @@ class Reader {
207
232
  /**
208
233
  * A graph as a tree of steps, or `undefined` when it is outside the subset a
209
234
  * list can draw (WORKFLOW-002 §3.6): sequences, an exclusive gateway with one
210
- * merge, a parallel gateway with a join, boundary events on steps.
235
+ * merge, a parallel gateway with a join, a loop, boundary events on steps.
211
236
  */
212
237
  export function toStructure(document: ProcessDocument): ProcessStructure | undefined {
213
238
  try {
@@ -242,9 +267,13 @@ export function fromStructure(structure: ProcessStructure): ProcessDocument {
242
267
  const target = index + 1 < list.length ? list[index + 1]!.id : after;
243
268
  const last = index + 1 === list.length && leave !== undefined;
244
269
  nodes[step.id] = step.node;
245
- if (step.type === "step") {
270
+ if (step.type === "step" || step.type === "loop") {
246
271
  flow(last ? leave.flow : step.next, step.id, target, titled(last ? leave.title : step.nextTitle));
247
272
  for (const boundary of step.boundaries ?? []) writeBoundary(boundary, step, target);
273
+ if (step.type === "loop") {
274
+ write(step.body, step.bodyEnd.id);
275
+ nodes[step.bodyEnd.id] = step.bodyEnd.node;
276
+ }
248
277
  return;
249
278
  }
250
279
  const meet = step.merge?.id ?? target;