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.
- package/cli/templates/process.ts +1 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
- package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
- package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
- package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
- package/core/workflow/definitions_module/access/Process.references.ts +1 -1
- package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
- package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +27 -16
- package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
- package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
- package/core/workflow/definitions_module/http/Processes.controller.ts +66 -23
- package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
- package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
- package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +35 -10
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +90 -28
- package/core/workflow/generated/deps.ts +15 -3
- package/core/workflow/index.ts +4 -2
- package/core/workflow/internal/WorkflowLanguages.ts +35 -0
- package/core/workflow/internal/canonicalJson.ts +8 -0
- package/core/workflow/internal/http.ts +20 -1
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -2
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +3 -3
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +6 -6
- package/core/workflow/runtime_module/http/Instances.controller.ts +30 -13
- package/core/workflow/runtime_module/internal/ChildCalls.ts +186 -0
- package/core/workflow/runtime_module/internal/GraphCache.ts +7 -1
- package/core/workflow/runtime_module/internal/RunLimits.ts +124 -0
- package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
- package/core/workflow/runtime_module/internal/TimerStep.ts +11 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +43 -12
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +559 -123
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +39 -10
- package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
- package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
- package/core/workflow/runtime_module/model/WfInstance.model.ts +34 -6
- package/core/workflow/runtime_module/model/WfJob.model.ts +29 -4
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfToken.model.ts +15 -3
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +43 -4
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +56 -13
- package/core/workflow/tasks_module/events/TaskEvents.ts +4 -8
- package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
- package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
- package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -4
- package/library/openapi/codegen.ts +3 -0
- package/library/validation/types/ValidatorOptions.ts +2 -0
- package/library/workflow/ProcessDocument.ts +73 -23
- package/library/workflow/expressionSyntax.ts +243 -0
- package/library/workflow/expressions.ts +347 -0
- package/library/workflow/formFields.ts +118 -33
- package/library/workflow/index.ts +48 -9
- package/library/workflow/localized.ts +35 -0
- package/library/workflow/masking.ts +72 -0
- package/library/workflow/mermaid.ts +39 -13
- package/library/workflow/nodeKinds.ts +147 -17
- package/library/workflow/palette.ts +110 -0
- package/library/workflow/paths.ts +37 -6
- package/library/workflow/structure.ts +311 -0
- package/library/workflow/upgradeDocument.ts +84 -0
- package/library/workflow/validateProcess.ts +246 -52
- package/package.json +1 -1
- package/library/workflow/conditions.ts +0 -44
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
| "
|
|
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
|
|
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
|
-
|
|
56
|
-
const
|
|
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:
|
|
84
|
-
const warning = (code: ProcessIssueCode, message: string, 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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
144
|
-
const
|
|
145
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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?:
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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 (
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return
|
|
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.
|
|
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
|
-
}
|