bazis 0.131.0 → 0.132.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 +26 -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 +65 -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 +33 -10
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +88 -28
- package/core/workflow/generated/deps.ts +11 -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 +0 -2
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +2 -2
- package/core/workflow/runtime_module/http/Instances.controller.ts +16 -11
- package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
- package/core/workflow/runtime_module/internal/ToolStep.ts +40 -9
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +60 -10
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +2 -4
- 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 +28 -3
- package/core/workflow/runtime_module/model/WfJob.model.ts +17 -1
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfToken.model.ts +11 -1
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +5 -1
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +9 -7
- 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 +3 -3
- package/library/openapi/codegen.ts +3 -0
- package/library/validation/types/ValidatorOptions.ts +2 -0
- package/library/workflow/ProcessDocument.ts +59 -23
- package/library/workflow/expressionSyntax.ts +243 -0
- package/library/workflow/expressions.ts +343 -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 +58 -0
- package/library/workflow/mermaid.ts +18 -8
- package/library/workflow/nodeKinds.ts +88 -17
- package/library/workflow/palette.ts +108 -0
- package/library/workflow/paths.ts +31 -6
- package/library/workflow/structure.ts +282 -0
- package/library/workflow/upgradeDocument.ts +84 -0
- package/library/workflow/validateProcess.ts +130 -48
- package/package.json +1 -1
- package/library/workflow/conditions.ts +0 -44
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
export type FormFieldType = "string" | "text" | "number" | "integer" | "boolean" | "date" | "datetime" | "enum";
|
|
1
|
+
import { checkLocalizedText, type LocalizedText } from "./localized";
|
|
3
2
|
|
|
3
|
+
/** The types of variables and of task form fields. */
|
|
4
|
+
export type FieldType = "string" | "text" | "number" | "integer" | "boolean" | "date" | "datetime" | "enum" | "object" | "list" | "ref";
|
|
5
|
+
|
|
6
|
+
/** A field of a task form; a variable of a process is the same with a direction. */
|
|
4
7
|
export interface FormField {
|
|
5
8
|
/** The key in the value: `amount`, `reason`. */
|
|
6
|
-
readonly
|
|
7
|
-
readonly type:
|
|
8
|
-
readonly title?:
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly type: FieldType;
|
|
11
|
+
readonly title?: LocalizedText;
|
|
12
|
+
readonly default?: unknown;
|
|
9
13
|
readonly required?: boolean;
|
|
10
14
|
/** Allowed values of an `enum` field. */
|
|
11
15
|
readonly values?: readonly (string | number)[];
|
|
16
|
+
/** For `ref`: the kind of reference, for example `orm.entity:Order`. */
|
|
17
|
+
readonly ref?: string;
|
|
18
|
+
/** Personal data: masked in the history under `log.maskPersonal`. */
|
|
19
|
+
readonly personal?: boolean;
|
|
20
|
+
/** The application's rules by namespace; `WorkflowInputValidator` reads them. */
|
|
21
|
+
readonly extensions?: Readonly<Record<string, unknown>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `in`: the input of a start. `local`: inside the process. `out`: what the process gives back. */
|
|
25
|
+
export type VariableDirection = "in" | "local" | "out";
|
|
26
|
+
|
|
27
|
+
export interface VariableSpec extends FormField {
|
|
28
|
+
readonly direction: VariableDirection;
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
/** The fields of a process input or a task form. */
|
|
@@ -24,8 +41,12 @@ export interface FormIssue {
|
|
|
24
41
|
readonly message: string;
|
|
25
42
|
}
|
|
26
43
|
|
|
27
|
-
const TYPES = new Set<
|
|
28
|
-
const
|
|
44
|
+
const TYPES = new Set<FieldType>(["string", "text", "number", "integer", "boolean", "date", "datetime", "enum", "object", "list", "ref"]);
|
|
45
|
+
const DIRECTIONS = new Set<VariableDirection>(["in", "local", "out"]);
|
|
46
|
+
/** A variable or field name. */
|
|
47
|
+
export const FIELD_NAME = /^[a-z][A-Za-z0-9_]{0,39}$/;
|
|
48
|
+
const REF_KIND = /^[a-z][A-Za-z0-9_.:-]{0,127}$/;
|
|
49
|
+
const NAMESPACE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
29
50
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
30
51
|
const MAX_FIELDS = 200;
|
|
31
52
|
|
|
@@ -33,6 +54,46 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
33
54
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
55
|
}
|
|
35
56
|
|
|
57
|
+
/** What is wrong with `extensions`, or `null`: an object of namespace → anything. */
|
|
58
|
+
export function checkExtensions(value: unknown, at: string): string | null {
|
|
59
|
+
if (value === undefined) return null;
|
|
60
|
+
if (!isObject(value)) return `${at} must be an object of namespace → data`;
|
|
61
|
+
const bad = Object.keys(value).find((key) => !NAMESPACE.test(key));
|
|
62
|
+
return bad === undefined ? null : `${at} has an invalid namespace ${JSON.stringify(bad)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What is wrong with one field, or `null`. */
|
|
66
|
+
function checkField(field: unknown, where: string): string | null {
|
|
67
|
+
if (!isObject(field)) return `${where} must be an object`;
|
|
68
|
+
if (typeof field.name !== "string" || !FIELD_NAME.test(field.name)) return `${where}.name must match ${FIELD_NAME.source}`;
|
|
69
|
+
if (typeof field.type !== "string" || !TYPES.has(field.type as FieldType)) return `${where}.type must be one of ${[...TYPES].join(", ")}`;
|
|
70
|
+
if (field.title !== undefined) {
|
|
71
|
+
const problem = checkLocalizedText(field.title, 200);
|
|
72
|
+
if (problem) return `${where}.title ${problem}`;
|
|
73
|
+
}
|
|
74
|
+
if (field.required !== undefined && typeof field.required !== "boolean") return `${where}.required must be a boolean`;
|
|
75
|
+
if (field.personal !== undefined && typeof field.personal !== "boolean") return `${where}.personal must be a boolean`;
|
|
76
|
+
if (field.type === "enum") {
|
|
77
|
+
if (!Array.isArray(field.values) || field.values.length === 0 || !field.values.every((item) => typeof item === "string" || typeof item === "number")) {
|
|
78
|
+
return `${where}.values must be a non-empty list of strings or numbers`;
|
|
79
|
+
}
|
|
80
|
+
} else if (field.values !== undefined) {
|
|
81
|
+
return `${where}.values is only for enum fields`;
|
|
82
|
+
}
|
|
83
|
+
if (field.type === "ref") {
|
|
84
|
+
if (typeof field.ref !== "string" || !REF_KIND.test(field.ref)) return `${where}.ref must name a kind of reference`;
|
|
85
|
+
} else if (field.ref !== undefined) {
|
|
86
|
+
return `${where}.ref is only for ref fields`;
|
|
87
|
+
}
|
|
88
|
+
const extensions = checkExtensions(field.extensions, `${where}.extensions`);
|
|
89
|
+
if (extensions) return extensions;
|
|
90
|
+
if (field.default !== undefined && field.default !== null) {
|
|
91
|
+
const problem = checkType(field as unknown as FormField, field.default);
|
|
92
|
+
if (problem) return `${where}.default: ${problem.message}`;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
36
97
|
/** Checks the shape of a form spec; returns a problem or `null`. `at` names it in the text. */
|
|
37
98
|
export function checkFormSpec(value: unknown, at: string): string | null {
|
|
38
99
|
if (!isObject(value) || !Array.isArray(value.fields)) return `${at} must be an object with a fields list`;
|
|
@@ -40,59 +101,77 @@ export function checkFormSpec(value: unknown, at: string): string | null {
|
|
|
40
101
|
const seen = new Set<string>();
|
|
41
102
|
for (const [index, field] of value.fields.entries()) {
|
|
42
103
|
const where = `${at}.fields[${index}]`;
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
if (field.required !== undefined && typeof field.required !== "boolean") return `${where}.required must be a boolean`;
|
|
50
|
-
if (field.type === "enum") {
|
|
51
|
-
if (!Array.isArray(field.values) || field.values.length === 0 || !field.values.every((item) => typeof item === "string" || typeof item === "number")) {
|
|
52
|
-
return `${where}.values must be a non-empty list of strings or numbers`;
|
|
53
|
-
}
|
|
54
|
-
} else if (field.values !== undefined) {
|
|
55
|
-
return `${where}.values is only for enum fields`;
|
|
56
|
-
}
|
|
104
|
+
const problem = checkField(field, where);
|
|
105
|
+
if (problem) return problem;
|
|
106
|
+
if (isObject(field) && Object.hasOwn(field, "direction")) return `${where}: a form field has no direction`;
|
|
107
|
+
const name = (field as FormField).name;
|
|
108
|
+
if (seen.has(name)) return `${where}.name ${name} is repeated`;
|
|
109
|
+
seen.add(name);
|
|
57
110
|
}
|
|
58
111
|
return null;
|
|
59
112
|
}
|
|
60
113
|
|
|
61
|
-
/**
|
|
62
|
-
export function
|
|
63
|
-
|
|
114
|
+
/** Every problem of the variables of a document, with the variable it is about. */
|
|
115
|
+
export function variableProblems(value: unknown): readonly { readonly variable?: string; readonly message: string }[] {
|
|
116
|
+
if (!Array.isArray(value)) return [{ message: "variables must be a list" }];
|
|
117
|
+
if (value.length > MAX_FIELDS) return [{ message: `there are more than ${MAX_FIELDS} variables` }];
|
|
118
|
+
const out: { variable?: string; message: string }[] = [];
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
for (const [index, variable] of value.entries()) {
|
|
121
|
+
const where = `variables[${index}]`;
|
|
122
|
+
const name = isObject(variable) && typeof variable.name === "string" ? variable.name : undefined;
|
|
123
|
+
const at = name === undefined ? {} : { variable: name };
|
|
124
|
+
const problem = checkField(variable, where);
|
|
125
|
+
if (problem) {
|
|
126
|
+
out.push({ ...at, message: problem });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const spec = variable as Record<string, unknown>;
|
|
130
|
+
if (typeof spec.direction !== "string" || !DIRECTIONS.has(spec.direction as VariableDirection)) out.push({ ...at, message: `${where}.direction must be in, local or out` });
|
|
131
|
+
else if (spec.required !== undefined && spec.direction !== "in") out.push({ ...at, message: `${where}.required is only for in variables` });
|
|
132
|
+
if (seen.has(name!)) out.push({ ...at, message: `${where}.name ${name} is repeated` });
|
|
133
|
+
seen.add(name!);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The field names of a form spec. */
|
|
139
|
+
export function formFieldNames(spec: FormSpec): string[] {
|
|
140
|
+
return spec.fields.map((field) => field.name);
|
|
64
141
|
}
|
|
65
142
|
|
|
66
143
|
/**
|
|
67
144
|
* Checks a value against a form spec: required fields, types, enum values and
|
|
68
|
-
* unknown fields
|
|
69
|
-
* Without a spec the form takes no
|
|
145
|
+
* unknown fields; a missing field takes its `default`. Returns the value with
|
|
146
|
+
* only the declared fields and every issue. Without a spec the form takes no
|
|
147
|
+
* data: any field is unknown.
|
|
70
148
|
*/
|
|
71
149
|
export function validateForm(spec: FormSpec | undefined, value: unknown): { readonly value: Record<string, unknown>; readonly issues: readonly FormIssue[] } {
|
|
72
150
|
const issues: FormIssue[] = [];
|
|
73
151
|
const source = value === undefined || value === null ? {} : value;
|
|
74
152
|
if (!isObject(source)) return { value: {}, issues: [{ field: "", code: "type", message: "value must be an object" }] };
|
|
75
153
|
const fields = spec?.fields ?? [];
|
|
76
|
-
const declared = new Set(fields.map((field) => field.
|
|
154
|
+
const declared = new Set(fields.map((field) => field.name));
|
|
77
155
|
for (const key of Object.keys(source)) {
|
|
78
156
|
if (!declared.has(key)) issues.push({ field: key, code: "unknown", message: `field ${key} is not declared` });
|
|
79
157
|
}
|
|
80
158
|
const out: Record<string, unknown> = {};
|
|
81
159
|
for (const field of fields) {
|
|
82
|
-
const
|
|
160
|
+
const given = Object.hasOwn(source, field.name) ? source[field.name] : undefined;
|
|
161
|
+
const item = given === undefined || given === null || given === "" ? field.default : given;
|
|
83
162
|
if (item === undefined || item === null || item === "") {
|
|
84
|
-
if (field.required === true) issues.push({ field: field.
|
|
163
|
+
if (field.required === true) issues.push({ field: field.name, code: "required", message: `field ${field.name} is required` });
|
|
85
164
|
continue;
|
|
86
165
|
}
|
|
87
166
|
const problem = checkType(field, item);
|
|
88
|
-
if (problem) issues.push({ field: field.
|
|
89
|
-
else out[field.
|
|
167
|
+
if (problem) issues.push({ field: field.name, ...problem });
|
|
168
|
+
else out[field.name] = item;
|
|
90
169
|
}
|
|
91
170
|
return { value: out, issues };
|
|
92
171
|
}
|
|
93
172
|
|
|
94
173
|
function checkType(field: FormField, value: unknown): { code: "type" | "enum"; message: string } | null {
|
|
95
|
-
const wrong = (expected: string) => ({ code: "type" as const, message: `field ${field.
|
|
174
|
+
const wrong = (expected: string) => ({ code: "type" as const, message: `field ${field.name} must be ${expected}` });
|
|
96
175
|
switch (field.type) {
|
|
97
176
|
case "string":
|
|
98
177
|
case "text":
|
|
@@ -108,6 +187,12 @@ function checkType(field: FormField, value: unknown): { code: "type" | "enum"; m
|
|
|
108
187
|
case "datetime":
|
|
109
188
|
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? null : wrong("an ISO 8601 date and time");
|
|
110
189
|
case "enum":
|
|
111
|
-
return (field.values ?? []).includes(value as string | number) ? null : { code: "enum", message: `field ${field.
|
|
190
|
+
return (field.values ?? []).includes(value as string | number) ? null : { code: "enum", message: `field ${field.name} must be one of ${(field.values ?? []).join(", ")}` };
|
|
191
|
+
case "object":
|
|
192
|
+
return isObject(value) ? null : wrong("an object");
|
|
193
|
+
case "list":
|
|
194
|
+
return Array.isArray(value) ? null : wrong("a list");
|
|
195
|
+
case "ref":
|
|
196
|
+
return (typeof value === "string" && value !== "") || typeof value === "number" ? null : wrong("a reference: a string or a number");
|
|
112
197
|
}
|
|
113
198
|
}
|
|
@@ -1,26 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The process document format `bazis.workflow/
|
|
3
|
-
* node kinds, validation, data paths and
|
|
4
|
-
*
|
|
2
|
+
* The process document format `bazis.workflow/v2` (WORKFLOW-002 §3): types,
|
|
3
|
+
* node kinds, validation, data paths and expressions, the upgrade from version 1
|
|
4
|
+
* and the core of a designer. Pure functions without DI or a database: usable by
|
|
5
|
+
* a designer, a CLI or a browser.
|
|
5
6
|
*/
|
|
6
7
|
export {
|
|
7
8
|
PROCESS_SCHEMA_VERSION,
|
|
8
9
|
emptyProcess,
|
|
10
|
+
initialVarsOf,
|
|
11
|
+
inputSpecOf,
|
|
12
|
+
outputOf,
|
|
9
13
|
type Bounds,
|
|
10
|
-
type Condition,
|
|
11
|
-
type ConditionOp,
|
|
12
14
|
type NodeKind,
|
|
13
15
|
type Point,
|
|
14
16
|
type ProcessDiagram,
|
|
15
17
|
type ProcessDocument,
|
|
16
18
|
type ProcessFlow,
|
|
19
|
+
type ProcessLogSettings,
|
|
17
20
|
type ProcessNode,
|
|
21
|
+
type ProcessSettings,
|
|
18
22
|
} from "./ProcessDocument";
|
|
19
|
-
export { NODE_KINDS, nodeKindOf, ISO_DURATION, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
|
|
23
|
+
export { NODE_KINDS, nodeKindOf, ISO_DURATION, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
|
|
20
24
|
export { PROCESS_LIMITS, validateProcess, hasErrors, type ProcessIssue, type ProcessIssueCode, type ValidateProcessOptions } from "./validateProcess";
|
|
21
|
-
export { DATA_PATH, isPath, readPath, resolveInput, type InstanceData } from "./paths";
|
|
22
|
-
export {
|
|
25
|
+
export { DATA_PATH, DATA_PATHS, isPath, readPath, resolveInput, type InstanceData } from "./paths";
|
|
26
|
+
export {
|
|
27
|
+
EXPRESSION_FUNCTIONS,
|
|
28
|
+
EXPRESSION_LIMITS,
|
|
29
|
+
checkExpression,
|
|
30
|
+
checkPath,
|
|
31
|
+
evaluateExpression,
|
|
32
|
+
expressionFunction,
|
|
33
|
+
isExpressionNode,
|
|
34
|
+
isTruthy,
|
|
35
|
+
type Expr,
|
|
36
|
+
type ExprCall,
|
|
37
|
+
type ExpressionScope,
|
|
38
|
+
type ExprFunction,
|
|
39
|
+
type ExprFunctionName,
|
|
40
|
+
type ExprPath,
|
|
41
|
+
type ExprType,
|
|
42
|
+
} from "./expressions";
|
|
43
|
+
export { ExpressionSyntaxError, parseExpression, printExpression } from "./expressionSyntax";
|
|
23
44
|
export { durationMs } from "./duration";
|
|
24
45
|
export { parseCron, type CronSchedule } from "./cron";
|
|
25
46
|
export { processToMermaid } from "./mermaid";
|
|
26
|
-
export {
|
|
47
|
+
export { PERSONAL_MASK, maskByMapping, maskBySchema, masksPersonal, personalPaths, personalVariables, readsPersonal } from "./masking";
|
|
48
|
+
export { allTexts, checkLocalizedText, textOf, type LocalizedText } from "./localized";
|
|
49
|
+
export {
|
|
50
|
+
FIELD_NAME,
|
|
51
|
+
checkExtensions,
|
|
52
|
+
checkFormSpec,
|
|
53
|
+
formFieldNames,
|
|
54
|
+
validateForm,
|
|
55
|
+
variableProblems,
|
|
56
|
+
type FieldType,
|
|
57
|
+
type FormField,
|
|
58
|
+
type FormIssue,
|
|
59
|
+
type FormSpec,
|
|
60
|
+
type VariableDirection,
|
|
61
|
+
type VariableSpec,
|
|
62
|
+
} from "./formFields";
|
|
63
|
+
export { PROCESS_SCHEMA_VERSION_V1, upgradeCondition, upgradeDocument, type ConditionV1, type FormFieldV1, type ProcessDocumentV1 } from "./upgradeDocument";
|
|
64
|
+
export { fromStructure, isStructured, toStructure, type ProcessStructure, type StructureBoundary, type StructureBranch, type StructureStart, type StructureStep } from "./structure";
|
|
65
|
+
export { formOf, processPalette, type PaletteNodeKind, type PaletteTool, type ProcessPalette, type StepForm, type StepFormField } from "./palette";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** A text in several languages: language → text. A plain string is in the default language. */
|
|
2
|
+
export type LocalizedText = string | Readonly<Record<string, string>>;
|
|
3
|
+
|
|
4
|
+
const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
5
|
+
|
|
6
|
+
/** The text for `language`: its exact entry, its base language (`ru` for `ru-RU`), then `fallback`, then the first one. */
|
|
7
|
+
export function textOf(text: LocalizedText | null | undefined, language: string, fallback = language): string {
|
|
8
|
+
if (text === undefined || text === null) return "";
|
|
9
|
+
if (typeof text === "string") return text;
|
|
10
|
+
const own = (key: string) => (Object.hasOwn(text, key) ? text[key] : undefined);
|
|
11
|
+
return own(language) ?? own(language.split("-")[0]!) ?? own(fallback) ?? Object.values(text)[0] ?? "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Every text of a value, the default language first: what a search looks through and a list sorts by. */
|
|
15
|
+
export function allTexts(text: LocalizedText | null | undefined, language: string): string {
|
|
16
|
+
if (text === undefined || text === null) return "";
|
|
17
|
+
if (typeof text === "string") return text;
|
|
18
|
+
const first = textOf(text, language);
|
|
19
|
+
return [first, ...Object.values(text).filter((item) => item !== first)].join("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What is wrong with a value as a localized text, or `null`. `min` — 1 for a required text. */
|
|
23
|
+
export function checkLocalizedText(value: unknown, max: number, min = 1): string | null {
|
|
24
|
+
const fits = (text: unknown) => typeof text === "string" && text.trim().length >= min && text.length <= max;
|
|
25
|
+
const size = min === 0 ? `up to ${max}` : `${min}–${max}`;
|
|
26
|
+
if (typeof value === "string") return fits(value) ? null : `must be ${size} characters`;
|
|
27
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return "must be a string or an object of language → text";
|
|
28
|
+
const entries = Object.entries(value);
|
|
29
|
+
if (entries.length === 0) return "needs at least one language";
|
|
30
|
+
for (const [language, text] of entries) {
|
|
31
|
+
if (!LANGUAGE.test(language)) return `has an invalid language ${JSON.stringify(language)}`;
|
|
32
|
+
if (!fits(text)) return `${language} must be ${size} characters`;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isExpressionNode } from "./expressions";
|
|
2
|
+
import { isPath } from "./paths";
|
|
3
|
+
import type { ProcessDocument } from "./ProcessDocument";
|
|
4
|
+
|
|
5
|
+
/** What a personal value shows as in the history. */
|
|
6
|
+
export const PERSONAL_MASK = "***";
|
|
7
|
+
|
|
8
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Whether personal data is masked in the history of this version: yes unless `log.maskPersonal` is `false`. */
|
|
13
|
+
export function masksPersonal(document: Pick<ProcessDocument, "settings">): boolean {
|
|
14
|
+
return document.settings?.log?.maskPersonal !== false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The paths of the personal variables: `$input.email`, `$vars.email`. */
|
|
18
|
+
export function personalPaths(document: Pick<ProcessDocument, "variables">): ReadonlySet<string> {
|
|
19
|
+
return new Set((document.variables ?? []).filter((variable) => variable.personal === true).map((variable) => `${variable.direction === "in" ? "$input" : "$vars"}.${variable.name}`));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The names of the personal `local` and `out` variables. */
|
|
23
|
+
export function personalVariables(document: Pick<ProcessDocument, "variables">): ReadonlySet<string> {
|
|
24
|
+
return new Set((document.variables ?? []).filter((variable) => variable.personal === true && variable.direction !== "in").map((variable) => variable.name));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function touches(path: string, personal: ReadonlySet<string>): boolean {
|
|
28
|
+
for (const root of personal) if (path === root || path.startsWith(`${root}.`)) return true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether an expression or a path string reads a personal variable. */
|
|
33
|
+
export function readsPersonal(value: unknown, personal: ReadonlySet<string>): boolean {
|
|
34
|
+
if (isPath(value)) return touches(value, personal);
|
|
35
|
+
if (!isExpressionNode(value)) return false;
|
|
36
|
+
if ("path" in value) return typeof value.path === "string" && touches(value.path, personal);
|
|
37
|
+
return Array.isArray(value.args) && value.args.some((arg) => readsPersonal(arg, personal));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A resolved step input with what its mapping took from personal variables masked. */
|
|
41
|
+
export function maskByMapping(mapping: unknown, value: unknown, personal: ReadonlySet<string>): unknown {
|
|
42
|
+
if (personal.size === 0) return value;
|
|
43
|
+
if (readsPersonal(mapping, personal)) return PERSONAL_MASK;
|
|
44
|
+
if (isExpressionNode(mapping)) return value;
|
|
45
|
+
if (Array.isArray(mapping) && Array.isArray(value)) return value.map((item, index) => maskByMapping(mapping[index], item, personal));
|
|
46
|
+
if (isObject(mapping) && isObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskByMapping(mapping[key], item, personal)]));
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A value with the fields its JSON Schema marks `x-personal` masked. */
|
|
51
|
+
export function maskBySchema(value: unknown, schema: unknown): unknown {
|
|
52
|
+
if (!isObject(schema) || value === null || value === undefined) return value;
|
|
53
|
+
if (schema["x-personal"] === true) return PERSONAL_MASK;
|
|
54
|
+
if (Array.isArray(value)) return value.map((item) => maskBySchema(item, schema.items));
|
|
55
|
+
if (!isObject(value) || !isObject(schema.properties)) return value;
|
|
56
|
+
const properties = schema.properties;
|
|
57
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, Object.hasOwn(properties, key) ? maskBySchema(item, properties[key]) : item]));
|
|
58
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Expr } from "./expressions";
|
|
2
|
+
import { printExpression } from "./expressionSyntax";
|
|
3
|
+
import { textOf } from "./localized";
|
|
4
|
+
import type { NodeKind, ProcessDocument } from "./ProcessDocument";
|
|
2
5
|
|
|
3
6
|
/** Words Mermaid reads as syntax: such node ids are renamed. */
|
|
4
7
|
const RESERVED = new Set(["end", "graph", "flowchart", "subgraph", "style", "class", "classdef", "click", "default", "linkstyle", "direction"]);
|
|
@@ -10,6 +13,7 @@ const SHAPES: Record<NodeKind, readonly [open: string, close: string]> = {
|
|
|
10
13
|
end: ["((", "))"],
|
|
11
14
|
userTask: ["[", "]"],
|
|
12
15
|
serviceTask: ["[[", "]]"],
|
|
16
|
+
assign: ["[/", "/]"],
|
|
13
17
|
timerCatch: ["{{", "}}"],
|
|
14
18
|
eventWait: ["{{", "}}"],
|
|
15
19
|
exclusiveGateway: ["{", "}"],
|
|
@@ -42,14 +46,14 @@ export function processToMermaid(document: ProcessDocument, direction: "LR" | "T
|
|
|
42
46
|
for (const [nodeId, node] of Object.entries(document.nodes)) {
|
|
43
47
|
const [open, close] = SHAPES[node.kind] ?? ["[", "]"];
|
|
44
48
|
const detail = detailOf(node.kind, node.config ?? {});
|
|
45
|
-
lines.push(` ${idOf(nodeId)}${open}"${label(node.title || nodeId)}${detail === undefined ? "" : `<br/><small>${label(detail)}</small>`}"${close}`);
|
|
49
|
+
lines.push(` ${idOf(nodeId)}${open}"${label(textOf(node.title, "en") || nodeId)}${detail === undefined ? "" : `<br/><small>${label(detail)}</small>`}"${close}`);
|
|
46
50
|
}
|
|
47
51
|
for (const [nodeId, node] of Object.entries(document.nodes)) {
|
|
48
52
|
if (node.attachedTo !== undefined && document.nodes[node.attachedTo] !== undefined) lines.push(` ${idOf(node.attachedTo)} -.- ${idOf(nodeId)}`);
|
|
49
53
|
}
|
|
50
54
|
for (const flow of Object.values(document.flows)) {
|
|
51
55
|
if (document.nodes[flow.source] === undefined || document.nodes[flow.target] === undefined) continue;
|
|
52
|
-
const text = flow.title
|
|
56
|
+
const text = flow.title !== undefined ? textOf(flow.title, "en") : flow.condition !== undefined ? expressionText(flow.condition) : flow.isDefault === true ? "default" : undefined;
|
|
53
57
|
lines.push(` ${idOf(flow.source)} -->${text === undefined ? "" : `|"${label(text)}"|`} ${idOf(flow.target)}`);
|
|
54
58
|
}
|
|
55
59
|
return `${lines.join("\n")}\n`;
|
|
@@ -70,15 +74,21 @@ function detailOf(kind: NodeKind, config: Readonly<Record<string, unknown>>): st
|
|
|
70
74
|
const assignee = config.assignee as { role?: unknown; user?: unknown } | undefined;
|
|
71
75
|
return assignee === undefined ? undefined : text(assignee.role) !== undefined ? `role ${assignee.role}` : text(assignee.user) === undefined ? undefined : `user ${assignee.user}`;
|
|
72
76
|
}
|
|
77
|
+
case "assign": {
|
|
78
|
+
const list = config.assignments;
|
|
79
|
+
return Array.isArray(list) ? list.map((item: { var?: unknown }) => String(item.var)).join(", ") : undefined;
|
|
80
|
+
}
|
|
73
81
|
default: return undefined;
|
|
74
82
|
}
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
/** A condition in the designer's syntax; one that does not print shows as JSON. */
|
|
86
|
+
function expressionText(expression: Expr): string {
|
|
87
|
+
try {
|
|
88
|
+
return printExpression(expression);
|
|
89
|
+
} catch {
|
|
90
|
+
return JSON.stringify(expression);
|
|
91
|
+
}
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
/** Text inside a quoted Mermaid label: quotes and markup are entities. */
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
import { parseCron } from "./cron";
|
|
2
|
-
import { checkFormSpec } from "./formFields";
|
|
2
|
+
import { FIELD_NAME, checkFormSpec } from "./formFields";
|
|
3
3
|
import { DATA_PATH } from "./paths";
|
|
4
4
|
import type { NodeKind } from "./ProcessDocument";
|
|
5
5
|
|
|
6
6
|
/** The role of a node in the graph: it sets the rules of incoming and outgoing flows. */
|
|
7
7
|
export type NodeRole = "start" | "activity" | "gateway" | "boundary" | "end";
|
|
8
8
|
|
|
9
|
+
/** A JSON Schema, as the palette gives it to a designer. */
|
|
10
|
+
export type JsonSchema = Readonly<Record<string, unknown>>;
|
|
11
|
+
|
|
9
12
|
export interface NodeKindDefinition {
|
|
10
13
|
readonly kind: NodeKind;
|
|
11
14
|
readonly role: NodeRole;
|
|
12
15
|
/** Checks the node `config`; returns a problem or `null`. */
|
|
13
16
|
readonly check: (config: Record<string, unknown>) => string | null;
|
|
17
|
+
/** The node `config` as a JSON Schema: what a designer builds the form of a step from. */
|
|
18
|
+
readonly config: JsonSchema;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
/** ISO 8601 duration without months and years: P1D, PT2H30M, PT45S. */
|
|
17
22
|
export const ISO_DURATION = /^P(?!$)(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
|
|
18
23
|
/** An event key or a Tool name: `payments.received`, `payments.charge`. */
|
|
19
24
|
export const DOTTED_KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
|
|
20
|
-
/** A
|
|
21
|
-
|
|
25
|
+
/** A parameter of a Tool. */
|
|
26
|
+
const PARAMETER = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
22
27
|
const ROLE = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
23
28
|
const USER = /^[^\s:][^\s]{0,127}$/;
|
|
24
29
|
|
|
@@ -74,7 +79,7 @@ function inputMapping(config: Record<string, unknown>): string | null {
|
|
|
74
79
|
const value = config.input;
|
|
75
80
|
if (value === undefined) return null;
|
|
76
81
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return "config.input must be an object";
|
|
77
|
-
return Object.keys(value).every((key) =>
|
|
82
|
+
return Object.keys(value).every((key) => PARAMETER.test(key)) ? null : "config.input has an invalid parameter name";
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/** Automatic retries of a failed Tool call. */
|
|
@@ -84,23 +89,89 @@ function retry(config: Record<string, unknown>): string | null {
|
|
|
84
89
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return "config.retry must be an object";
|
|
85
90
|
const policy = value as Record<string, unknown>;
|
|
86
91
|
if (!Number.isSafeInteger(policy.maxAttempts) || (policy.maxAttempts as number) < 1 || (policy.maxAttempts as number) > 10) return "config.retry.maxAttempts must be 1–10";
|
|
92
|
+
if (policy.growth !== undefined && policy.growth !== "fixed" && policy.growth !== "exponential") return "config.retry.growth must be fixed or exponential";
|
|
93
|
+
if (policy.factor !== undefined) {
|
|
94
|
+
if (policy.growth === "fixed") return "config.retry.factor is only for exponential growth";
|
|
95
|
+
if (typeof policy.factor !== "number" || !(policy.factor >= 1) || policy.factor > 100) return "config.retry.factor must be a number 1–100";
|
|
96
|
+
}
|
|
87
97
|
return string(policy, "backoff", ISO_DURATION, false)?.replace("config.", "config.retry.") ?? null;
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
/** What a failed Tool step does when no error boundary takes it. */
|
|
101
|
+
function onError(config: Record<string, unknown>): string | null {
|
|
102
|
+
return config.onError === undefined || config.onError === "fail" || config.onError === "skip" ? null : "config.onError must be fail or skip";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The shape of an `assign` step; its variables and expressions are checked with the whole document. */
|
|
106
|
+
function assignments(config: Record<string, unknown>): string | null {
|
|
107
|
+
const list = config.assignments;
|
|
108
|
+
if (!Array.isArray(list) || list.length === 0) return "config.assignments must be a non-empty list";
|
|
109
|
+
for (const [index, item] of list.entries()) {
|
|
110
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) return `config.assignments[${index}] must be an object`;
|
|
111
|
+
const { var: name, value } = item as { var?: unknown; value?: unknown };
|
|
112
|
+
if (typeof name !== "string" || !FIELD_NAME.test(name)) return `config.assignments[${index}].var is invalid`;
|
|
113
|
+
if (value === undefined) return `config.assignments[${index}].value is required`;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const duration = { type: "string", format: "duration" };
|
|
119
|
+
const text = { type: "string" };
|
|
120
|
+
const dataPath = { type: "string", pattern: DATA_PATH.source };
|
|
121
|
+
const saveTo = { type: "string", pattern: FIELD_NAME.source };
|
|
122
|
+
const expression = { description: "An expression: a JSON literal, { path } or { op, args }" };
|
|
123
|
+
const flag = { type: "boolean" };
|
|
124
|
+
const object = (properties: Record<string, unknown>, required: readonly string[] = []): JsonSchema => ({ type: "object", properties, ...(required.length === 0 ? {} : { required }) });
|
|
125
|
+
const timerConfig = { duration, at: dataPath };
|
|
126
|
+
const fields = { type: "object", properties: { fields: { type: "array", items: { type: "object" } } }, required: ["fields"] };
|
|
127
|
+
|
|
90
128
|
export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
|
|
91
|
-
{ kind: "manualStart", role: "start", check: none },
|
|
92
|
-
{ kind: "scheduleStart", role: "start", check: cron },
|
|
93
|
-
{ kind: "eventStart", role: "start", check: (c) => string(c, "event", DOTTED_KEY) },
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
{ kind: "manualStart", role: "start", check: none, config: object({}) },
|
|
130
|
+
{ kind: "scheduleStart", role: "start", check: cron, config: object({ cron: text }, ["cron"]) },
|
|
131
|
+
{ kind: "eventStart", role: "start", check: (c) => string(c, "event", DOTTED_KEY), config: object({ event: text }, ["event"]) },
|
|
132
|
+
{
|
|
133
|
+
kind: "userTask",
|
|
134
|
+
role: "activity",
|
|
135
|
+
check: (c) => first(assignee(c), form(c), string(c, "dueIn", ISO_DURATION, false), string(c, "saveTo", FIELD_NAME, false)),
|
|
136
|
+
config: object({ assignee: object({ role: text, user: text }), form: fields, dueIn: duration, saveTo }, ["assignee"]),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
kind: "serviceTask",
|
|
140
|
+
role: "activity",
|
|
141
|
+
check: (c) => first(string(c, "tool", DOTTED_KEY), inputMapping(c), string(c, "saveTo", FIELD_NAME, false), retry(c), string(c, "timeout", ISO_DURATION, false), onError(c)),
|
|
142
|
+
config: object({
|
|
143
|
+
tool: text,
|
|
144
|
+
input: { type: "object", description: "Parameter → a literal, a path string or an expression" },
|
|
145
|
+
saveTo,
|
|
146
|
+
timeout: duration,
|
|
147
|
+
retry: object({ maxAttempts: { type: "integer", minimum: 1, maximum: 10 }, backoff: duration, growth: { enum: ["fixed", "exponential"] }, factor: { type: "number", minimum: 1, maximum: 100 } }, ["maxAttempts"]),
|
|
148
|
+
onError: { enum: ["fail", "skip"] },
|
|
149
|
+
}, ["tool"]),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
kind: "assign",
|
|
153
|
+
role: "activity",
|
|
154
|
+
check: assignments,
|
|
155
|
+
config: object({ assignments: { type: "array", minItems: 1, items: object({ var: { type: "string", pattern: FIELD_NAME.source }, value: expression }, ["var", "value"]) } }, ["assignments"]),
|
|
156
|
+
},
|
|
157
|
+
{ kind: "timerCatch", role: "activity", check: timer, config: object(timerConfig) },
|
|
158
|
+
{
|
|
159
|
+
kind: "eventWait",
|
|
160
|
+
role: "activity",
|
|
161
|
+
check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), string(c, "saveTo", FIELD_NAME, false)),
|
|
162
|
+
config: object({ event: text, correlation: dataPath, saveTo }, ["event"]),
|
|
163
|
+
},
|
|
164
|
+
{ kind: "exclusiveGateway", role: "gateway", check: none, config: object({}) },
|
|
165
|
+
{ kind: "parallelGateway", role: "gateway", check: none, config: object({}) },
|
|
166
|
+
{ kind: "boundaryError", role: "boundary", check: (c) => string(c, "errorCode", /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, false), config: object({ errorCode: text }) },
|
|
167
|
+
{ kind: "boundaryTimer", role: "boundary", check: (c) => first(timer(c), boolean(c, "interrupting")), config: object({ ...timerConfig, interrupting: flag }) },
|
|
168
|
+
{
|
|
169
|
+
kind: "boundaryEvent",
|
|
170
|
+
role: "boundary",
|
|
171
|
+
check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), boolean(c, "interrupting")),
|
|
172
|
+
config: object({ event: text, correlation: dataPath, interrupting: flag }, ["event"]),
|
|
173
|
+
},
|
|
174
|
+
{ kind: "end", role: "end", check: none, config: object({}) },
|
|
104
175
|
] satisfies NodeKindDefinition[]);
|
|
105
176
|
|
|
106
177
|
const BY_KIND = new Map(NODE_KINDS.map((definition) => [definition.kind, definition]));
|