bazis 0.132.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 (36) hide show
  1. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  2. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +5 -4
  3. package/core/workflow/definitions_module/http/Processes.controller.ts +2 -1
  4. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +2 -0
  5. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +4 -2
  6. package/core/workflow/generated/deps.ts +4 -0
  7. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -0
  8. package/core/workflow/runtime_module/background/WorkflowRetention.ts +2 -2
  9. package/core/workflow/runtime_module/events/Runtime.events.ts +4 -4
  10. package/core/workflow/runtime_module/http/Instances.controller.ts +14 -2
  11. package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
  12. package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
  13. package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
  14. package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
  15. package/core/workflow/runtime_module/internal/ToolStep.ts +4 -4
  16. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +518 -132
  17. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +37 -6
  18. package/core/workflow/runtime_module/model/WfHistory.model.ts +1 -1
  19. package/core/workflow/runtime_module/model/WfInstance.model.ts +7 -4
  20. package/core/workflow/runtime_module/model/WfJob.model.ts +13 -4
  21. package/core/workflow/runtime_module/model/WfToken.model.ts +5 -3
  22. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +38 -3
  23. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +48 -7
  24. package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
  25. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +1 -1
  26. package/library/workflow/ProcessDocument.ts +14 -0
  27. package/library/workflow/expressions.ts +5 -1
  28. package/library/workflow/index.ts +1 -1
  29. package/library/workflow/masking.ts +17 -3
  30. package/library/workflow/mermaid.ts +22 -6
  31. package/library/workflow/nodeKinds.ts +59 -0
  32. package/library/workflow/palette.ts +3 -1
  33. package/library/workflow/paths.ts +8 -2
  34. package/library/workflow/structure.ts +33 -4
  35. package/library/workflow/validateProcess.ts +129 -17
  36. package/package.json +1 -1
@@ -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}$/;
@@ -115,6 +123,29 @@ function assignments(config: Record<string, unknown>): string | null {
115
123
  return null;
116
124
  }
117
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
+
118
149
  const duration = { type: "string", format: "duration" };
119
150
  const text = { type: "string" };
120
151
  const dataPath = { type: "string", pattern: DATA_PATH.source };
@@ -154,6 +185,34 @@ export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
154
185
  check: assignments,
155
186
  config: object({ assignments: { type: "array", minItems: 1, items: object({ var: { type: "string", pattern: FIELD_NAME.source }, value: expression }, ["var", "value"]) } }, ["assignments"]),
156
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
+ },
157
216
  { kind: "timerCatch", role: "activity", check: timer, config: object(timerConfig) },
158
217
  {
159
218
  kind: "eventWait",
@@ -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,
@@ -16,10 +16,14 @@ export interface InstanceData {
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([
@@ -30,6 +34,8 @@ export const DATA_PATHS: readonly { readonly path: string; readonly description:
30
34
  { path: "$trigger", description: "How it was started: { via, at, actor, eventKey?, cause? }" },
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;
@@ -1,7 +1,7 @@
1
1
  import { checkExpression, checkPath, isExpressionNode, type ExpressionScope } from "./expressions";
2
2
  import { checkExtensions, variableProblems } from "./formFields";
3
3
  import { checkLocalizedText } from "./localized";
4
- import { ISO_DURATION, nodeKindOf, type NodeRole } from "./nodeKinds";
4
+ import { ISO_DURATION, nodeKindOf, PROCESS_KEY, type NodeRole } from "./nodeKinds";
5
5
  import { isPath } from "./paths";
6
6
  import { PROCESS_SCHEMA_VERSION, type ProcessDocument } from "./ProcessDocument";
7
7
  import { toStructure } from "./structure";
@@ -32,6 +32,10 @@ export type ProcessIssueCode =
32
32
  | "title_invalid"
33
33
  | "config_invalid"
34
34
  | "unknown_tool"
35
+ | "unknown_process"
36
+ | "container_invalid"
37
+ | "flow_across_container"
38
+ | "loop_body"
35
39
  | "flow_dangling"
36
40
  | "flow_self"
37
41
  | "flow_duplicate"
@@ -61,7 +65,9 @@ export const PROCESS_LIMITS = Object.freeze({ nodes: 500, flows: 1000, bytes: 1_
61
65
 
62
66
  const ID = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
63
67
  /** Paths that come with later stages of WORKFLOW-002: until then they are an error, not a silent literal. */
64
- const NOT_YET = /^\$(secrets|item|index)(\.|$)/;
68
+ const NOT_YET = /^\$secrets(\.|$)/;
69
+ /** Who a boundary error may hang on: the steps that fail by an error of their own. */
70
+ const CAN_FAIL: ReadonlySet<string | undefined> = new Set(["serviceTask", "callProcess", "loop"]);
65
71
 
66
72
  type Json = Record<string, unknown>;
67
73
 
@@ -79,6 +85,8 @@ export interface ValidateProcessOptions {
79
85
  * `callers`. Without it Tool names are only checked for their form.
80
86
  */
81
87
  readonly tools?: (name: string) => boolean;
88
+ /** Whether a process may be called or handle errors. Without it process keys are only checked for their form. */
89
+ readonly processes?: (key: string) => boolean;
82
90
  /** Whether an `extensions` namespace has a check. Without it namespaces are only checked for their form. */
83
91
  readonly extensions?: (namespace: string) => boolean;
84
92
  }
@@ -106,6 +114,10 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
106
114
  if (document.settings !== undefined) {
107
115
  const problem = checkSettings(document.settings);
108
116
  if (problem) error("settings_invalid", problem);
117
+ else {
118
+ const handler = (document.settings as Json).errorHandler;
119
+ if (typeof handler === "string" && options.processes && !options.processes(handler)) error("unknown_process", `Process ${JSON.stringify(handler)} of settings.errorHandler is not published`);
120
+ }
109
121
  }
110
122
  checkNamespaces(document.extensions, "extensions", {}, options, error);
111
123
  for (const variable of declared) checkNamespaces(variable.extensions, "extensions", { variable: String(variable.name) }, options, error);
@@ -143,6 +155,8 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
143
155
  if (problem) error("config_invalid", problem, { node: id });
144
156
  else if (definition.kind === "serviceTask" && options.tools && !options.tools(config.tool as string)) {
145
157
  error("unknown_tool", `Tool ${JSON.stringify(config.tool)} does not exist or does not list "workflow" in its callers`, { node: id });
158
+ } else if (definition.kind === "callProcess" && options.processes && !options.processes(config.process as string)) {
159
+ error("unknown_process", `Process ${JSON.stringify(config.process)} is not published`, { node: id });
146
160
  }
147
161
  }
148
162
  if (definition.role === "boundary") {
@@ -154,24 +168,95 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
154
168
  }
155
169
  for (const [id, host] of attached) {
156
170
  if (roles.get(host) !== "activity") error("boundary_attach", `attachedTo ${JSON.stringify(host)} is not a step`, { node: id });
157
- else if (kinds.get(id) === "boundaryError" && kinds.get(host) !== "serviceTask") error("boundary_attach", "error boundary attaches only to a service task", { node: id });
171
+ else if (kinds.get(id) === "boundaryError" && !CAN_FAIL.has(kinds.get(host))) error("boundary_attach", "error boundary attaches only to a service task, a called process or a loop", { node: id });
172
+ }
173
+
174
+ // Containers: `parent` names a loop, and a chain of parents ends.
175
+ const parents = new Map<string, string>();
176
+ for (const [id, raw] of Object.entries(nodes)) {
177
+ const parent = isObject(raw) ? raw.parent : undefined;
178
+ if (parent === undefined) continue;
179
+ if (typeof parent !== "string" || kinds.get(parent) !== "loop" || parent === id) error("container_invalid", "parent must be a loop", { node: id });
180
+ else if (roles.get(id) === "start") error("container_invalid", "a start cannot be inside a loop", { node: id });
181
+ else parents.set(id, parent);
182
+ }
183
+ for (const id of [...parents.keys()]) {
184
+ const seen = new Set([id]);
185
+ for (let up = parents.get(id); up !== undefined; up = parents.get(up)) {
186
+ if (seen.has(up)) {
187
+ error("container_invalid", "loops cannot contain each other", { node: id });
188
+ parents.delete(id);
189
+ break;
190
+ }
191
+ seen.add(up);
192
+ }
193
+ }
194
+ for (const [id, host] of attached) {
195
+ if (roles.has(host) && parents.get(id) !== parents.get(host)) error("boundary_attach", "a boundary node must be in the same container as its step", { node: id });
158
196
  }
159
197
 
160
198
  // What expressions may read: declared variables, what steps save and existing nodes.
161
199
  const named = (direction: (value: unknown) => boolean) => new Set(declared.filter((variable) => direction(variable.direction)).map((variable) => String(variable.name)));
162
200
  const writable = named((direction) => direction !== "in");
163
- const saved = new Set(writable);
164
- for (const raw of Object.values(nodes)) {
165
- const saveTo = isObject(raw) && isObject(raw.config) ? raw.config.saveTo : undefined;
166
- if (typeof saveTo === "string") saved.add(saveTo);
167
- }
168
201
  const known = variables.length === 0;
169
- const scope: ExpressionScope = { ...(known ? { input: named((direction) => direction === "in"), vars: saved } : {}), nodes: new Set(Object.keys(nodes)) };
202
+ const input = named((direction) => direction === "in");
203
+ /** What a step in a container reads: a body sees what is outside it, the outside does not see the body. */
204
+ const scopes = new Map<string | undefined, { readonly scope: ExpressionScope; readonly names: ReadonlySet<string> }>();
205
+ const scopeIn = (container: string | undefined): { readonly scope: ExpressionScope; readonly names: ReadonlySet<string> } => {
206
+ const ready = scopes.get(container);
207
+ if (ready) return ready;
208
+ const outer = container === undefined ? undefined : scopeIn(parents.get(container));
209
+ const vars = new Set<string>(outer ? outer.scope.vars : writable);
210
+ const seen = new Set<string>(outer ? outer.scope.nodes : []);
211
+ const names = new Set<string>(outer ? outer.names : []);
212
+ const own = container === undefined ? undefined : (nodes[container] as Json).config;
213
+ for (const key of ["item", "index"]) {
214
+ const name = isObject(own) ? own[key] : undefined;
215
+ if (typeof name === "string") names.add(name);
216
+ }
217
+ for (const name of names) vars.add(name);
218
+ for (const [id, raw] of Object.entries(nodes)) {
219
+ if (parents.get(id) !== container) continue;
220
+ seen.add(id);
221
+ const saveTo = isObject(raw) && isObject(raw.config) ? raw.config.saveTo : undefined;
222
+ if (typeof saveTo === "string") vars.add(saveTo);
223
+ }
224
+ const made = { scope: { ...(known ? { input, vars } : { vars }), nodes: seen, loop: container !== undefined }, names };
225
+ scopes.set(container, made);
226
+ return made;
227
+ };
228
+ const scopeOf = (container: string | undefined): ExpressionScope => {
229
+ const { scope } = scopeIn(container);
230
+ return known ? scope : { nodes: scope.nodes!, loop: scope.loop! };
231
+ };
170
232
 
171
233
  // What steps read: an assign writes declared variables, inputs and waits read what exists.
172
234
  for (const [id, raw] of Object.entries(nodes)) {
173
235
  if (!isObject(raw) || !isObject(raw.config) || definitionProblem(raw)) continue;
174
- for (const problem of configExpressionProblems(raw.kind as string, raw.config, scope, known ? writable : undefined)) error("config_invalid", problem, { node: id });
236
+ const container = parents.get(id);
237
+ for (const problem of configExpressionProblems(raw.kind as string, raw.config, scopeOf(container), known ? writable : undefined)) error("config_invalid", problem, { node: id });
238
+ if (raw.kind !== "loop") continue;
239
+ const body = scopeOf(id);
240
+ if (raw.config.while !== undefined) {
241
+ // Before an iteration: it knows its number, not what the body of the last one kept to itself.
242
+ const outside = scopeOf(container);
243
+ const own = [raw.config.item, raw.config.index].filter((name): name is string => typeof name === "string");
244
+ const problem = checkExpression(raw.config.while, { ...outside, loop: true, ...(outside.vars ? { vars: new Set([...outside.vars, ...own]) } : {}) });
245
+ if (problem) error("config_invalid", `config.while: ${problem}`, { node: id });
246
+ }
247
+ const collect = raw.config.collect as Json | undefined;
248
+ if (collect !== undefined) {
249
+ const problem = checkExpression(collect.from, body);
250
+ if (problem) error("config_invalid", `config.collect.from: ${problem}`, { node: id });
251
+ if (known && !writable.has(collect.to as string)) error("config_invalid", `config.collect.to: ${String(collect.to)} is not a declared local or out variable`, { node: id });
252
+ }
253
+ for (const key of ["item", "index"]) {
254
+ const name = raw.config[key];
255
+ if (typeof name !== "string") continue;
256
+ if (known && (writable.has(name) || input.has(name))) error("config_invalid", `config.${key}: ${name} is already a variable of the process`, { node: id });
257
+ if (scopeIn(container).names.has(name)) error("config_invalid", `config.${key}: ${name} is already taken by an outer loop`, { node: id });
258
+ }
259
+ if (raw.config.item !== undefined && raw.config.item === raw.config.index) error("config_invalid", "config.item and config.index must differ", { node: id });
175
260
  }
176
261
 
177
262
  // Flows: ends, duplicates, conditions.
@@ -193,6 +278,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
193
278
  continue;
194
279
  }
195
280
  if (source === target) error("flow_self", "flow connects a node to itself", { flow: id });
281
+ if (parents.get(source) !== parents.get(target)) error("flow_across_container", "a flow cannot cross the border of a loop", { flow: id });
196
282
  const pair = `${source}\u0000${target}`;
197
283
  if (pairs.has(pair)) error("flow_duplicate", "another flow already connects these nodes", { flow: id });
198
284
  pairs.add(pair);
@@ -207,7 +293,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
207
293
  if (raw.condition !== undefined) {
208
294
  if (!fromChoice) error("condition_not_allowed", "only flows leaving an exclusive gateway have conditions", { flow: id });
209
295
  else {
210
- const problem = checkExpression(raw.condition, scope);
296
+ const problem = checkExpression(raw.condition, scopeOf(parents.get(source)));
211
297
  if (problem) error("condition_invalid", problem, { flow: id });
212
298
  }
213
299
  }
@@ -217,7 +303,19 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
217
303
  // Rules by node role.
218
304
  const starts = [...roles].filter(([, role]) => role === "start").map(([id]) => id);
219
305
  if (starts.length === 0) error("no_start", "process needs a start node");
220
- if (![...roles.values()].includes("end")) error("no_end", "process needs an end node");
306
+ if (![...roles].some(([id, role]) => role === "end" && !parents.has(id))) error("no_end", "process needs an end node");
307
+ // A body: one step without incoming flows to begin with and an end of its own.
308
+ const entries = new Map<string, string>();
309
+ for (const [loop, kind] of kinds) {
310
+ if (kind !== "loop") continue;
311
+ const members = [...parents].filter(([, parent]) => parent === loop).map(([id]) => id);
312
+ const first = members.filter((id) => roles.get(id) !== "boundary" && (incoming.get(id)?.length ?? 0) === 0);
313
+ if (first.length !== 1) error("loop_body", `the body of a loop needs exactly one step without incoming flows, not ${first.length}`, { node: loop });
314
+ else if (roles.get(first[0]!) !== "activity" && roles.get(first[0]!) !== "gateway") error("loop_body", "the body of a loop begins with a step", { node: loop });
315
+ else entries.set(loop, first[0]!);
316
+ if (!members.some((id) => roles.get(id) === "end")) error("loop_body", "the body of a loop needs an end of its own", { node: loop });
317
+ }
318
+ const opening = new Set(entries.values());
221
319
  for (const [id, role] of roles) {
222
320
  const ins = incoming.get(id)?.length ?? 0;
223
321
  const outs = outgoing.get(id) ?? [];
@@ -231,7 +329,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
231
329
  if (ins === 0) error("end_incoming", "end node needs an incoming flow", { node: id });
232
330
  break;
233
331
  case "activity":
234
- if (ins === 0) error("activity_incoming", "step needs an incoming flow", { node: id });
332
+ if (ins === 0 && !opening.has(id)) error("activity_incoming", "step needs an incoming flow", { node: id });
235
333
  if (outs.length !== 1) error("activity_outgoing", "step needs exactly one outgoing flow; branch with a gateway", { node: id });
236
334
  break;
237
335
  case "boundary":
@@ -239,7 +337,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
239
337
  if (outs.length !== 1) error("boundary_outgoing", "boundary node needs exactly one outgoing flow", { node: id });
240
338
  break;
241
339
  case "gateway":
242
- if (ins === 0 || outs.length === 0 || (ins < 2 && outs.length < 2)) {
340
+ if ((ins === 0 && !opening.has(id)) || outs.length === 0 || (ins < 2 && outs.length < 2 && !opening.has(id))) {
243
341
  error("gateway_flows", "gateway needs incoming and outgoing flows and must split or join", { node: id });
244
342
  }
245
343
  if (kinds.get(id) === "exclusiveGateway" && outs.length > 1) checkChoice(id, outs, flows, error);
@@ -251,8 +349,10 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
251
349
  const forward = new Map<string, string[]>();
252
350
  for (const [id, list] of outgoing) forward.set(id, list.map((flow) => (flows[flow] as Json).target as string));
253
351
  for (const [boundary, host] of attached) if (roles.has(host)) forward.set(host, [...forward.get(host) ?? [], boundary]);
352
+ // The way back goes only inside a container: the end of a body says nothing about what follows the loop.
254
353
  const backward = new Map<string, string[]>();
255
354
  for (const [from, list] of forward) for (const to of list) backward.set(to, [...backward.get(to) ?? [], from]);
355
+ for (const [loop, entry] of entries) forward.set(loop, [...forward.get(loop) ?? [], entry]);
256
356
  const reached = walk(starts, forward);
257
357
  const ending = walk([...roles].filter(([, role]) => role === "end").map(([id]) => id), backward);
258
358
  for (const id of roles.keys()) {
@@ -291,8 +391,16 @@ function checkChoice(id: string, outs: readonly string[], flows: Json, error: (c
291
391
  function checkSettings(value: unknown): string | null {
292
392
  if (!isObject(value)) return "settings must be an object";
293
393
  for (const [key, item] of Object.entries(value)) {
294
- if (key === "stepTimeout") {
295
- if (typeof item !== "string" || !ISO_DURATION.test(item)) return "settings.stepTimeout must be an ISO duration without months and years";
394
+ if (key === "stepTimeout" || key === "runTimeout") {
395
+ if (typeof item !== "string" || !ISO_DURATION.test(item)) return `settings.${key} must be an ISO duration without months and years`;
396
+ } else if (key === "runAs") {
397
+ if (item !== "starter" && item !== "process") return "settings.runAs must be starter or process";
398
+ } else if (key === "sameKey") {
399
+ if (item !== "allow" && item !== "skip" && item !== "queue") return "settings.sameKey must be allow, skip or queue";
400
+ } else if (key === "maxConcurrent") {
401
+ if (!Number.isSafeInteger(item) || (item as number) < 1 || (item as number) > 100_000) return "settings.maxConcurrent must be an integer 1–100000";
402
+ } else if (key === "errorHandler") {
403
+ if (typeof item !== "string" || !PROCESS_KEY.test(item)) return "settings.errorHandler must be a process key";
296
404
  } else if (key === "log") {
297
405
  if (!isObject(item)) return "settings.log must be an object";
298
406
  for (const [flag, on] of Object.entries(item)) {
@@ -338,7 +446,11 @@ function configExpressionProblems(kind: string, config: Json, scope: ExpressionS
338
446
  if (problem) out.push(`config.assignments[${index}].value: ${problem}`);
339
447
  }
340
448
  }
341
- if (kind === "serviceTask" && config.input !== undefined) walkInput(config.input, "config.input", scope, out);
449
+ if ((kind === "serviceTask" || kind === "callProcess") && config.input !== undefined) walkInput(config.input, "config.input", scope, out);
450
+ if (kind === "loop" && config.over !== undefined) {
451
+ const problem = checkExpression(config.over, scope);
452
+ if (problem) out.push(`config.over: ${problem}`);
453
+ }
342
454
  for (const key of ["at", "correlation"]) {
343
455
  if (typeof config[key] === "string") {
344
456
  const problem = checkPath(config[key], scope);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.132.0",
3
+ "version": "0.133.0",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",