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
@@ -1,7 +1,10 @@
1
- import { checkFormSpec, formFieldCodes, type FormSpec } from "./formFields";
2
- import { nodeKindOf, type NodeRole } from "./nodeKinds";
3
- import { DATA_PATH } from "./paths";
4
- import { PROCESS_SCHEMA_VERSION, type ConditionOp } from "./ProcessDocument";
1
+ import { checkExpression, checkPath, isExpressionNode, type ExpressionScope } from "./expressions";
2
+ import { checkExtensions, variableProblems } from "./formFields";
3
+ import { checkLocalizedText } from "./localized";
4
+ import { ISO_DURATION, nodeKindOf, PROCESS_KEY, type NodeRole } from "./nodeKinds";
5
+ import { isPath } from "./paths";
6
+ import { PROCESS_SCHEMA_VERSION, type ProcessDocument } from "./ProcessDocument";
7
+ import { toStructure } from "./structure";
5
8
 
6
9
  /**
7
10
  * One finding of {@link validateProcess}. An `error` blocks publishing, a
@@ -14,17 +17,25 @@ export interface ProcessIssue {
14
17
  readonly message: string;
15
18
  readonly node?: string;
16
19
  readonly flow?: string;
20
+ readonly variable?: string;
17
21
  }
18
22
 
19
23
  export type ProcessIssueCode =
20
24
  | "schema"
21
25
  | "too_large"
22
- | "input_invalid"
26
+ | "variable_invalid"
27
+ | "settings_invalid"
28
+ | "extension_invalid"
29
+ | "extension_unknown"
23
30
  | "id_invalid"
24
31
  | "unknown_kind"
25
32
  | "title_invalid"
26
33
  | "config_invalid"
27
34
  | "unknown_tool"
35
+ | "unknown_process"
36
+ | "container_invalid"
37
+ | "flow_across_container"
38
+ | "loop_body"
28
39
  | "flow_dangling"
29
40
  | "flow_self"
30
41
  | "flow_duplicate"
@@ -47,13 +58,16 @@ export type ProcessIssueCode =
47
58
  | "unreachable"
48
59
  | "dead_end"
49
60
  | "diagram_missing"
50
- | "diagram_orphan";
61
+ | "diagram_orphan"
62
+ | "not_structured";
51
63
 
52
- export const PROCESS_LIMITS = Object.freeze({ nodes: 500, flows: 1000, bytes: 1_000_000, conditionDepth: 8 });
64
+ export const PROCESS_LIMITS = Object.freeze({ nodes: 500, flows: 1000, bytes: 1_000_000 });
53
65
 
54
66
  const ID = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
55
- const OPS = new Set<ConditionOp>(["eq", "ne", "gt", "gte", "lt", "lte", "in", "contains", "empty", "notEmpty"]);
56
- const UNARY = new Set<ConditionOp>(["empty", "notEmpty"]);
67
+ /** Paths that come with later stages of WORKFLOW-002: until then they are an error, not a silent literal. */
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"]);
57
71
 
58
72
  type Json = Record<string, unknown>;
59
73
 
@@ -71,6 +85,10 @@ export interface ValidateProcessOptions {
71
85
  * `callers`. Without it Tool names are only checked for their form.
72
86
  */
73
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;
90
+ /** Whether an `extensions` namespace has a check. Without it namespaces are only checked for their form. */
91
+ readonly extensions?: (namespace: string) => boolean;
74
92
  }
75
93
 
76
94
  /**
@@ -80,8 +98,8 @@ export interface ValidateProcessOptions {
80
98
  */
81
99
  export function validateProcess(document: unknown, options: ValidateProcessOptions = {}): ProcessIssue[] {
82
100
  const out: ProcessIssue[] = [];
83
- const error = (code: ProcessIssueCode, message: string, at: { node?: string; flow?: string } = {}) => out.push({ severity: "error", code, message, ...at });
84
- const warning = (code: ProcessIssueCode, message: string, at: { node?: string; flow?: string } = {}) => out.push({ severity: "warning", code, message, ...at });
101
+ const error = (code: ProcessIssueCode, message: string, at: IssueTarget = {}) => out.push({ severity: "error", code, message, ...at });
102
+ const warning = (code: ProcessIssueCode, message: string, at: IssueTarget = {}) => out.push({ severity: "warning", code, message, ...at });
85
103
 
86
104
  if (!isObject(document)) return [{ severity: "error", code: "schema", message: "document must be an object" }];
87
105
  if (JSON.stringify(document).length > PROCESS_LIMITS.bytes) return [{ severity: "error", code: "too_large", message: `document is larger than ${PROCESS_LIMITS.bytes} bytes` }];
@@ -90,10 +108,19 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
90
108
  if (!isObject(document.flows)) error("schema", "flows must be an object");
91
109
  if (document.diagram !== undefined && !isDiagram(document.diagram)) error("schema", "diagram must have shapes and edges objects with numeric coordinates");
92
110
  if (out.length > 0) return out;
93
- if (document.input !== undefined) {
94
- const problem = checkFormSpec(document.input, "input");
95
- if (problem) error("input_invalid", problem);
111
+ const variables = document.variables === undefined ? [] : variableProblems(document.variables);
112
+ for (const { message, variable } of variables) error("variable_invalid", message, variable === undefined ? {} : { variable });
113
+ const declared = variables.length === 0 && Array.isArray(document.variables) ? document.variables as readonly Json[] : [];
114
+ if (document.settings !== undefined) {
115
+ const problem = checkSettings(document.settings);
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
+ }
96
121
  }
122
+ checkNamespaces(document.extensions, "extensions", {}, options, error);
123
+ for (const variable of declared) checkNamespaces(variable.extensions, "extensions", { variable: String(variable.name) }, options, error);
97
124
 
98
125
  const nodes = document.nodes as Json;
99
126
  const flows = document.flows as Json;
@@ -117,7 +144,9 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
117
144
  }
118
145
  roles.set(id, definition.role);
119
146
  kinds.set(id, definition.kind);
120
- if (typeof raw.title !== "string" || raw.title.trim().length === 0 || raw.title.length > 200) error("title_invalid", "title must be 1–200 characters", { node: id });
147
+ const titleProblem = checkLocalizedText(raw.title, 200);
148
+ if (titleProblem) error("title_invalid", `title ${titleProblem}`, { node: id });
149
+ checkNamespaces(raw.extensions, "extensions", { node: id }, options, error);
121
150
  if (raw.config !== undefined && !isObject(raw.config)) {
122
151
  error("config_invalid", "config must be an object", { node: id });
123
152
  } else {
@@ -126,6 +155,8 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
126
155
  if (problem) error("config_invalid", problem, { node: id });
127
156
  else if (definition.kind === "serviceTask" && options.tools && !options.tools(config.tool as string)) {
128
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 });
129
160
  }
130
161
  }
131
162
  if (definition.role === "boundary") {
@@ -137,12 +168,96 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
137
168
  }
138
169
  for (const [id, host] of attached) {
139
170
  if (roles.get(host) !== "activity") error("boundary_attach", `attachedTo ${JSON.stringify(host)} is not a step`, { node: id });
140
- 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);
141
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 });
196
+ }
197
+
198
+ // What expressions may read: declared variables, what steps save and existing nodes.
199
+ const named = (direction: (value: unknown) => boolean) => new Set(declared.filter((variable) => direction(variable.direction)).map((variable) => String(variable.name)));
200
+ const writable = named((direction) => direction !== "in");
201
+ const known = variables.length === 0;
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
+ };
142
232
 
143
- // What conditions may read: declared input fields and existing nodes.
144
- const inputSpec = document.input !== undefined && checkFormSpec(document.input, "input") === null ? document.input as FormSpec : undefined;
145
- const scope: ConditionScope = { input: inputSpec === undefined ? null : new Set(formFieldCodes(inputSpec)), nodes: roles };
233
+ // What steps read: an assign writes declared variables, inputs and waits read what exists.
234
+ for (const [id, raw] of Object.entries(nodes)) {
235
+ if (!isObject(raw) || !isObject(raw.config) || definitionProblem(raw)) continue;
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 });
260
+ }
146
261
 
147
262
  // Flows: ends, duplicates, conditions.
148
263
  const outgoing = new Map<string, string[]>();
@@ -163,18 +278,22 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
163
278
  continue;
164
279
  }
165
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 });
166
282
  const pair = `${source}\u0000${target}`;
167
283
  if (pairs.has(pair)) error("flow_duplicate", "another flow already connects these nodes", { flow: id });
168
284
  pairs.add(pair);
169
285
  outgoing.set(source, [...outgoing.get(source) ?? [], id]);
170
286
  incoming.set(target, [...incoming.get(target) ?? [], id]);
171
- if (raw.title !== undefined && (typeof raw.title !== "string" || raw.title.length > 200)) error("title_invalid", "flow title must be up to 200 characters", { flow: id });
287
+ if (raw.title !== undefined) {
288
+ const problem = checkLocalizedText(raw.title, 200);
289
+ if (problem) error("title_invalid", `flow title ${problem}`, { flow: id });
290
+ }
172
291
  if (raw.isDefault !== undefined && typeof raw.isDefault !== "boolean") error("default_invalid", "isDefault must be a boolean", { flow: id });
173
292
  const fromChoice = kinds.get(source) === "exclusiveGateway";
174
293
  if (raw.condition !== undefined) {
175
294
  if (!fromChoice) error("condition_not_allowed", "only flows leaving an exclusive gateway have conditions", { flow: id });
176
295
  else {
177
- const problem = checkCondition(raw.condition, 0, scope);
296
+ const problem = checkExpression(raw.condition, scopeOf(parents.get(source)));
178
297
  if (problem) error("condition_invalid", problem, { flow: id });
179
298
  }
180
299
  }
@@ -184,7 +303,19 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
184
303
  // Rules by node role.
185
304
  const starts = [...roles].filter(([, role]) => role === "start").map(([id]) => id);
186
305
  if (starts.length === 0) error("no_start", "process needs a start node");
187
- 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());
188
319
  for (const [id, role] of roles) {
189
320
  const ins = incoming.get(id)?.length ?? 0;
190
321
  const outs = outgoing.get(id) ?? [];
@@ -198,7 +329,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
198
329
  if (ins === 0) error("end_incoming", "end node needs an incoming flow", { node: id });
199
330
  break;
200
331
  case "activity":
201
- 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 });
202
333
  if (outs.length !== 1) error("activity_outgoing", "step needs exactly one outgoing flow; branch with a gateway", { node: id });
203
334
  break;
204
335
  case "boundary":
@@ -206,7 +337,7 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
206
337
  if (outs.length !== 1) error("boundary_outgoing", "boundary node needs exactly one outgoing flow", { node: id });
207
338
  break;
208
339
  case "gateway":
209
- 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))) {
210
341
  error("gateway_flows", "gateway needs incoming and outgoing flows and must split or join", { node: id });
211
342
  }
212
343
  if (kinds.get(id) === "exclusiveGateway" && outs.length > 1) checkChoice(id, outs, flows, error);
@@ -218,8 +349,10 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
218
349
  const forward = new Map<string, string[]>();
219
350
  for (const [id, list] of outgoing) forward.set(id, list.map((flow) => (flows[flow] as Json).target as string));
220
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.
221
353
  const backward = new Map<string, string[]>();
222
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]);
223
356
  const reached = walk(starts, forward);
224
357
  const ending = walk([...roles].filter(([, role]) => role === "end").map(([id]) => id), backward);
225
358
  for (const id of roles.keys()) {
@@ -234,11 +367,14 @@ export function validateProcess(document: unknown, options: ValidateProcessOptio
234
367
  for (const id of Object.keys(diagram.shapes)) if (!Object.hasOwn(nodes, id)) warning("diagram_orphan", "shape without a node", { node: id });
235
368
  for (const id of Object.keys(diagram.edges)) if (!Object.hasOwn(flows, id)) warning("diagram_orphan", "edge without a flow", { flow: id });
236
369
  }
370
+ if (!hasErrors(out) && toStructure(document as unknown as ProcessDocument) === undefined) {
371
+ warning("not_structured", "the graph cannot be drawn as a list of steps: a designer shows it read only");
372
+ }
237
373
  return out;
238
374
  }
239
375
 
240
376
  /** An exclusive gateway: every outgoing flow has a condition, except at most one default flow. */
241
- function checkChoice(id: string, outs: readonly string[], flows: Json, error: (code: ProcessIssueCode, message: string, at?: { node?: string; flow?: string }) => void): void {
377
+ function checkChoice(id: string, outs: readonly string[], flows: Json, error: (code: ProcessIssueCode, message: string, at?: IssueTarget) => void): void {
242
378
  const defaults = outs.filter((flow) => (flows[flow] as Json).isDefault === true);
243
379
  if (defaults.length > 1) error("default_invalid", "exclusive gateway can have only one default flow", { node: id });
244
380
  for (const flow of outs) {
@@ -251,37 +387,95 @@ function checkChoice(id: string, outs: readonly string[], flows: Json, error: (c
251
387
  }
252
388
  }
253
389
 
254
- interface ConditionScope {
255
- /** Input field codes; `null` when no input is declared and `$input` is not checked. */
256
- readonly input: ReadonlySet<string> | null;
257
- readonly nodes: ReadonlyMap<string, unknown>;
390
+ /** The settings this version of the framework runs; a setting that would be stored and ignored is an error. */
391
+ function checkSettings(value: unknown): string | null {
392
+ if (!isObject(value)) return "settings must be an object";
393
+ for (const [key, item] of Object.entries(value)) {
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";
404
+ } else if (key === "log") {
405
+ if (!isObject(item)) return "settings.log must be an object";
406
+ for (const [flag, on] of Object.entries(item)) {
407
+ if (flag !== "variables" && flag !== "toolPayloads" && flag !== "maskPersonal") return `settings.log.${flag} is unknown`;
408
+ if (typeof on !== "boolean") return `settings.log.${flag} must be a boolean`;
409
+ }
410
+ } else {
411
+ return `settings.${key} is not supported`;
412
+ }
413
+ }
414
+ return null;
415
+ }
416
+
417
+ type IssueTarget = { node?: string; flow?: string; variable?: string };
418
+
419
+ /** `extensions` of the root or a node: its form, then whether its namespaces have a check. */
420
+ function checkNamespaces(value: unknown, at: string, target: IssueTarget, options: ValidateProcessOptions, error: (code: ProcessIssueCode, message: string, at?: IssueTarget) => void): void {
421
+ if (value === undefined) return;
422
+ const problem = checkExtensions(value, at);
423
+ if (problem) {
424
+ error("extension_invalid", problem, target);
425
+ return;
426
+ }
427
+ if (options.extensions === undefined) return;
428
+ for (const namespace of Object.keys(value as Json)) {
429
+ if (!options.extensions(namespace)) error("extension_unknown", `no check is registered for the extensions namespace ${namespace}`, target);
430
+ }
431
+ }
432
+
433
+ function definitionProblem(raw: Json): boolean {
434
+ const definition = nodeKindOf(raw.kind);
435
+ return definition === undefined || definition.check(raw.config as Json) !== null;
258
436
  }
259
437
 
260
- function checkCondition(condition: unknown, depth: number, scope: ConditionScope): string | null {
261
- if (depth > PROCESS_LIMITS.conditionDepth) return `condition is nested deeper than ${PROCESS_LIMITS.conditionDepth}`;
262
- if (!isObject(condition)) return "condition must be an object";
263
- const keys = Object.keys(condition);
264
- if (keys.length === 1 && (keys[0] === "all" || keys[0] === "any")) {
265
- const list = condition[keys[0]];
266
- if (!Array.isArray(list) || list.length === 0) return `${keys[0]} must be a non-empty list`;
267
- for (const item of list) {
268
- const problem = checkCondition(item, depth + 1, scope);
269
- if (problem) return problem;
438
+ /** The expressions and paths in a step's `config`, against what the document declares. */
439
+ function configExpressionProblems(kind: string, config: Json, scope: ExpressionScope, writable: ReadonlySet<string> | undefined): string[] {
440
+ const out: string[] = [];
441
+ if (kind === "assign") {
442
+ for (const [index, item] of (config.assignments as readonly Json[]).entries()) {
443
+ const name = item.var as string;
444
+ if (writable && !writable.has(name)) out.push(`config.assignments[${index}].var: ${name} is not a declared local or out variable`);
445
+ const problem = checkExpression(item.value, scope);
446
+ if (problem) out.push(`config.assignments[${index}].value: ${problem}`);
270
447
  }
271
- return null;
272
448
  }
273
- if (keys.length === 1 && keys[0] === "not") return checkCondition(condition.not, depth + 1, scope);
274
- if (typeof condition.path !== "string" || !DATA_PATH.test(condition.path)) return "condition.path must look like $input.field, $vars.name or $nodes.Node.field";
275
- const [root, first] = condition.path.split(".");
276
- if (root === "$input" && scope.input && !scope.input.has(first!)) return `condition.path ${condition.path}: input has no field ${first}`;
277
- if (root === "$nodes" && !scope.nodes.has(first!)) return `condition.path ${condition.path}: no node ${first}`;
278
- if (typeof condition.op !== "string" || !OPS.has(condition.op as ConditionOp)) return `condition.op must be one of ${[...OPS].join(", ")}`;
279
- const op = condition.op as ConditionOp;
280
- if (keys.some((key) => key !== "path" && key !== "op" && key !== "value")) return "condition has unknown keys";
281
- if (UNARY.has(op)) return condition.value === undefined ? null : `${op} takes no value`;
282
- if (condition.value === undefined) return `${op} needs a value`;
283
- if (op === "in" && !Array.isArray(condition.value)) return "in needs a list value";
284
- return null;
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
+ }
454
+ for (const key of ["at", "correlation"]) {
455
+ if (typeof config[key] === "string") {
456
+ const problem = checkPath(config[key], scope);
457
+ if (problem) out.push(`config.${key}: ${problem}`);
458
+ }
459
+ }
460
+ return out;
461
+ }
462
+
463
+ function walkInput(value: unknown, at: string, scope: ExpressionScope, out: string[]): void {
464
+ if (typeof value === "string") {
465
+ if (NOT_YET.test(value)) out.push(`${at}: ${value} is not supported`);
466
+ else if (isPath(value)) {
467
+ const problem = checkPath(value, scope);
468
+ if (problem) out.push(`${at}: ${problem}`);
469
+ }
470
+ return;
471
+ }
472
+ if (isExpressionNode(value)) {
473
+ const problem = checkExpression(value, scope);
474
+ if (problem) out.push(`${at}: ${problem}`);
475
+ return;
476
+ }
477
+ if (Array.isArray(value)) value.forEach((item, index) => walkInput(item, `${at}[${index}]`, scope, out));
478
+ else if (isObject(value)) for (const [key, item] of Object.entries(value)) walkInput(item, `${at}.${key}`, scope, out);
285
479
  }
286
480
 
287
481
  function isDiagram(value: unknown): boolean {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.131.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",
@@ -1,44 +0,0 @@
1
- import type { Condition } from "./ProcessDocument";
2
- import { isPath, readPath, type InstanceData } from "./paths";
3
-
4
- /** Evaluates a flow condition. Pure: no calls, no side effects. */
5
- export function evaluateCondition(condition: Condition, data: InstanceData): boolean {
6
- if ("all" in condition) return condition.all.every((item) => evaluateCondition(item, data));
7
- if ("any" in condition) return condition.any.some((item) => evaluateCondition(item, data));
8
- if ("not" in condition) return !evaluateCondition(condition.not, data);
9
- const actual = readPath(data, condition.path);
10
- const expected = isPath(condition.value) ? readPath(data, condition.value) : condition.value;
11
- switch (condition.op) {
12
- case "eq": return same(actual, expected);
13
- case "ne": return !same(actual, expected);
14
- case "gt": return compare(actual, expected, (a, b) => a > b);
15
- case "gte": return compare(actual, expected, (a, b) => a >= b);
16
- case "lt": return compare(actual, expected, (a, b) => a < b);
17
- case "lte": return compare(actual, expected, (a, b) => a <= b);
18
- case "in": return Array.isArray(expected) && expected.some((item) => same(actual, item));
19
- case "contains":
20
- if (typeof actual === "string") return typeof expected === "string" && actual.includes(expected);
21
- return Array.isArray(actual) && actual.some((item) => same(item, expected));
22
- case "empty": return isEmpty(actual);
23
- case "notEmpty": return !isEmpty(actual);
24
- }
25
- }
26
-
27
- function same(a: unknown, b: unknown): boolean {
28
- if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return Number(a) === Number(b);
29
- return JSON.stringify(a) === JSON.stringify(b);
30
- }
31
-
32
- /** Numbers compare as numbers (a decimal arrives as a string), other strings as strings (ISO dates compare right). */
33
- function compare(a: unknown, b: unknown, test: (a: number | string, b: number | string) => boolean): boolean {
34
- if (a === null || a === undefined || b === null || b === undefined) return false;
35
- const na = Number(a), nb = Number(b);
36
- if (typeof a !== "boolean" && typeof b !== "boolean" && a !== "" && b !== "" && Number.isFinite(na) && Number.isFinite(nb)) return test(na, nb);
37
- return typeof a === "string" && typeof b === "string" ? test(a, b) : false;
38
- }
39
-
40
- function isEmpty(value: unknown): boolean {
41
- if (value === null || value === undefined || value === "") return true;
42
- if (Array.isArray(value)) return value.length === 0;
43
- return typeof value === "object" && Object.keys(value).length === 0;
44
- }