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.
Files changed (66) 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 +26 -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 +65 -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 +33 -10
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +88 -28
  21. package/core/workflow/generated/deps.ts +11 -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 +0 -2
  27. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  28. package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
  29. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  30. package/core/workflow/runtime_module/events/Runtime.events.ts +2 -2
  31. package/core/workflow/runtime_module/http/Instances.controller.ts +16 -11
  32. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  33. package/core/workflow/runtime_module/internal/ToolStep.ts +40 -9
  34. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +60 -10
  35. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +2 -4
  36. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  37. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  38. package/core/workflow/runtime_module/model/WfInstance.model.ts +28 -3
  39. package/core/workflow/runtime_module/model/WfJob.model.ts +17 -1
  40. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  41. package/core/workflow/runtime_module/model/WfToken.model.ts +11 -1
  42. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +5 -1
  43. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +9 -7
  44. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
  45. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  46. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  47. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
  48. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +3 -3
  49. package/library/openapi/codegen.ts +3 -0
  50. package/library/validation/types/ValidatorOptions.ts +2 -0
  51. package/library/workflow/ProcessDocument.ts +59 -23
  52. package/library/workflow/expressionSyntax.ts +243 -0
  53. package/library/workflow/expressions.ts +343 -0
  54. package/library/workflow/formFields.ts +118 -33
  55. package/library/workflow/index.ts +48 -9
  56. package/library/workflow/localized.ts +35 -0
  57. package/library/workflow/masking.ts +58 -0
  58. package/library/workflow/mermaid.ts +18 -8
  59. package/library/workflow/nodeKinds.ts +88 -17
  60. package/library/workflow/palette.ts +108 -0
  61. package/library/workflow/paths.ts +31 -6
  62. package/library/workflow/structure.ts +282 -0
  63. package/library/workflow/upgradeDocument.ts +84 -0
  64. package/library/workflow/validateProcess.ts +130 -48
  65. package/package.json +1 -1
  66. package/library/workflow/conditions.ts +0 -44
@@ -1,14 +1,31 @@
1
- /** Field types of a process input and of a task form. */
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 code: string;
7
- readonly type: FormFieldType;
8
- readonly title?: string;
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<FormFieldType>(["string", "text", "number", "integer", "boolean", "date", "datetime", "enum"]);
28
- const CODE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
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
- if (!isObject(field)) return `${where} must be an object`;
44
- if (typeof field.code !== "string" || !CODE.test(field.code) || field.code === "__proto__") return `${where}.code is invalid`;
45
- if (seen.has(field.code)) return `${where}.code ${field.code} is repeated`;
46
- seen.add(field.code);
47
- if (typeof field.type !== "string" || !TYPES.has(field.type as FormFieldType)) return `${where}.type must be one of ${[...TYPES].join(", ")}`;
48
- if (field.title !== undefined && (typeof field.title !== "string" || field.title.length > 200)) return `${where}.title must be up to 200 characters`;
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
- /** The field codes of a form spec. */
62
- export function formFieldCodes(spec: FormSpec): string[] {
63
- return spec.fields.map((field) => field.code);
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. Returns the value with only the declared fields and every issue.
69
- * Without a spec the form takes no data: any field is unknown.
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.code));
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 item = Object.hasOwn(source, field.code) ? source[field.code] : undefined;
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.code, code: "required", message: `field ${field.code} is required` });
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.code, ...problem });
89
- else out[field.code] = item;
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.code} must be ${expected}` });
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.code} must be one of ${(field.values ?? []).join(", ")}` };
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/v1` (WORKFLOW-001 §2): types,
3
- * node kinds, validation, data paths and conditions. Pure functions without DI
4
- * or a database: usable by a designer, a CLI or a browser.
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 { evaluateCondition } from "./conditions";
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 { checkFormSpec, formFieldCodes, validateForm, type FormField, type FormFieldType, type FormIssue, type FormSpec } from "./formFields";
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 { Condition, NodeKind, ProcessDocument } from "./ProcessDocument";
1
+ import type { Expr } from "./expressions";
2
+ import { printExpression } from "./expressionSyntax";
3
+ import { textOf } from "./localized";
4
+ import type { NodeKind, ProcessDocument } from "./ProcessDocument";
2
5
 
3
6
  /** Words Mermaid reads as syntax: such node ids are renamed. */
4
7
  const RESERVED = new Set(["end", "graph", "flowchart", "subgraph", "style", "class", "classdef", "click", "default", "linkstyle", "direction"]);
@@ -10,6 +13,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 ?? (flow.condition !== undefined ? conditionText(flow.condition) : flow.isDefault === true ? "default" : undefined);
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
- function conditionText(condition: Condition): string {
78
- if ("all" in condition) return condition.all.map(conditionText).join(" and ");
79
- if ("any" in condition) return condition.any.map(conditionText).join(" or ");
80
- if ("not" in condition) return `not (${conditionText(condition.not)})`;
81
- return condition.op === "empty" || condition.op === "notEmpty" ? `${condition.path} ${condition.op}` : `${condition.path} ${condition.op} ${JSON.stringify(condition.value)}`;
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 process variable name. */
21
- export const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
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) => VAR_NAME.test(key)) ? null : "config.input has an invalid parameter name";
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
- { kind: "userTask", role: "activity", check: (c) => first(assignee(c), form(c), string(c, "dueIn", ISO_DURATION, false)) },
95
- { kind: "serviceTask", role: "activity", check: (c) => first(string(c, "tool", DOTTED_KEY), inputMapping(c), string(c, "saveTo", VAR_NAME, false), retry(c)) },
96
- { kind: "timerCatch", role: "activity", check: timer },
97
- { kind: "eventWait", role: "activity", check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), string(c, "saveTo", VAR_NAME, false)) },
98
- { kind: "exclusiveGateway", role: "gateway", check: none },
99
- { kind: "parallelGateway", role: "gateway", check: none },
100
- { kind: "boundaryError", role: "boundary", check: (c) => string(c, "errorCode", /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, false) },
101
- { kind: "boundaryTimer", role: "boundary", check: (c) => first(timer(c), boolean(c, "interrupting")) },
102
- { kind: "boundaryEvent", role: "boundary", check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), boolean(c, "interrupting")) },
103
- { kind: "end", role: "end", check: none },
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]));