bazis 0.130.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 (76) hide show
  1. package/cli/templates/process.ts +1 -1
  2. package/core/agent/AgentToolExecutor.ts +3 -0
  3. package/core/agent/Tool.contract.ts +1 -1
  4. package/core/agent/index.ts +1 -0
  5. package/core/agent/metadata.ts +9 -0
  6. package/core/agent/semantic.ts +8 -1
  7. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  8. package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
  9. package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
  10. package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
  11. package/core/workflow/contracts/types.ts +37 -1
  12. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +18 -3
  13. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
  14. package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
  15. package/core/workflow/definitions_module/access/Process.references.ts +1 -1
  16. package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
  17. package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
  18. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +26 -16
  19. package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
  20. package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
  21. package/core/workflow/definitions_module/events/Definitions.events.ts +16 -0
  22. package/core/workflow/definitions_module/http/Processes.controller.ts +72 -25
  23. package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
  24. package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
  25. package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
  26. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +49 -14
  27. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +128 -45
  28. package/core/workflow/generated/deps.ts +15 -5
  29. package/core/workflow/index.ts +17 -3
  30. package/core/workflow/internal/WorkflowEvents.ts +23 -0
  31. package/core/workflow/internal/WorkflowLanguages.ts +35 -0
  32. package/core/workflow/internal/canonicalJson.ts +8 -0
  33. package/core/workflow/internal/http.ts +53 -5
  34. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +0 -2
  35. package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
  36. package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
  37. package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
  38. package/core/workflow/runtime_module/events/Runtime.events.ts +30 -3
  39. package/core/workflow/runtime_module/http/Events.controller.ts +2 -1
  40. package/core/workflow/runtime_module/http/Instances.controller.ts +32 -21
  41. package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
  42. package/core/workflow/runtime_module/internal/ToolStep.ts +42 -10
  43. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +83 -31
  44. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +2 -4
  45. package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
  46. package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
  47. package/core/workflow/runtime_module/model/WfInstance.model.ts +28 -3
  48. package/core/workflow/runtime_module/model/WfJob.model.ts +17 -1
  49. package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
  50. package/core/workflow/runtime_module/model/WfToken.model.ts +11 -1
  51. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +19 -6
  52. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +20 -17
  53. package/core/workflow/tasks_module/events/Tasks.events.ts +15 -0
  54. package/core/workflow/tasks_module/http/Tasks.controller.ts +21 -18
  55. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
  56. package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
  57. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +4 -4
  58. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +11 -8
  59. package/library/openapi/codegen.ts +12 -0
  60. package/library/validation/types/ValidatorOptions.ts +12 -0
  61. package/library/workflow/ProcessDocument.ts +59 -23
  62. package/library/workflow/expressionSyntax.ts +243 -0
  63. package/library/workflow/expressions.ts +343 -0
  64. package/library/workflow/formFields.ts +120 -34
  65. package/library/workflow/index.ts +48 -9
  66. package/library/workflow/localized.ts +35 -0
  67. package/library/workflow/masking.ts +58 -0
  68. package/library/workflow/mermaid.ts +18 -8
  69. package/library/workflow/nodeKinds.ts +88 -17
  70. package/library/workflow/palette.ts +108 -0
  71. package/library/workflow/paths.ts +31 -6
  72. package/library/workflow/structure.ts +282 -0
  73. package/library/workflow/upgradeDocument.ts +84 -0
  74. package/library/workflow/validateProcess.ts +130 -48
  75. package/package.json +1 -1
  76. package/library/workflow/conditions.ts +0 -44
@@ -1,14 +1,20 @@
1
1
  import { grantsToWhere } from "../../../access";
2
2
  import { createHash } from "node:crypto";
3
- import { UniqueViolationError } from "../../../orm";
3
+ import { paginate, UniqueViolationError } from "../../../orm";
4
4
  import type { AgentToolContract } from "../../../agent";
5
- import { emptyProcess, hasErrors, validateProcess, type ProcessDocument, type ProcessIssue } from "../../../../library/workflow";
6
- import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
5
+ import { allTexts, checkLocalizedText, emptyProcess, hasErrors, processPalette, validateProcess, type LocalizedText, type ProcessDocument, type ProcessIssue, type ProcessPalette } from "../../../../library/workflow";
6
+ import type { ServiceProvider } from "../../../di";
7
+ import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowAuditOptions, type WorkflowPage } from "../../contracts/types";
7
8
  import type { WorkflowClock } from "../../contracts/WorkflowClock";
8
9
  import { WorkflowError } from "../../contracts/WorkflowError";
10
+ import { WorkflowExtensions, type ExtensionTarget, type WorkflowPortContext } from "../../contracts/WorkflowExtensions";
11
+ import { canonicalJson } from "../../internal/canonicalJson";
12
+ import type { WorkflowEvents } from "../../internal/WorkflowEvents";
13
+ import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
9
14
  import type { WorkflowTools } from "../../internal/WorkflowTools";
10
15
  import type { CodeProcessCatalog } from "../code/CodeProcessCatalog";
11
16
  import type { OwnedCodeProcess } from "../code/codeProcesses";
17
+ import { WORKFLOW_PROCESS_CHANGED, type ProcessChange } from "../events/Definitions.events";
12
18
  import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
13
19
  import { WfProcess } from "../model/WfProcess.model";
14
20
  import { WfVersion } from "../model/WfVersion.model";
@@ -18,12 +24,17 @@ import {
18
24
  type ProcessDetails,
19
25
  type ProcessListQuery,
20
26
  type ProcessSummary,
27
+ type SavedDraft,
28
+ type SaveDraftOptions,
21
29
  type UpdateProcess,
30
+ type ValidateOptions,
22
31
  type VersionDetails,
23
32
  type VersionSummary,
24
33
  } from "./IWorkflowDefinitions.service";
25
34
 
26
35
  const KEY = /^[a-z][a-z0-9_.-]{0,63}$/;
36
+ /** A path of segments without empty ones: `Sales/Orders`. */
37
+ const FOLDER = /^[^/\s](?:[^/]*[^/\s])?(?:\/[^/\s](?:[^/]*[^/\s])?)*$/;
27
38
  /** Who publishes the processes declared in code. */
28
39
  const CODE_ACTOR: WorkflowActor = { kind: "system", id: "code" };
29
40
 
@@ -33,12 +44,15 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
33
44
  private readonly workflowTools: WorkflowTools,
34
45
  private readonly code: CodeProcessCatalog,
35
46
  private readonly clock: WorkflowClock,
47
+ private readonly events: WorkflowEvents,
48
+ private readonly languages: WorkflowLanguages,
49
+ /** The scope: the port is asked here, so that the application's own registration replaces the default. */
50
+ private readonly services: ServiceProvider,
36
51
  ) {
37
52
  super();
38
53
  }
39
54
 
40
55
  async list(query: ProcessListQuery = {}): Promise<WorkflowPage<ProcessSummary>> {
41
- const { limit, offset } = pagingOf(query);
42
56
  const where = query.access === undefined ? "all" : grantsToWhere(query.access);
43
57
  if (where === "none") return { items: [], total: 0 };
44
58
  let source = this.db.processes.asNoTracking();
@@ -47,23 +61,61 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
47
61
  if (query.archived === false) source = source.where((p) => p.archivedAt.isNull());
48
62
  if (query.search !== undefined && query.search.trim() !== "") {
49
63
  const text = query.search.trim();
50
- source = source.where((p) => p.key.contains(text, { ignoreCase: true }).or(p.title.contains(text, { ignoreCase: true })));
64
+ source = source.where((p) => p.key.contains(text, { ignoreCase: true }).or(p.titleText.contains(text, { ignoreCase: true })));
51
65
  }
52
- const total = await source.count();
53
- const rows = await source.orderBy((p) => p.key).skip(offset).take(limit).toList();
54
- return { items: rows.map((row) => this.summaryOf(row)), total };
66
+ if (query.folder !== undefined) {
67
+ const folder = query.folder;
68
+ source = source.where((p) => p.folder.eq(folder).or(p.folder.startsWith(`${folder}/`)));
69
+ }
70
+ if (query.owner !== undefined) source = source.where((p) => p.owner.eq(query.owner!));
71
+ // The list sorts by a title as `titleText`: `title` itself is JSON.
72
+ const sort = query.sort?.map((field) => (field.field === "title" ? { ...field, field: "titleText" } : field));
73
+ const { items, total } = await paginate(source, pageQueryOf({ ...query, ...(sort === undefined ? {} : { sort }) }, { field: "key", dir: "asc" }));
74
+ return { items: items.map((row) => this.summaryOf(row)), total };
55
75
  }
56
76
 
57
77
  async get(key: string): Promise<ProcessDetails> {
58
78
  return this.detailsOf(await this.process(key, false));
59
79
  }
60
80
 
61
- async create(process: CreateProcess, actor: WorkflowActor): Promise<ProcessDetails> {
81
+ async folderOf(key: string): Promise<string | null> {
82
+ const row = await this.db.processes.asNoTracking().firstOrDefault((p) => p.key.eq(key));
83
+ return row?.folder ?? null;
84
+ }
85
+
86
+ async validate(document: unknown, options: ValidateOptions = {}): Promise<readonly ProcessIssue[]> {
87
+ const port = this.services.resolve(WorkflowExtensions);
88
+ const namespaces = new Set(port.namespaces());
89
+ const issues = validateProcess(document, { tools: (name) => this.workflowTools.has(name), extensions: (namespace) => namespaces.has(namespace) });
90
+ if (namespaces.size === 0 || issues.some((issue) => issue.code === "too_large" || issue.code === "schema")) return issues;
91
+ const context: WorkflowPortContext = { partition: null, language: options.language ?? this.languages.default, actor: options.actor ?? null };
92
+ const found: ProcessIssue[] = [...issues];
93
+ const { extensions, variables, nodes } = document as ProcessDocument;
94
+ const checkAll = async (data: Readonly<Record<string, unknown>> | undefined, target: ExtensionTarget): Promise<void> => {
95
+ if (typeof data !== "object" || data === null) return;
96
+ for (const [namespace, value] of Object.entries(data)) {
97
+ if (!namespaces.has(namespace)) continue;
98
+ for (const message of await port.check(namespace, value, target, context)) found.push({ severity: "error", code: "extension_invalid", message: `${namespace}: ${message}`, ...target });
99
+ }
100
+ };
101
+ await checkAll(extensions, {});
102
+ if (Array.isArray(variables)) for (const variable of variables) await checkAll(variable?.extensions, { variable: variable?.name });
103
+ for (const [node, raw] of Object.entries(nodes)) await checkAll(raw?.extensions, { node });
104
+ return found;
105
+ }
106
+
107
+ palette(): ProcessPalette {
108
+ return processPalette(this.tools());
109
+ }
110
+
111
+ async create(process: CreateProcess, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<ProcessDetails> {
62
112
  if (typeof process.key !== "string" || !KEY.test(process.key)) throw new WorkflowError("invalid_input", "key must match ^[a-z][a-z0-9_.-]{0,63}$");
63
113
  const row = Object.assign(new WfProcess(), {
64
114
  key: process.key,
65
- title: title(process.title),
115
+ ...this.titled(process.title),
66
116
  description: description(process.description),
117
+ folder: folder(process.folder),
118
+ owner: owner(process.owner),
67
119
  retentionDays: retention(process.retentionDays),
68
120
  draft: emptyProcess(),
69
121
  draftUpdatedAt: this.clock.now(),
@@ -77,43 +129,60 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
77
129
  if (error instanceof UniqueViolationError) throw new WorkflowError("conflict", `Process ${process.key} already exists`);
78
130
  throw error;
79
131
  }
132
+ await this.changed("created", row.key, actor, options);
80
133
  return this.detailsOf(row);
81
134
  }
82
135
 
83
- async update(key: string, changes: UpdateProcess, _actor: WorkflowActor): Promise<ProcessDetails> {
136
+ async update(key: string, changes: UpdateProcess, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<ProcessDetails> {
84
137
  const row = await this.process(key, true);
85
138
  const { archived, ...fields } = changes;
86
139
  if (Object.values(fields).some((value) => value !== undefined)) this.assertNotCode(key);
87
- if (changes.title !== undefined) row.title = title(changes.title);
140
+ const card = (): string => canonicalJson({ title: row.title, description: row.description, folder: row.folder, owner: row.owner, retentionDays: row.retentionDays });
141
+ const before = { card: card(), archived: row.archivedAt !== null };
142
+ if (changes.title !== undefined) Object.assign(row, this.titled(changes.title));
88
143
  if (changes.description !== undefined) row.description = description(changes.description);
144
+ if (changes.folder !== undefined) row.folder = folder(changes.folder);
145
+ if (changes.owner !== undefined) row.owner = owner(changes.owner);
89
146
  if (changes.retentionDays !== undefined) row.retentionDays = retention(changes.retentionDays);
90
147
  if (archived !== undefined) row.archivedAt = archived ? row.archivedAt ?? this.clock.now() : null;
91
148
  await this.db.saveChanges();
149
+ if (card() !== before.card) await this.changed("updated", key, actor, options);
150
+ if (archived !== undefined && archived !== before.archived) await this.changed(archived ? "archived" : "restored", key, actor, options);
92
151
  return this.detailsOf(row);
93
152
  }
94
153
 
95
- async saveDraft(key: string, document: unknown, actor: WorkflowActor): Promise<{ readonly issues: readonly ProcessIssue[] }> {
96
- const issues = this.validate(document);
154
+ async saveDraft(key: string, document: unknown, actor: WorkflowActor, options: SaveDraftOptions = {}): Promise<SavedDraft> {
155
+ const issues = await this.validate(document, { actor, ...(options.language === undefined ? {} : { language: options.language }) });
97
156
  if (issues.some((issue) => issue.code === "too_large")) throw new WorkflowError("invalid_document", "The document is too large", issues);
98
- const row = await this.process(key, true);
99
- this.assertNotCode(key);
100
- row.draft = document;
101
- row.draftUpdatedAt = this.clock.now();
102
- row.draftUpdatedByKind = actor.kind;
103
- row.draftUpdatedById = actor.id;
104
- await this.db.saveChanges();
105
- return { issues };
157
+ return await this.db.transactionScope(async (transaction) => {
158
+ const row = await this.db.processes.where((p) => p.key.eq(key)).forUpdate().firstOrDefault();
159
+ if (!row) throw notFound(key);
160
+ this.assertNotCode(key);
161
+ const previous = row.draftUpdatedAt?.getTime();
162
+ if (options.expected !== undefined && previous !== options.expected.getTime()) throw new WorkflowError("conflict", `The draft of ${key} was saved by someone else`);
163
+ // Later than the last save even within a millisecond: `expected` tells any two saves apart.
164
+ const draftUpdatedAt = new Date(Math.max(this.clock.now().getTime(), (previous ?? 0) + 1));
165
+ row.draft = document;
166
+ row.draftUpdatedAt = draftUpdatedAt;
167
+ row.draftUpdatedByKind = actor.kind;
168
+ row.draftUpdatedById = actor.id;
169
+ await this.db.saveChanges();
170
+ transaction.afterCommit(() => this.changed("draftSaved", key, actor, options));
171
+ return { issues, draftUpdatedAt };
172
+ });
106
173
  }
107
174
 
108
- async publish(key: string, actor: WorkflowActor): Promise<VersionDetails> {
109
- return await this.db.transactionScope(async () => {
175
+ async publish(key: string, actor: WorkflowActor, options: WorkflowAuditOptions & Pick<ValidateOptions, "language"> = {}): Promise<VersionDetails> {
176
+ return await this.db.transactionScope(async (transaction) => {
110
177
  const row = await this.db.processes.where((p) => p.key.eq(key)).forUpdate().firstOrDefault();
111
178
  if (!row) throw notFound(key);
112
179
  this.assertNotCode(key);
113
180
  if (row.archivedAt !== null) throw new WorkflowError("archived", `Process ${key} is archived`);
114
- const issues = this.validate(row.draft);
181
+ const issues = await this.validate(row.draft, { actor, ...(options.language === undefined ? {} : { language: options.language }) });
115
182
  if (hasErrors(issues)) throw new WorkflowError("invalid_document", `Process ${key} has errors and cannot be published`, issues);
116
- return (await this.publishDraft(row, actor)).version;
183
+ const { version, created } = await this.publishDraft(row, actor);
184
+ if (created) transaction.afterCommit(() => this.changed("published", key, actor, options, version.version));
185
+ return version;
117
186
  });
118
187
  }
119
188
 
@@ -124,7 +193,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
124
193
  * and unpublished. The caller has checked the documents with the Tools.
125
194
  */
126
195
  async publishCode(entries: readonly OwnedCodeProcess[]): Promise<readonly { readonly key: string; readonly version: number | null; readonly published: boolean }[]> {
127
- return await this.db.transactionScope(async () => {
196
+ return await this.db.transactionScope(async (transaction) => {
128
197
  await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext('bazis.workflow.code'))");
129
198
  const results: { key: string; version: number | null; published: boolean }[] = [];
130
199
  for (const { process } of [...entries].sort((a, b) => a.process.key.localeCompare(b.process.key))) {
@@ -132,10 +201,15 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
132
201
  if (!row) {
133
202
  row = Object.assign(new WfProcess(), { key: process.key });
134
203
  this.db.processes.add(row);
204
+ transaction.afterCommit(() => this.changed("created", process.key, CODE_ACTOR));
135
205
  }
136
- const fields = { title: title(process.title), description: description(process.description), retentionDays: retention(process.retentionDays) };
137
- if (row.title !== fields.title) row.title = fields.title;
138
- if (row.description !== fields.description) row.description = fields.description;
206
+ const named = this.titled(process.title);
207
+ const about = description(process.description);
208
+ if (canonicalJson(row.title) !== canonicalJson(named.title)) Object.assign(row, named);
209
+ if (canonicalJson(row.description) !== canonicalJson(about)) row.description = about;
210
+ const fields = { folder: folder(process.folder), owner: owner(process.owner), retentionDays: retention(process.retentionDays) };
211
+ if (row.folder !== fields.folder) row.folder = fields.folder;
212
+ if (row.owner !== fields.owner) row.owner = fields.owner;
139
213
  if (row.retentionDays !== fields.retentionDays) row.retentionDays = fields.retentionDays;
140
214
  if (row.draft === null || canonicalJson(row.draft) !== canonicalJson(process.document)) {
141
215
  row.draft = process.document;
@@ -149,6 +223,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
149
223
  continue;
150
224
  }
151
225
  const { version, created } = await this.publishDraft(row, CODE_ACTOR);
226
+ if (created) transaction.afterCommit(() => this.changed("published", process.key, CODE_ACTOR, {}, version.version));
152
227
  results.push({ key: row.key, version: version.version, published: created });
153
228
  }
154
229
  return results;
@@ -226,6 +301,10 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
226
301
  return this.workflowTools.list();
227
302
  }
228
303
 
304
+ private async changed(change: ProcessChange, key: string, actor: WorkflowActor, options: WorkflowAuditOptions = {}, version: number | null = null): Promise<void> {
305
+ await this.events.publish(WORKFLOW_PROCESS_CHANGED, { change, key, version, actor, request: options.request ?? null });
306
+ }
307
+
229
308
  /** A process declared in code changes only through the code. */
230
309
  private assertNotCode(key: string): void {
231
310
  const module = this.code.moduleOf(key);
@@ -237,6 +316,8 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
237
316
  key: row.key,
238
317
  title: row.title,
239
318
  description: row.description,
319
+ folder: row.folder,
320
+ owner: row.owner,
240
321
  latestVersion: row.latestVersion,
241
322
  retentionDays: row.retentionDays,
242
323
  archived: row.archivedAt !== null,
@@ -255,8 +336,11 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
255
336
  };
256
337
  }
257
338
 
258
- private validate(document: unknown): ProcessIssue[] {
259
- return validateProcess(document, { tools: (name) => this.workflowTools.has(name) });
339
+ /** A title with the column a list searches and sorts by. */
340
+ private titled(value: unknown): { readonly title: LocalizedText; readonly titleText: string } {
341
+ const problem = checkLocalizedText(value, 200);
342
+ if (problem) throw new WorkflowError("invalid_input", `title ${problem}`);
343
+ return { title: value as LocalizedText, titleText: allTexts(value as LocalizedText, this.languages.default) };
260
344
  }
261
345
 
262
346
  private async process(key: string, tracked: boolean): Promise<WfProcess> {
@@ -271,14 +355,22 @@ function notFound(key: string): WorkflowError {
271
355
  return new WorkflowError("not_found", `Process ${key} does not exist`);
272
356
  }
273
357
 
274
- function title(value: unknown): string {
275
- if (typeof value !== "string" || value.trim() === "" || value.length > 200) throw new WorkflowError("invalid_input", "title must be 1–200 characters");
358
+ function description(value: unknown): LocalizedText | null {
359
+ if (value === undefined || value === null) return null;
360
+ const problem = checkLocalizedText(value, 2000, 0);
361
+ if (problem) throw new WorkflowError("invalid_input", `description ${problem}`);
362
+ return value as LocalizedText;
363
+ }
364
+
365
+ function folder(value: unknown): string | null {
366
+ if (value === undefined || value === null) return null;
367
+ if (typeof value !== "string" || value.length > 500 || !FOLDER.test(value)) throw new WorkflowError("invalid_input", "folder must be a path like Sales/Orders");
276
368
  return value;
277
369
  }
278
370
 
279
- function description(value: unknown): string | null {
371
+ function owner(value: unknown): string | null {
280
372
  if (value === undefined || value === null) return null;
281
- if (typeof value !== "string" || value.length > 2000) throw new WorkflowError("invalid_input", "description must be up to 2000 characters");
373
+ if (typeof value !== "string" || value === "" || value.length > 200) throw new WorkflowError("invalid_input", "owner must be up to 200 characters");
282
374
  return value;
283
375
  }
284
376
 
@@ -298,12 +390,3 @@ function versionOf(key: string, row: WfVersion): VersionDetails {
298
390
  document: row.document as ProcessDocument,
299
391
  };
300
392
  }
301
-
302
- /** JSON with sorted keys: the same document gives the same hash whatever the key order. */
303
- function canonicalJson(value: unknown): string {
304
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
305
- if (typeof value === "object" && value !== null) {
306
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
307
- }
308
- return JSON.stringify(value) ?? "null";
309
- }
@@ -5,13 +5,18 @@ import { DbContextOptions } from "../../../library/orm/DbContextOptions";
5
5
  import { ServiceProvider } from "../../di/ServiceProvider";
6
6
  import { EventBus } from "../../kernel/events/EventBus";
7
7
  import { WorkflowClock } from "../contracts/WorkflowClock";
8
+ import { FolderReferences } from "../definitions_module/access/Folder.references";
8
9
  import { ProcessReferences } from "../definitions_module/access/Process.references";
10
+ import { ProcessFolderLookup } from "../definitions_module/access/ProcessFolder.lookup";
11
+ import { CatalogImport } from "../definitions_module/code/CatalogImport";
9
12
  import { CodeProcessCatalog } from "../definitions_module/code/CodeProcessCatalog";
10
13
  import { CodeProcessPublisher } from "../definitions_module/code/CodeProcessPublisher";
11
14
  import { ProcessesController } from "../definitions_module/http/Processes.controller";
12
15
  import { DefinitionsDbContext } from "../definitions_module/model/Definitions.dbContext";
13
16
  import { IWorkflowDefinitions } from "../definitions_module/services/IWorkflowDefinitions.service";
14
17
  import { WorkflowDefinitions } from "../definitions_module/services/WorkflowDefinitions.service";
18
+ import { WorkflowEvents } from "../internal/WorkflowEvents";
19
+ import { WorkflowLanguages } from "../internal/WorkflowLanguages";
15
20
  import { WorkflowTools } from "../internal/WorkflowTools";
16
21
  import { WorkflowRetention } from "../runtime_module/background/WorkflowRetention";
17
22
  import { WorkflowWorker } from "../runtime_module/background/WorkflowWorker";
@@ -32,12 +37,15 @@ import { IWorkflowTasks } from "../tasks_module/services/IWorkflowTasks.service"
32
37
  import { WorkflowTasks } from "../tasks_module/services/WorkflowTasks.service";
33
38
 
34
39
  registerFrameworkClassDeps([
40
+ [CatalogImport, [DefinitionsDbContext, WorkflowLanguages]],
35
41
  [CodeProcessCatalog, [ServiceProvider]],
36
42
  [CodeProcessPublisher, [ServiceProvider]],
37
43
  [DefinitionsDbContext, [DbContextOptions]],
38
44
  [EventsController, [IWorkflowRuntime]],
39
- [InstancesController, [IWorkflowRuntime]],
45
+ [FolderReferences, [DefinitionsDbContext]],
46
+ [InstancesController, [IWorkflowRuntime, IWorkflowDefinitions]],
40
47
  [ProcessesController, [IWorkflowDefinitions]],
48
+ [ProcessFolderLookup, [DefinitionsDbContext]],
41
49
  [ProcessReferences, [DefinitionsDbContext]],
42
50
  [RuntimeDbContext, [DbContextOptions]],
43
51
  [Schedules, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions]],
@@ -46,11 +54,13 @@ registerFrameworkClassDeps([
46
54
  [TasksDbContext, [DbContextOptions]],
47
55
  [TimerStep, [RuntimeDbContext, WorkflowEngine]],
48
56
  [ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools]],
49
- [WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock]],
50
- [WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache, EventBus, ServiceProvider, WorkflowClock, "optional:Logger"]],
57
+ [WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents, WorkflowLanguages, ServiceProvider]],
58
+ [WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache, WorkflowEvents, WorkflowClock]],
59
+ [WorkflowEvents, [EventBus, ServiceProvider, "optional:Logger"]],
60
+ [WorkflowLanguages, ["Config:workflow"]],
51
61
  [WorkflowRetention, [ServiceProvider, "Config:workflow"]],
52
- [WorkflowRuntime, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions, ServiceProvider]],
53
- [WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock]],
62
+ [WorkflowRuntime, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions, ServiceProvider, WorkflowLanguages]],
63
+ [WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock, WorkflowEvents]],
54
64
  [WorkflowTools, [ServiceProvider]],
55
65
  [WorkflowWorker, [ServiceProvider, "Config:workflow"]],
56
66
  ]);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Business processes without a UI (WORKFLOW-001): the composite `WorkflowModule`
2
+ * Business processes without a UI (WORKFLOW-001, WORKFLOW-002): the composite `WorkflowModule`
3
3
  * with the Definitions and Runtime parts, their services, HTTP, events and
4
4
  * extension points. The document format is `bazis/library/workflow`.
5
5
  */
@@ -14,6 +14,8 @@ export { WorkflowDefinitionsModule } from "./definitions_module/WorkflowDefiniti
14
14
  export { WorkflowRuntimeModule } from "./runtime_module/WorkflowRuntime.module";
15
15
  export { WorkflowTasksModule } from "./tasks_module/WorkflowTasks.module";
16
16
  export { IWorkflowTasks, type TaskDetails, type TaskListQuery } from "./tasks_module/services/IWorkflowTasks.service";
17
+ export { WORKFLOW_TASK_COMPLETED, type WorkflowTaskCompleted } from "./tasks_module/events/Tasks.events";
18
+ export { WORKFLOW_PROCESS_CHANGED, type ProcessChange, type WorkflowProcessChanged } from "./definitions_module/events/Definitions.events";
17
19
  export type { TaskStatus } from "./tasks_module/model/WfTask.model";
18
20
  export {
19
21
  IWorkflowDefinitions,
@@ -21,38 +23,50 @@ export {
21
23
  type ProcessDetails,
22
24
  type ProcessListQuery,
23
25
  type ProcessSummary,
26
+ type SavedDraft,
27
+ type SaveDraftOptions,
24
28
  type UpdateProcess,
29
+ type ValidateOptions,
25
30
  type VersionDetails,
26
31
  type VersionSummary,
27
32
  } from "./definitions_module/services/IWorkflowDefinitions.service";
28
33
  export {
29
34
  IWorkflowRuntime,
35
+ type CancelInstance,
30
36
  type EmitOptions,
31
37
  type EmitResult,
32
38
  type HistoryEntry,
33
39
  type InstanceDetails,
34
40
  type InstanceListQuery,
35
41
  type InstanceSummary,
42
+ type RetryInstance,
36
43
  type StartInstance,
37
44
  type TokenDetails,
38
45
  } from "./runtime_module/services/IWorkflowRuntime.service";
39
46
  export {
47
+ WORKFLOW_INSTANCE_CANCELLED,
40
48
  WORKFLOW_INSTANCE_FINISHED,
49
+ WORKFLOW_INSTANCE_RETRIED,
50
+ WORKFLOW_INSTANCE_STARTED,
41
51
  WORKFLOW_INSTANCES_DELETED,
42
52
  WORKFLOW_TASK_REQUESTED,
43
53
  WORKFLOW_TOKEN_CLOSED,
54
+ type WorkflowInstanceCancelled,
44
55
  type WorkflowInstanceFinished,
56
+ type WorkflowInstanceRetried,
57
+ type WorkflowInstanceStarted,
45
58
  type WorkflowInstancesDeleted,
46
59
  type WorkflowTaskRequested,
47
60
  type WorkflowTokenClosed,
48
61
  } from "./runtime_module/events/Runtime.events";
49
- export { workflowRuntimeConfig } from "./runtime_module/config/Runtime.config";
62
+ export { workflowConfig, type WorkflowSettings } from "./contracts/Workflow.config";
50
63
  export type { HistoryType } from "./runtime_module/model/WfHistory.model";
51
64
  export type { InstanceStatus, StartedVia } from "./runtime_module/model/WfInstance.model";
52
65
  export type { TokenStatus } from "./runtime_module/model/WfToken.model";
53
66
  export { WorkflowInputValidator, DefaultWorkflowInputValidator, type WorkflowInputContext } from "./contracts/WorkflowInputValidator";
67
+ export { WorkflowExtensions, DefaultWorkflowExtensions, type ExtensionTarget, type WorkflowPortContext } from "./contracts/WorkflowExtensions";
54
68
  export { WorkflowError, type WorkflowErrorCode } from "./contracts/WorkflowError";
55
- export type { WorkflowActor, WorkflowPage, WorkflowPaging } from "./contracts/types";
69
+ export type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging, WorkflowRequest, WorkflowToolMetadata } from "./contracts/types";
56
70
 
57
71
  // Augment the declaring file, not the "../di" re-export: re-export targets
58
72
  // depend on program file order and silently stop merging in some projects.
@@ -0,0 +1,23 @@
1
+ import type { ServiceProvider } from "../../di";
2
+ import { EventBus, type EventToken, type Logger } from "../../kernel";
3
+
4
+ /**
5
+ * Publishes the events of the workflow services after their action is saved, in
6
+ * the scope of the action. A failing handler is logged: the action stands.
7
+ */
8
+ export class WorkflowEvents {
9
+ constructor(
10
+ private readonly bus: EventBus,
11
+ /** Event handlers run in this scope. */
12
+ private readonly scope: ServiceProvider,
13
+ private readonly logger?: Logger,
14
+ ) {}
15
+
16
+ async publish<T>(event: EventToken<T>, payload: T): Promise<void> {
17
+ try {
18
+ await this.bus.publishScoped(this.scope, event, payload);
19
+ } catch (error) {
20
+ this.logger?.error("workflow event handler failed", { event: event.name, error: error instanceof Error ? error.message : String(error) });
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,35 @@
1
+ import type { ConfigView } from "../../kernel";
2
+ import type { WorkflowSettings } from "../contracts/Workflow.config";
3
+
4
+ /**
5
+ * The language of a call (WORKFLOW-002 §6): over HTTP from `Accept-Language`
6
+ * among `workflow.languages`, otherwise `workflow.language`; in events, schedules
7
+ * and the background always `workflow.language`.
8
+ */
9
+ export class WorkflowLanguages {
10
+ constructor(private readonly config: ConfigView<WorkflowSettings>) {}
11
+
12
+ get default(): string {
13
+ return this.config.get("language");
14
+ }
15
+
16
+ /** The best language of an `Accept-Language` header: by weight, then by order; `ru-RU` also finds `ru`. */
17
+ of(header: string | undefined): string {
18
+ if (header === undefined) return this.default;
19
+ const known = new Set([this.default, ...this.config.get("languages").split(",").map((item) => item.trim())]);
20
+ const wanted = header.split(",")
21
+ .map((part, index) => {
22
+ const [tag = "", ...parameters] = part.trim().split(";");
23
+ const weight = parameters.map((item) => /^\s*q=([\d.]+)\s*$/.exec(item)?.[1]).find((item) => item !== undefined);
24
+ return { tag: tag.trim(), weight: weight === undefined ? 1 : Number(weight), index };
25
+ })
26
+ .filter(({ tag, weight }) => tag !== "" && weight > 0)
27
+ .sort((a, b) => b.weight - a.weight || a.index - b.index);
28
+ for (const { tag } of wanted) {
29
+ if (known.has(tag)) return tag;
30
+ const base = tag.split("-")[0]!;
31
+ if (known.has(base)) return base;
32
+ }
33
+ return this.default;
34
+ }
35
+ }
@@ -0,0 +1,8 @@
1
+ /** JSON with sorted keys: the same document gives the same hash whatever the key order. */
2
+ export function canonicalJson(value: unknown): string {
3
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
4
+ if (typeof value === "object" && value !== null) {
5
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
6
+ }
7
+ return JSON.stringify(value) ?? "null";
8
+ }
@@ -1,12 +1,29 @@
1
1
  import { IAccess, type AccessObject, type SubjectAccess } from "../../access";
2
2
  import { ForbiddenError, type HttpContext } from "../../http";
3
- import type { WorkflowActor } from "../contracts/types";
3
+ import { frameworkBadRequest } from "../../http/Errors/HttpError";
4
+ import { ListQueryError, parseListQuery, type ListQuery, type ListQueryOptions, type ListQueryProblem } from "../../../library/jsonapi";
5
+ import { textOf, type LocalizedText } from "../../../library/workflow";
6
+ import type { WorkflowActor, WorkflowPaging, WorkflowRequest } from "../contracts/types";
4
7
  import { WorkflowError } from "../contracts/WorkflowError";
8
+ import { WorkflowLanguages } from "./WorkflowLanguages";
5
9
 
6
10
  /** Who calls a route: their rights and how they are recorded as an actor. */
7
11
  export interface WorkflowCaller {
8
12
  readonly access: SubjectAccess;
9
13
  readonly actor: WorkflowActor;
14
+ readonly request: WorkflowRequest;
15
+ /** The language of the request: `Accept-Language` among `workflow.languages`. */
16
+ readonly language: string;
17
+ }
18
+
19
+ /** A record with `titleText`: its `title` in the language of the request. `title` itself stays for editing. */
20
+ export function withTitleText<T extends { readonly title: LocalizedText }>(item: T, language: string, fallback: string): T & { readonly titleText: string } {
21
+ return { ...item, titleText: textOf(item.title, language, fallback) };
22
+ }
23
+
24
+ /** The default language, for {@link withTitleText}. */
25
+ export function defaultLanguage(ctx: HttpContext): string {
26
+ return ctx.services.resolve(WorkflowLanguages).default;
10
27
  }
11
28
 
12
29
  /**
@@ -20,7 +37,7 @@ export async function callerOf(ctx: HttpContext, permission?: string, object?: A
20
37
  if (permission !== undefined && !await access.can(permission, object)) throw new ForbiddenError();
21
38
  const sub = access.subject.sub;
22
39
  const [id = sub.slice(sub.lastIndexOf(":") + 1)] = await access.ownerIds();
23
- return { access, actor: { kind: "user", id } };
40
+ return { access, actor: { kind: "user", id }, request: { ip: ctx.clientIp ?? null, userAgent: ctx.header("user-agent") ?? null }, language: ctx.services.resolve(WorkflowLanguages).of(ctx.header("accept-language")) };
24
41
  }
25
42
 
26
43
  /** A route parameter as a string. */
@@ -28,6 +45,11 @@ export function param(ctx: HttpContext, name: string): string {
28
45
  return String(ctx.params[name] ?? "");
29
46
  }
30
47
 
48
+ /** A title or a description from a body; the service checks it. */
49
+ export function optionalText(value: unknown): LocalizedText | null | undefined {
50
+ return value as LocalizedText | null | undefined;
51
+ }
52
+
31
53
  /** The JSON body as an object; anything else is `invalid_input`. */
32
54
  export async function body(ctx: HttpContext): Promise<Record<string, unknown>> {
33
55
  const text = await ctx.text();
@@ -64,7 +86,33 @@ export function optionalInteger(value: unknown, name: string): number | undefine
64
86
  return number as number;
65
87
  }
66
88
 
67
- /** `limit` and `offset` of a list query. */
68
- export function pagingQuery(ctx: HttpContext): { limit?: number; offset?: number } {
69
- return { limit: optionalInteger(ctx.query("limit"), "limit"), offset: optionalInteger(ctx.query("offset"), "offset") };
89
+ /**
90
+ * The list query of a route: `filter[...]`, `sort`, `page[...]`; anything
91
+ * `options` does not list is 400. Each filter here names one service option, so
92
+ * OR groups and a field filtered twice are refused rather than half applied.
93
+ */
94
+ export function listQuery(ctx: HttpContext, options: Pick<ListQueryOptions, "filter" | "sort">): ListQuery {
95
+ try {
96
+ const list = parseListQuery(ctx.url.searchParams, { ...options, page: { defaultSize: 50, maxSize: 200 } });
97
+ const problems: ListQueryProblem[] = list.or.length > 0 ? [{ parameter: "filter[or]", message: "OR groups are not supported here" }] : [];
98
+ for (const [index, { field }] of list.filters.entries()) {
99
+ if (list.filters.findIndex((rule) => rule.field === field) !== index) problems.push({ parameter: `filter[${field}]`, message: "the field is filtered twice" });
100
+ }
101
+ if (problems.length > 0) throw new ListQueryError(problems);
102
+ return list;
103
+ } catch (error) {
104
+ if (error instanceof ListQueryError) throw frameworkBadRequest("invalidListQuery", undefined, error.problems);
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ /** The page and order of a list query, for a list operation. */
110
+ export function pagingOfList(list: ListQuery): WorkflowPaging {
111
+ return { limit: list.page.limit, offset: list.page.offset, ...(list.sort.length === 0 ? {} : { sort: list.sort }) };
112
+ }
113
+
114
+ /** The value of `filter[field]`. */
115
+ export function filterOf(list: ListQuery, field: string): string | undefined {
116
+ const value = list.filters.find((rule) => rule.field === field)?.value;
117
+ return Array.isArray(value) ? value.join(",") : value as string | undefined;
70
118
  }
@@ -5,7 +5,6 @@ import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinit
5
5
  import { runtimePermissions } from "./access/Runtime.permissions";
6
6
  import { WorkflowRetention } from "./background/WorkflowRetention";
7
7
  import { WorkflowWorker } from "./background/WorkflowWorker";
8
- import { workflowRuntimeConfig } from "./config/Runtime.config";
9
8
  import { EventsController } from "./http/Events.controller";
10
9
  import { InstancesController } from "./http/Instances.controller";
11
10
  import { GraphCache } from "./internal/GraphCache";
@@ -25,7 +24,6 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
25
24
  @Module({
26
25
  imports: [AccessModule, WorkflowDefinitionsModule],
27
26
  permissions: runtimePermissions,
28
- config: [workflowRuntimeConfig],
29
27
  ormBazis: { context: RuntimeDbContext, entities: [...RUNTIME_ENTITIES], ownedStore: runtimeStore, registerRepositories: false },
30
28
  providers: [
31
29
  singleton(GraphCache),
@@ -1,24 +1,26 @@
1
1
  import { definePermissions } from "../../../access";
2
+ import { ProcessFolderLookup } from "../../definitions_module/access/ProcessFolder.lookup";
2
3
 
3
4
  const process = { ref: "workflow.process", column: "processKey" };
5
+ const folder = { ref: "workflow.folder", lookup: ProcessFolderLookup };
4
6
 
5
7
  /** The rights of running processes (WORKFLOW-002 §6.2). */
6
8
  export const runtimePermissions = definePermissions({
7
9
  "workflow.instances.start": {
8
10
  title: { en: "Start processes", ru: "Запускать процессы" },
9
11
  scope: "request",
10
- on: { process },
12
+ on: { process, folder },
11
13
  },
12
14
  "workflow.instances.read": {
13
15
  title: { en: "See instances", ru: "Видеть экземпляры" },
14
16
  scope: { column: "partition" },
15
- on: { process },
17
+ on: { process, folder },
16
18
  owner: "startedById",
17
19
  },
18
20
  "workflow.instances.manage": {
19
21
  title: { en: "Cancel and retry instances", ru: "Отменять и повторять экземпляры" },
20
22
  scope: { column: "partition" },
21
- on: { process },
23
+ on: { process, folder },
22
24
  },
23
25
  "workflow.events.emit": {
24
26
  title: { en: "Send events to processes", ru: "Отправлять события процессам" },