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.
- package/core/agent/AgentToolExecutor.ts +5 -0
- package/core/agent/Tool.contract.ts +2 -2
- package/core/agent/metadata.ts +11 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate.ts +8 -0
- package/core/tools/StandardTools.module.ts +14 -0
- package/core/tools/generated/deps.ts +10 -0
- package/core/tools/index.ts +8 -0
- package/core/tools/orm/OrmEntity.references.ts +71 -0
- package/core/tools/orm/ormEntityTools.ts +423 -0
- package/core/workflow/contracts/WorkflowError.ts +2 -1
- package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
- package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -2
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
- package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
- package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
- package/core/workflow/definitions_module/http/Processes.controller.ts +21 -5
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +10 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +28 -2
- package/core/workflow/generated/deps.ts +12 -2
- package/core/workflow/index.ts +5 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +9 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
- package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
- package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
- package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
- package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
- package/core/workflow/runtime_module/internal/RunLimits.ts +134 -0
- package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
- package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
- package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +544 -132
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +44 -6
- package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
- package/core/workflow/runtime_module/model/WfJob.model.ts +18 -8
- package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +59 -4
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +72 -42
- package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
- package/library/workflow/ProcessDocument.ts +14 -0
- package/library/workflow/cron.ts +103 -24
- package/library/workflow/expressions.ts +5 -1
- package/library/workflow/index.ts +2 -2
- package/library/workflow/masking.ts +17 -3
- package/library/workflow/mermaid.ts +22 -6
- package/library/workflow/nodeKinds.ts +123 -9
- package/library/workflow/palette.ts +3 -1
- package/library/workflow/paths.ts +10 -4
- package/library/workflow/structure.ts +33 -4
- package/library/workflow/validateProcess.ts +141 -17
- package/package.json +2 -1
package/library/workflow/cron.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
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
|
|
46
|
-
|
|
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 *
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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 $
|
|
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
{
|
|
131
|
-
|
|
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
|
-
|
|
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;
|