bazis 0.131.0 → 0.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/cli/templates/process.ts +1 -1
  2. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  3. package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
  4. package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
  5. package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
  6. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
  7. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
  8. package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
  9. package/core/workflow/definitions_module/access/Process.references.ts +1 -1
  10. package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
  11. package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
  12. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +27 -16
  13. package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
  14. package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
  15. package/core/workflow/definitions_module/http/Processes.controller.ts +66 -23
  16. package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
  17. package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
  18. package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
  19. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +35 -10
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +90 -28
  21. package/core/workflow/generated/deps.ts +15 -3
  22. package/core/workflow/index.ts +4 -2
  23. package/core/workflow/internal/WorkflowLanguages.ts +35 -0
  24. package/core/workflow/internal/canonicalJson.ts +8 -0
  25. package/core/workflow/internal/http.ts +20 -1
  26. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -2
  27. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  28. package/core/workflow/runtime_module/background/WorkflowRetention.ts +3 -3
  29. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +6 -6
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +30 -13
  32. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  33. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  34. package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
  35. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  36. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  37. package/core/workflow/runtime_module/internal/ToolStep.ts +43 -12
  38. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +559 -123
  39. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +39 -10
  40. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  41. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  42. package/core/workflow/runtime_module/model/WfInstance.model.ts +34 -6
  43. package/core/workflow/runtime_module/model/WfJob.model.ts +29 -4
  44. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  45. package/core/workflow/runtime_module/model/WfToken.model.ts +15 -3
  46. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +43 -4
  47. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +56 -13
  48. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  49. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
  50. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  51. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  52. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
  53. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -4
  54. package/library/openapi/codegen.ts +3 -0
  55. package/library/validation/types/ValidatorOptions.ts +2 -0
  56. package/library/workflow/ProcessDocument.ts +73 -23
  57. package/library/workflow/expressionSyntax.ts +243 -0
  58. package/library/workflow/expressions.ts +347 -0
  59. package/library/workflow/formFields.ts +118 -33
  60. package/library/workflow/index.ts +48 -9
  61. package/library/workflow/localized.ts +35 -0
  62. package/library/workflow/masking.ts +72 -0
  63. package/library/workflow/mermaid.ts +39 -13
  64. package/library/workflow/nodeKinds.ts +147 -17
  65. package/library/workflow/palette.ts +110 -0
  66. package/library/workflow/paths.ts +37 -6
  67. package/library/workflow/structure.ts +311 -0
  68. package/library/workflow/upgradeDocument.ts +84 -0
  69. package/library/workflow/validateProcess.ts +246 -52
  70. package/package.json +1 -1
  71. package/library/workflow/conditions.ts +0 -44
@@ -0,0 +1,35 @@
1
+ /** A text in several languages: language → text. A plain string is in the default language. */
2
+ export type LocalizedText = string | Readonly<Record<string, string>>;
3
+
4
+ const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
5
+
6
+ /** The text for `language`: its exact entry, its base language (`ru` for `ru-RU`), then `fallback`, then the first one. */
7
+ export function textOf(text: LocalizedText | null | undefined, language: string, fallback = language): string {
8
+ if (text === undefined || text === null) return "";
9
+ if (typeof text === "string") return text;
10
+ const own = (key: string) => (Object.hasOwn(text, key) ? text[key] : undefined);
11
+ return own(language) ?? own(language.split("-")[0]!) ?? own(fallback) ?? Object.values(text)[0] ?? "";
12
+ }
13
+
14
+ /** Every text of a value, the default language first: what a search looks through and a list sorts by. */
15
+ export function allTexts(text: LocalizedText | null | undefined, language: string): string {
16
+ if (text === undefined || text === null) return "";
17
+ if (typeof text === "string") return text;
18
+ const first = textOf(text, language);
19
+ return [first, ...Object.values(text).filter((item) => item !== first)].join("\n");
20
+ }
21
+
22
+ /** What is wrong with a value as a localized text, or `null`. `min` — 1 for a required text. */
23
+ export function checkLocalizedText(value: unknown, max: number, min = 1): string | null {
24
+ const fits = (text: unknown) => typeof text === "string" && text.trim().length >= min && text.length <= max;
25
+ const size = min === 0 ? `up to ${max}` : `${min}–${max}`;
26
+ if (typeof value === "string") return fits(value) ? null : `must be ${size} characters`;
27
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return "must be a string or an object of language → text";
28
+ const entries = Object.entries(value);
29
+ if (entries.length === 0) return "needs at least one language";
30
+ for (const [language, text] of entries) {
31
+ if (!LANGUAGE.test(language)) return `has an invalid language ${JSON.stringify(language)}`;
32
+ if (!fits(text)) return `${language} must be ${size} characters`;
33
+ }
34
+ return null;
35
+ }
@@ -0,0 +1,72 @@
1
+ import { isExpressionNode } from "./expressions";
2
+ import { isPath } from "./paths";
3
+ import type { ProcessDocument } from "./ProcessDocument";
4
+
5
+ /** What a personal value shows as in the history. */
6
+ export const PERSONAL_MASK = "***";
7
+
8
+ function isObject(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+
12
+ /** Whether personal data is masked in the history of this version: yes unless `log.maskPersonal` is `false`. */
13
+ export function masksPersonal(document: Pick<ProcessDocument, "settings">): boolean {
14
+ return document.settings?.log?.maskPersonal !== false;
15
+ }
16
+
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;
34
+ }
35
+
36
+ /** The names of the personal `local` and `out` variables. */
37
+ export function personalVariables(document: Pick<ProcessDocument, "variables">): ReadonlySet<string> {
38
+ return new Set((document.variables ?? []).filter((variable) => variable.personal === true && variable.direction !== "in").map((variable) => variable.name));
39
+ }
40
+
41
+ function touches(path: string, personal: ReadonlySet<string>): boolean {
42
+ for (const root of personal) if (path === root || path.startsWith(`${root}.`)) return true;
43
+ return false;
44
+ }
45
+
46
+ /** Whether an expression or a path string reads a personal variable. */
47
+ export function readsPersonal(value: unknown, personal: ReadonlySet<string>): boolean {
48
+ if (isPath(value)) return touches(value, personal);
49
+ if (!isExpressionNode(value)) return false;
50
+ if ("path" in value) return typeof value.path === "string" && touches(value.path, personal);
51
+ return Array.isArray(value.args) && value.args.some((arg) => readsPersonal(arg, personal));
52
+ }
53
+
54
+ /** A resolved step input with what its mapping took from personal variables masked. */
55
+ export function maskByMapping(mapping: unknown, value: unknown, personal: ReadonlySet<string>): unknown {
56
+ if (personal.size === 0) return value;
57
+ if (readsPersonal(mapping, personal)) return PERSONAL_MASK;
58
+ if (isExpressionNode(mapping)) return value;
59
+ if (Array.isArray(mapping) && Array.isArray(value)) return value.map((item, index) => maskByMapping(mapping[index], item, personal));
60
+ if (isObject(mapping) && isObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskByMapping(mapping[key], item, personal)]));
61
+ return value;
62
+ }
63
+
64
+ /** A value with the fields its JSON Schema marks `x-personal` masked. */
65
+ export function maskBySchema(value: unknown, schema: unknown): unknown {
66
+ if (!isObject(schema) || value === null || value === undefined) return value;
67
+ if (schema["x-personal"] === true) return PERSONAL_MASK;
68
+ if (Array.isArray(value)) return value.map((item) => maskBySchema(item, schema.items));
69
+ if (!isObject(value) || !isObject(schema.properties)) return value;
70
+ const properties = schema.properties;
71
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, Object.hasOwn(properties, key) ? maskBySchema(item, properties[key]) : item]));
72
+ }
@@ -1,4 +1,7 @@
1
- import type { Condition, NodeKind, ProcessDocument } from "./ProcessDocument";
1
+ import type { Expr } from "./expressions";
2
+ import { printExpression } from "./expressionSyntax";
3
+ import { textOf } from "./localized";
4
+ import type { NodeKind, ProcessDocument } from "./ProcessDocument";
2
5
 
3
6
  /** Words Mermaid reads as syntax: such node ids are renamed. */
4
7
  const RESERVED = new Set(["end", "graph", "flowchart", "subgraph", "style", "class", "classdef", "click", "default", "linkstyle", "direction"]);
@@ -10,6 +13,9 @@ const SHAPES: Record<NodeKind, readonly [open: string, close: string]> = {
10
13
  end: ["((", "))"],
11
14
  userTask: ["[", "]"],
12
15
  serviceTask: ["[[", "]]"],
16
+ callProcess: ["[[", "]]"],
17
+ loop: ["[", "]"],
18
+ assign: ["[/", "/]"],
13
19
  timerCatch: ["{{", "}}"],
14
20
  eventWait: ["{{", "}}"],
15
21
  exclusiveGateway: ["{", "}"],
@@ -21,7 +27,7 @@ const SHAPES: Record<NodeKind, readonly [open: string, close: string]> = {
21
27
 
22
28
  /**
23
29
  * A process as a Mermaid `flowchart` (WORKFLOW-001 §14): starts are stadiums,
24
- * 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,
25
31
  * ends and boundary events circles. A boundary event hangs on its step by a
26
32
  * dotted link. Labels show the node kind's detail: the Tool, the cron, the
27
33
  * duration, the event key; flows show their title or condition.
@@ -39,17 +45,29 @@ export function processToMermaid(document: ProcessDocument, direction: "LR" | "T
39
45
  return id;
40
46
  };
41
47
  const lines = [`flowchart ${direction}`];
42
- for (const [nodeId, node] of Object.entries(document.nodes)) {
43
- const [open, close] = SHAPES[node.kind] ?? ["[", "]"];
44
- const detail = detailOf(node.kind, node.config ?? {});
45
- lines.push(` ${idOf(nodeId)}${open}"${label(node.title || nodeId)}${detail === undefined ? "" : `<br/><small>${label(detail)}</small>`}"${close}`);
46
- }
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, " ");
47
65
  for (const [nodeId, node] of Object.entries(document.nodes)) {
48
66
  if (node.attachedTo !== undefined && document.nodes[node.attachedTo] !== undefined) lines.push(` ${idOf(node.attachedTo)} -.- ${idOf(nodeId)}`);
49
67
  }
50
68
  for (const flow of Object.values(document.flows)) {
51
69
  if (document.nodes[flow.source] === undefined || document.nodes[flow.target] === undefined) continue;
52
- const text = flow.title ?? (flow.condition !== undefined ? conditionText(flow.condition) : flow.isDefault === true ? "default" : undefined);
70
+ const text = flow.title !== undefined ? textOf(flow.title, "en") : flow.condition !== undefined ? expressionText(flow.condition) : flow.isDefault === true ? "default" : undefined;
53
71
  lines.push(` ${idOf(flow.source)} -->${text === undefined ? "" : `|"${label(text)}"|`} ${idOf(flow.target)}`);
54
72
  }
55
73
  return `${lines.join("\n")}\n`;
@@ -59,6 +77,8 @@ function detailOf(kind: NodeKind, config: Readonly<Record<string, unknown>>): st
59
77
  const text = (value: unknown) => (typeof value === "string" && value !== "" ? value : undefined);
60
78
  switch (kind) {
61
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";
62
82
  case "scheduleStart": return text(config.cron);
63
83
  case "eventStart":
64
84
  case "eventWait":
@@ -70,15 +90,21 @@ function detailOf(kind: NodeKind, config: Readonly<Record<string, unknown>>): st
70
90
  const assignee = config.assignee as { role?: unknown; user?: unknown } | undefined;
71
91
  return assignee === undefined ? undefined : text(assignee.role) !== undefined ? `role ${assignee.role}` : text(assignee.user) === undefined ? undefined : `user ${assignee.user}`;
72
92
  }
93
+ case "assign": {
94
+ const list = config.assignments;
95
+ return Array.isArray(list) ? list.map((item: { var?: unknown }) => String(item.var)).join(", ") : undefined;
96
+ }
73
97
  default: return undefined;
74
98
  }
75
99
  }
76
100
 
77
- function conditionText(condition: Condition): string {
78
- if ("all" in condition) return condition.all.map(conditionText).join(" and ");
79
- if ("any" in condition) return condition.any.map(conditionText).join(" or ");
80
- if ("not" in condition) return `not (${conditionText(condition.not)})`;
81
- return condition.op === "empty" || condition.op === "notEmpty" ? `${condition.path} ${condition.op}` : `${condition.path} ${condition.op} ${JSON.stringify(condition.value)}`;
101
+ /** A condition in the designer's syntax; one that does not print shows as JSON. */
102
+ function expressionText(expression: Expr): string {
103
+ try {
104
+ return printExpression(expression);
105
+ } catch {
106
+ return JSON.stringify(expression);
107
+ }
82
108
  }
83
109
 
84
110
  /** Text inside a quoted Mermaid label: quotes and markup are entities. */
@@ -1,24 +1,37 @@
1
1
  import { parseCron } from "./cron";
2
- import { checkFormSpec } from "./formFields";
2
+ import { FIELD_NAME, checkFormSpec } from "./formFields";
3
3
  import { DATA_PATH } from "./paths";
4
4
  import type { NodeKind } from "./ProcessDocument";
5
5
 
6
6
  /** The role of a node in the graph: it sets the rules of incoming and outgoing flows. */
7
7
  export type NodeRole = "start" | "activity" | "gateway" | "boundary" | "end";
8
8
 
9
+ /** A JSON Schema, as the palette gives it to a designer. */
10
+ export type JsonSchema = Readonly<Record<string, unknown>>;
11
+
9
12
  export interface NodeKindDefinition {
10
13
  readonly kind: NodeKind;
11
14
  readonly role: NodeRole;
15
+ /** A container: other nodes name it as their `parent`. */
16
+ readonly container?: boolean;
12
17
  /** Checks the node `config`; returns a problem or `null`. */
13
18
  readonly check: (config: Record<string, unknown>) => string | null;
19
+ /** The node `config` as a JSON Schema: what a designer builds the form of a step from. */
20
+ readonly config: JsonSchema;
14
21
  }
15
22
 
16
23
  /** ISO 8601 duration without months and years: P1D, PT2H30M, PT45S. */
17
24
  export const ISO_DURATION = /^P(?!$)(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
18
25
  /** An event key or a Tool name: `payments.received`, `payments.charge`. */
19
26
  export const DOTTED_KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
20
- /** A process variable name. */
21
- export const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
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;
33
+ /** A parameter of a Tool. */
34
+ const PARAMETER = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
22
35
  const ROLE = /^[a-z][a-z0-9_.-]{0,63}$/;
23
36
  const USER = /^[^\s:][^\s]{0,127}$/;
24
37
 
@@ -74,7 +87,7 @@ function inputMapping(config: Record<string, unknown>): string | null {
74
87
  const value = config.input;
75
88
  if (value === undefined) return null;
76
89
  if (typeof value !== "object" || value === null || Array.isArray(value)) return "config.input must be an object";
77
- return Object.keys(value).every((key) => VAR_NAME.test(key)) ? null : "config.input has an invalid parameter name";
90
+ return Object.keys(value).every((key) => PARAMETER.test(key)) ? null : "config.input has an invalid parameter name";
78
91
  }
79
92
 
80
93
  /** Automatic retries of a failed Tool call. */
@@ -84,23 +97,140 @@ function retry(config: Record<string, unknown>): string | null {
84
97
  if (typeof value !== "object" || value === null || Array.isArray(value)) return "config.retry must be an object";
85
98
  const policy = value as Record<string, unknown>;
86
99
  if (!Number.isSafeInteger(policy.maxAttempts) || (policy.maxAttempts as number) < 1 || (policy.maxAttempts as number) > 10) return "config.retry.maxAttempts must be 1–10";
100
+ if (policy.growth !== undefined && policy.growth !== "fixed" && policy.growth !== "exponential") return "config.retry.growth must be fixed or exponential";
101
+ if (policy.factor !== undefined) {
102
+ if (policy.growth === "fixed") return "config.retry.factor is only for exponential growth";
103
+ if (typeof policy.factor !== "number" || !(policy.factor >= 1) || policy.factor > 100) return "config.retry.factor must be a number 1–100";
104
+ }
87
105
  return string(policy, "backoff", ISO_DURATION, false)?.replace("config.", "config.retry.") ?? null;
88
106
  }
89
107
 
108
+ /** What a failed Tool step does when no error boundary takes it. */
109
+ function onError(config: Record<string, unknown>): string | null {
110
+ return config.onError === undefined || config.onError === "fail" || config.onError === "skip" ? null : "config.onError must be fail or skip";
111
+ }
112
+
113
+ /** The shape of an `assign` step; its variables and expressions are checked with the whole document. */
114
+ function assignments(config: Record<string, unknown>): string | null {
115
+ const list = config.assignments;
116
+ if (!Array.isArray(list) || list.length === 0) return "config.assignments must be a non-empty list";
117
+ for (const [index, item] of list.entries()) {
118
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return `config.assignments[${index}] must be an object`;
119
+ const { var: name, value } = item as { var?: unknown; value?: unknown };
120
+ if (typeof name !== "string" || !FIELD_NAME.test(name)) return `config.assignments[${index}].var is invalid`;
121
+ if (value === undefined) return `config.assignments[${index}].value is required`;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /** What a loop runs over and how; its expressions are checked with the whole document. */
127
+ function loop(config: Record<string, unknown>): string | null {
128
+ const sources = ["over", "while", "times"].filter((key) => config[key] !== undefined);
129
+ if (sources.length !== 1) return "config needs exactly one of over, while or times";
130
+ const { maxIterations, times, parallel, onItemError: onItem, collect } = config;
131
+ if (!Number.isSafeInteger(maxIterations) || (maxIterations as number) < 1 || (maxIterations as number) > MAX_ITERATIONS) return `config.maxIterations must be 1–${MAX_ITERATIONS}`;
132
+ 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";
133
+ if (parallel !== undefined) {
134
+ if (typeof parallel !== "object" || parallel === null || Array.isArray(parallel)) return "config.parallel must be an object";
135
+ const { limit } = parallel as { limit?: unknown };
136
+ if (!Number.isSafeInteger(limit) || (limit as number) < 1 || (limit as number) > MAX_LOOP_PARALLEL) return `config.parallel.limit must be 1–${MAX_LOOP_PARALLEL}`;
137
+ if (config.while !== undefined) return "config.parallel does not go with while: its condition depends on the iteration before";
138
+ }
139
+ if (onItem !== undefined && onItem !== "stop" && onItem !== "skip") return "config.onItemError must be stop or skip";
140
+ if (collect !== undefined) {
141
+ if (typeof collect !== "object" || collect === null || Array.isArray(collect)) return "config.collect must be an object";
142
+ const { from, to } = collect as { from?: unknown; to?: unknown };
143
+ if (from === undefined) return "config.collect.from is required";
144
+ if (typeof to !== "string" || !FIELD_NAME.test(to)) return "config.collect.to is invalid";
145
+ }
146
+ return first(string(config, "item", FIELD_NAME, false), string(config, "index", FIELD_NAME, false));
147
+ }
148
+
149
+ const duration = { type: "string", format: "duration" };
150
+ const text = { type: "string" };
151
+ const dataPath = { type: "string", pattern: DATA_PATH.source };
152
+ const saveTo = { type: "string", pattern: FIELD_NAME.source };
153
+ const expression = { description: "An expression: a JSON literal, { path } or { op, args }" };
154
+ const flag = { type: "boolean" };
155
+ const object = (properties: Record<string, unknown>, required: readonly string[] = []): JsonSchema => ({ type: "object", properties, ...(required.length === 0 ? {} : { required }) });
156
+ const timerConfig = { duration, at: dataPath };
157
+ const fields = { type: "object", properties: { fields: { type: "array", items: { type: "object" } } }, required: ["fields"] };
158
+
90
159
  export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
91
- { kind: "manualStart", role: "start", check: none },
92
- { kind: "scheduleStart", role: "start", check: cron },
93
- { kind: "eventStart", role: "start", check: (c) => string(c, "event", DOTTED_KEY) },
94
- { kind: "userTask", role: "activity", check: (c) => first(assignee(c), form(c), string(c, "dueIn", ISO_DURATION, false)) },
95
- { kind: "serviceTask", role: "activity", check: (c) => first(string(c, "tool", DOTTED_KEY), inputMapping(c), string(c, "saveTo", VAR_NAME, false), retry(c)) },
96
- { kind: "timerCatch", role: "activity", check: timer },
97
- { kind: "eventWait", role: "activity", check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), string(c, "saveTo", VAR_NAME, false)) },
98
- { kind: "exclusiveGateway", role: "gateway", check: none },
99
- { kind: "parallelGateway", role: "gateway", check: none },
100
- { kind: "boundaryError", role: "boundary", check: (c) => string(c, "errorCode", /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, false) },
101
- { kind: "boundaryTimer", role: "boundary", check: (c) => first(timer(c), boolean(c, "interrupting")) },
102
- { kind: "boundaryEvent", role: "boundary", check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), boolean(c, "interrupting")) },
103
- { kind: "end", role: "end", check: none },
160
+ { 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"]) },
163
+ {
164
+ kind: "userTask",
165
+ role: "activity",
166
+ check: (c) => first(assignee(c), form(c), string(c, "dueIn", ISO_DURATION, false), string(c, "saveTo", FIELD_NAME, false)),
167
+ config: object({ assignee: object({ role: text, user: text }), form: fields, dueIn: duration, saveTo }, ["assignee"]),
168
+ },
169
+ {
170
+ kind: "serviceTask",
171
+ role: "activity",
172
+ check: (c) => first(string(c, "tool", DOTTED_KEY), inputMapping(c), string(c, "saveTo", FIELD_NAME, false), retry(c), string(c, "timeout", ISO_DURATION, false), onError(c)),
173
+ config: object({
174
+ tool: text,
175
+ input: { type: "object", description: "Parameter → a literal, a path string or an expression" },
176
+ saveTo,
177
+ timeout: duration,
178
+ retry: object({ maxAttempts: { type: "integer", minimum: 1, maximum: 10 }, backoff: duration, growth: { enum: ["fixed", "exponential"] }, factor: { type: "number", minimum: 1, maximum: 100 } }, ["maxAttempts"]),
179
+ onError: { enum: ["fail", "skip"] },
180
+ }, ["tool"]),
181
+ },
182
+ {
183
+ kind: "assign",
184
+ role: "activity",
185
+ check: assignments,
186
+ config: object({ assignments: { type: "array", minItems: 1, items: object({ var: { type: "string", pattern: FIELD_NAME.source }, value: expression }, ["var", "value"]) } }, ["assignments"]),
187
+ },
188
+ {
189
+ kind: "loop",
190
+ role: "activity",
191
+ container: true,
192
+ check: loop,
193
+ config: object({
194
+ over: expression,
195
+ while: expression,
196
+ times: { type: "integer", minimum: 1, maximum: MAX_ITERATIONS },
197
+ item: saveTo,
198
+ index: saveTo,
199
+ maxIterations: { type: "integer", minimum: 1, maximum: MAX_ITERATIONS },
200
+ parallel: object({ limit: { type: "integer", minimum: 1, maximum: MAX_LOOP_PARALLEL } }, ["limit"]),
201
+ onItemError: { enum: ["stop", "skip"] },
202
+ collect: object({ from: expression, to: saveTo }, ["from", "to"]),
203
+ }, ["maxIterations"]),
204
+ },
205
+ {
206
+ kind: "callProcess",
207
+ role: "activity",
208
+ check: (c) => first(string(c, "process", PROCESS_KEY), inputMapping(c), string(c, "saveTo", FIELD_NAME, false), onError(c)),
209
+ config: object({
210
+ process: { type: "string", pattern: PROCESS_KEY.source },
211
+ input: { type: "object", description: "Parameter → a literal, a path string or an expression" },
212
+ saveTo,
213
+ onError: { enum: ["fail", "skip"] },
214
+ }, ["process"]),
215
+ },
216
+ { kind: "timerCatch", role: "activity", check: timer, config: object(timerConfig) },
217
+ {
218
+ kind: "eventWait",
219
+ role: "activity",
220
+ 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"]),
222
+ },
223
+ { kind: "exclusiveGateway", role: "gateway", check: none, config: object({}) },
224
+ { kind: "parallelGateway", role: "gateway", check: none, config: object({}) },
225
+ { kind: "boundaryError", role: "boundary", check: (c) => string(c, "errorCode", /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, false), config: object({ errorCode: text }) },
226
+ { kind: "boundaryTimer", role: "boundary", check: (c) => first(timer(c), boolean(c, "interrupting")), config: object({ ...timerConfig, interrupting: flag }) },
227
+ {
228
+ kind: "boundaryEvent",
229
+ role: "boundary",
230
+ check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), boolean(c, "interrupting")),
231
+ config: object({ event: text, correlation: dataPath, interrupting: flag }, ["event"]),
232
+ },
233
+ { kind: "end", role: "end", check: none, config: object({}) },
104
234
  ] satisfies NodeKindDefinition[]);
105
235
 
106
236
  const BY_KIND = new Map(NODE_KINDS.map((definition) => [definition.kind, definition]));
@@ -0,0 +1,110 @@
1
+ import { EXPRESSION_FUNCTIONS, type ExprFunction } from "./expressions";
2
+ import type { LocalizedText } from "./localized";
3
+ import { NODE_KINDS, nodeKindOf, type JsonSchema, type NodeRole } from "./nodeKinds";
4
+ import { DATA_PATHS } from "./paths";
5
+ import type { NodeKind } from "./ProcessDocument";
6
+
7
+ /** What the palette shows of a Tool: structurally a Tool contract. */
8
+ export interface PaletteTool {
9
+ readonly name: string;
10
+ readonly description: string;
11
+ readonly input?: { readonly kind?: string; readonly schema?: unknown };
12
+ readonly output?: { readonly kind?: string; readonly schema?: unknown };
13
+ readonly sideEffect: string;
14
+ readonly dryRun: string;
15
+ }
16
+
17
+ export interface PaletteNodeKind {
18
+ readonly kind: NodeKind;
19
+ readonly role: NodeRole;
20
+ /** A container: other nodes name it as their `parent`. */
21
+ readonly container?: boolean;
22
+ readonly config: JsonSchema;
23
+ }
24
+
25
+ /** Everything a designer builds its forms from (WORKFLOW-002 §4.2). */
26
+ export interface ProcessPalette {
27
+ readonly nodeKinds: readonly PaletteNodeKind[];
28
+ readonly functions: readonly ExprFunction[];
29
+ readonly paths: typeof DATA_PATHS;
30
+ readonly tools: readonly PaletteTool[];
31
+ }
32
+
33
+ /** The palette with the application's Tools: a new Tool appears in a designer without a release of the framework. */
34
+ export function processPalette(tools: readonly PaletteTool[]): ProcessPalette {
35
+ return {
36
+ nodeKinds: NODE_KINDS.map(({ kind, role, container, config }) => ({ kind, role, ...(container === undefined ? {} : { container }), config })),
37
+ functions: EXPRESSION_FUNCTIONS,
38
+ paths: DATA_PATHS,
39
+ tools,
40
+ };
41
+ }
42
+
43
+ /** One field of the form of a step. */
44
+ export interface StepFormField {
45
+ readonly name: string;
46
+ readonly type: string;
47
+ readonly required: boolean;
48
+ readonly title?: LocalizedText;
49
+ readonly description?: string;
50
+ readonly values?: readonly unknown[];
51
+ /** What the field references (`x-ref`), its keys reference (`x-ref-keys`) and the input that gives the area (`x-ref-scope`). */
52
+ readonly ref?: string;
53
+ readonly refKeys?: string;
54
+ readonly refScope?: string;
55
+ /** Personal data (`x-personal`). */
56
+ readonly personal?: boolean;
57
+ /** The fields of an object. */
58
+ readonly fields?: readonly StepFormField[];
59
+ /** A list's items. */
60
+ readonly items?: StepFormField;
61
+ }
62
+
63
+ export interface StepForm {
64
+ readonly fields: readonly StepFormField[];
65
+ }
66
+
67
+ function isObject(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
69
+ }
70
+
71
+ function fieldOf(name: string, schema: unknown, required: boolean): StepFormField {
72
+ const source = isObject(schema) ? schema : {};
73
+ const values = Array.isArray(source.enum) ? source.enum : undefined;
74
+ const type = typeof source.type === "string" ? source.type : Array.isArray(source.type) ? String(source.type.find((item) => item !== "null") ?? "any") : values !== undefined ? "enum" : "any";
75
+ const fields = isObject(source.properties) ? fieldsOf(source) : undefined;
76
+ return {
77
+ name,
78
+ type,
79
+ required,
80
+ ...(source.title === undefined ? {} : { title: source.title as LocalizedText }),
81
+ ...(typeof source.description === "string" ? { description: source.description } : {}),
82
+ ...(values === undefined ? {} : { values }),
83
+ ...(typeof source["x-ref"] === "string" ? { ref: source["x-ref"] } : {}),
84
+ ...(typeof source["x-ref-keys"] === "string" ? { refKeys: source["x-ref-keys"] } : {}),
85
+ ...(typeof source["x-ref-scope"] === "string" ? { refScope: source["x-ref-scope"] } : {}),
86
+ ...(source["x-personal"] === true ? { personal: true } : {}),
87
+ ...(fields === undefined ? {} : { fields }),
88
+ ...(isObject(source.items) ? { items: fieldOf("item", source.items, false) } : {}),
89
+ };
90
+ }
91
+
92
+ function fieldsOf(schema: Record<string, unknown>): StepFormField[] {
93
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
94
+ return Object.entries(isObject(schema.properties) ? schema.properties : {}).map(([name, item]) => fieldOf(name, item, required.has(name)));
95
+ }
96
+
97
+ /**
98
+ * The form of a step's settings, by the palette (WORKFLOW-002 §3.8): the
99
+ * `config` of a node kind, or the input of a Tool: fields, types, references
100
+ * and localizable titles. An unknown kind has no fields.
101
+ */
102
+ export function formOf(source: NodeKind | Pick<PaletteTool, "input">): StepForm {
103
+ if (typeof source === "string") {
104
+ const definition = nodeKindOf(source);
105
+ return { fields: definition === undefined ? [] : fieldsOf(definition.config as Record<string, unknown>) };
106
+ }
107
+ const schema = source.input?.schema;
108
+ return { fields: isObject(schema) ? fieldsOf(schema) : [] };
109
+ }
110
+
@@ -1,12 +1,42 @@
1
- /** Instance data that conditions and step inputs read. */
1
+ import { evaluateExpression, isExpressionNode } from "./expressions";
2
+
3
+ /**
4
+ * What expressions and step inputs read. `input`, `vars` and `nodes` are the
5
+ * instance's own data; the rest describes the run. The engine fills them all.
6
+ */
2
7
  export interface InstanceData {
3
8
  readonly input: Readonly<Record<string, unknown>>;
4
9
  readonly vars: Readonly<Record<string, unknown>>;
5
10
  readonly nodes: Readonly<Record<string, unknown>>;
11
+ /** The data of the event that started or continued the process. */
12
+ readonly event?: unknown;
13
+ /** How it was started: `{ via, at, actor, eventKey?, cause? }`. */
14
+ readonly trigger?: unknown;
15
+ /** `{ id, processKey, version, partition, test }`. */
16
+ readonly run?: unknown;
17
+ /** The engine's time as an ISO string. */
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;
6
23
  }
7
24
 
8
- /** `$input.amount`, `$vars.payment.id`, `$nodes.Pay.paymentId`. */
9
- export const DATA_PATH = /^\$(input|vars|nodes)((\.[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_]*)*)$/;
27
+
28
+ /** The roots of `$` paths, for a designer. */
29
+ export const DATA_PATHS: readonly { readonly path: string; readonly description: string }[] = Object.freeze([
30
+ { path: "$input.<field>", description: "A field of the process input: a variable with direction in" },
31
+ { path: "$vars.<name>", description: "A variable of the process" },
32
+ { path: "$nodes.<node>.<field>", description: "The result of a passed step" },
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? }" },
35
+ { path: "$run", description: "This run: { id, processKey, version, partition, test }" },
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" },
39
+ ]);
10
40
 
11
41
  /** `true` if the value is a path to instance data. */
12
42
  export function isPath(value: unknown): value is string {
@@ -18,7 +48,7 @@ export function readPath(data: InstanceData, path: string): unknown {
18
48
  const match = DATA_PATH.exec(path);
19
49
  if (!match) return undefined;
20
50
  let current: unknown = data[match[1] as keyof InstanceData];
21
- for (const key of match[2]!.slice(1).split(".")) {
51
+ for (const key of match[2]!.split(".").slice(1)) {
22
52
  if (typeof current !== "object" || current === null || !Object.prototype.hasOwnProperty.call(current, key)) return undefined;
23
53
  current = (current as Record<string, unknown>)[key];
24
54
  }
@@ -26,11 +56,12 @@ export function readPath(data: InstanceData, path: string): unknown {
26
56
  }
27
57
 
28
58
  /**
29
- * A step input: a path string becomes the instance value, objects and lists are
30
- * resolved deeply, anything else is a literal. A missing value becomes `null`.
59
+ * A step input: a path string and an expression become their value, objects and
60
+ * lists are resolved deeply, anything else is a literal. A missing value becomes `null`.
31
61
  */
32
62
  export function resolveInput(value: unknown, data: InstanceData): unknown {
33
63
  if (isPath(value)) return readPath(data, value) ?? null;
64
+ if (isExpressionNode(value)) return evaluateExpression(value, data) ?? null;
34
65
  if (Array.isArray(value)) return value.map((item) => resolveInput(item, data));
35
66
  if (typeof value === "object" && value !== null) {
36
67
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveInput(item, data)]));