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
@@ -14,7 +14,7 @@ export const ${process.module}Process = defineProcess({
14
14
  key: "${process.folder}",
15
15
  title: "${title}",
16
16
  document: {
17
- schemaVersion: "bazis.workflow/v1",
17
+ schemaVersion: "bazis.workflow/v2",
18
18
  nodes: {
19
19
  Start: { kind: "manualStart", title: "Start" },
20
20
  End: { kind: "end", title: "End" },
@@ -58,6 +58,8 @@ export interface AgentToolExecutionContext {
58
58
  readonly maxAttempts: number;
59
59
  readonly idempotencyKey?: string;
60
60
  readonly approval: AgentToolExecutionApproval;
61
+ /** On whose behalf the call runs, when the caller says so. */
62
+ readonly actor?: ToolActor;
61
63
  readonly metadata: JsonObject;
62
64
  readonly signal: AbortSignal;
63
65
  }
@@ -1664,6 +1666,7 @@ export class AgentToolExecutor {
1664
1666
  maxAttempts: attempt.maxAttempts,
1665
1667
  ...(attempt.idempotencyKey !== undefined ? { idempotencyKey: attempt.idempotencyKey } : {}),
1666
1668
  approval,
1669
+ ...(options.actor !== undefined ? { actor: options.actor } : {}),
1667
1670
  metadata,
1668
1671
  signal,
1669
1672
  });
@@ -15,6 +15,6 @@ export function describeTool(tool: ToolDefinition) {
15
15
  name: tool.metadata.name, description: tool.metadata.description,
16
16
  input: tool.metadata.input ? schema(tool.metadata.input) : undefined,
17
17
  output: tool.metadata.output ? schema(tool.metadata.output) : undefined,
18
- sideEffect: tool.metadata.sideEffect, approval: tool.metadata.approval, timeoutMs: tool.metadata.timeoutMs,
18
+ sideEffect: tool.metadata.sideEffect, approval: tool.metadata.approval, timeoutMs: tool.metadata.timeoutMs, dryRun: tool.metadata.dryRun,
19
19
  });
20
20
  }
@@ -88,6 +88,7 @@ export {
88
88
  type ToolApproval,
89
89
  type ToolMetadata,
90
90
  type ToolActor,
91
+ type ToolDryRun,
91
92
  type ToolOptions,
92
93
  type ToolSideEffect,
93
94
  } from "./metadata";
@@ -15,6 +15,8 @@ export interface ToolActor {
15
15
  readonly id: string;
16
16
  }
17
17
  export type ToolApproval = "never" | "policy" | "required";
18
+ /** Whether a Tool can run without changing anything, when a process is tried out (WORKFLOW-002 §5.4). */
19
+ export type ToolDryRun = "supported" | "unsupported";
18
20
 
19
21
  export interface ToolOptions {
20
22
  readonly name: string;
@@ -24,6 +26,8 @@ export interface ToolOptions {
24
26
  readonly sideEffect?: ToolSideEffect;
25
27
  readonly approval?: ToolApproval;
26
28
  readonly timeoutMs?: number;
29
+ /** `"unsupported"` by default. */
30
+ readonly dryRun?: ToolDryRun;
27
31
  /**
28
32
  * Who may call the Tool: `"agent"` for LLM agents, any other id for code that
29
33
  * runs Tools itself (`ToolRunner.as("workflow")`). Required and non-empty;
@@ -42,6 +46,7 @@ export interface ToolMetadata {
42
46
  readonly sideEffect: ToolSideEffect;
43
47
  readonly approval: ToolApproval;
44
48
  readonly timeoutMs?: number;
49
+ readonly dryRun: ToolDryRun;
45
50
  readonly callers: readonly string[];
46
51
  readonly tags: readonly string[];
47
52
  }
@@ -103,6 +108,9 @@ export function defineToolMetadata(_target: object, metadata: object, options: T
103
108
  if (options.approval !== undefined && options.approval !== "never" && options.approval !== "policy" && options.approval !== "required") {
104
109
  throw new AgentSetupError("@Tool.approval must be never, policy or required.");
105
110
  }
111
+ if (options.dryRun !== undefined && options.dryRun !== "supported" && options.dryRun !== "unsupported") {
112
+ throw new AgentSetupError("@Tool.dryRun must be supported or unsupported.");
113
+ }
106
114
  const normalized: ToolMetadata = Object.freeze({
107
115
  name: text(options.name, "@Tool.name"),
108
116
  description: text(options.description, "@Tool.description"),
@@ -111,6 +119,7 @@ export function defineToolMetadata(_target: object, metadata: object, options: T
111
119
  sideEffect,
112
120
  approval: approvalFor(sideEffect, options.approval),
113
121
  timeoutMs: positiveInteger(options.timeoutMs, "@Tool.timeoutMs"),
122
+ dryRun: options.dryRun ?? "unsupported",
114
123
  // Checked when the module registers the Tool, so a missing list prints as
115
124
  // one configuration-error line at start instead of failing the import.
116
125
  callers: Array.isArray(options.callers) ? Object.freeze(options.callers.map((caller) => callerId(caller))) : Object.freeze([]),
@@ -1,5 +1,5 @@
1
1
  import { AgentSemanticError } from "./errors";
2
- import type { ToolApproval, ToolSideEffect } from "./metadata";
2
+ import type { ToolApproval, ToolDryRun, ToolSideEffect } from "./metadata";
3
3
 
4
4
  const FORBIDDEN_JSON_KEYS = new Set(["__proto__", "prototype", "constructor"]);
5
5
 
@@ -162,6 +162,7 @@ export interface AgentToolContract {
162
162
  readonly sideEffect: ToolSideEffect;
163
163
  readonly approval: ToolApproval;
164
164
  readonly timeoutMs?: number;
165
+ readonly dryRun: ToolDryRun;
165
166
  }
166
167
 
167
168
  export interface AgentToolContractOptions {
@@ -172,6 +173,7 @@ export interface AgentToolContractOptions {
172
173
  readonly sideEffect?: ToolSideEffect;
173
174
  readonly approval?: ToolApproval;
174
175
  readonly timeoutMs?: number;
176
+ readonly dryRun?: ToolDryRun;
175
177
  }
176
178
 
177
179
  export interface AgentModelCapabilities {
@@ -533,6 +535,10 @@ export function agentToolContract(options: AgentToolContractOptions): AgentToolC
533
535
  if ((sideEffect === "write" || sideEffect === "external") && approval === "never") {
534
536
  throw new AgentSemanticError('write/external tools cannot use approval: "never".');
535
537
  }
538
+ const dryRun = options.dryRun ?? "unsupported";
539
+ if (dryRun !== "supported" && dryRun !== "unsupported") {
540
+ throw new AgentSemanticError("tool.dryRun must be supported or unsupported.");
541
+ }
536
542
  return freezeWithoutUndefined({
537
543
  name: requiredName(options.name, "tool.name"),
538
544
  description: requiredContentText(options.description, "tool.description"),
@@ -541,6 +547,7 @@ export function agentToolContract(options: AgentToolContractOptions): AgentToolC
541
547
  sideEffect,
542
548
  approval,
543
549
  timeoutMs: optionalPositiveInteger(options.timeoutMs, "tool.timeoutMs"),
550
+ dryRun,
544
551
  });
545
552
  }
546
553
 
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.130.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.132.0" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -1,12 +1,15 @@
1
- import { defineConfig } from "../../../kernel";
1
+ import { defineConfig } from "../../kernel";
2
2
 
3
3
  /**
4
- * Settings of the workflow background services (WORKFLOW-001 §4), from the
5
- * application configuration: `BAZIS_WORKFLOW__WORKER__ENABLED=false` keeps an
6
- * instance from running jobs, for example a web-only one.
4
+ * Settings of the workflow pack, from the application configuration:
5
+ * `BAZIS_WORKFLOW__WORKER__ENABLED=false` keeps an instance from running jobs,
6
+ * for example a web-only one (WORKFLOW-001 §4). `language` is the default
7
+ * language of texts, `languages` the comma-separated list a request may ask for
8
+ * with `Accept-Language` (WORKFLOW-002 §6).
7
9
  */
8
- /** The settings of the workflow background services, `workflow.*` in the configuration. */
9
10
  export interface WorkflowSettings {
11
+ readonly language: string;
12
+ readonly languages: string;
10
13
  readonly "worker.enabled": boolean;
11
14
  readonly "worker.intervalMs": number;
12
15
  readonly "worker.batchSize": number;
@@ -15,8 +18,12 @@ export interface WorkflowSettings {
15
18
  readonly "retention.batchSize": number;
16
19
  }
17
20
 
18
- export const workflowRuntimeConfig = defineConfig<WorkflowSettings>("workflow", {
21
+ const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
22
+
23
+ export const workflowConfig = defineConfig<WorkflowSettings>("workflow", {
19
24
  default: {
25
+ language: "en",
26
+ languages: "en",
20
27
  "worker.enabled": true,
21
28
  "worker.intervalMs": 1000,
22
29
  "worker.batchSize": 10,
@@ -25,6 +32,8 @@ export const workflowRuntimeConfig = defineConfig<WorkflowSettings>("workflow",
25
32
  "retention.batchSize": 500,
26
33
  },
27
34
  validate: {
35
+ language: (value) => (LANGUAGE.test(value) ? undefined : "must be a language: en, ru, pt-BR"),
36
+ languages: (value) => (value.split(",").every((item) => LANGUAGE.test(item.trim())) ? undefined : "must be a comma-separated list of languages"),
28
37
  "worker.intervalMs": (value) => positive(value, 3_600_000),
29
38
  "worker.batchSize": (value) => positive(value, 1000),
30
39
  "retention.intervalMs": (value) => positive(value, 86_400_000),
@@ -0,0 +1,40 @@
1
+ import type { WorkflowActor } from "./types";
2
+
3
+ /** What every port of the pack learns about the call (WORKFLOW-002 §6). */
4
+ export interface WorkflowPortContext {
5
+ readonly partition: string | null;
6
+ /** The language of the request; `workflow.language` for events, schedules and the background. */
7
+ readonly language: string;
8
+ /** `null` for a call from code. */
9
+ readonly actor: WorkflowActor | null;
10
+ }
11
+
12
+ /** Where the `extensions` are: a node, a variable, or neither for the root of the document. */
13
+ export interface ExtensionTarget {
14
+ readonly node?: string;
15
+ readonly variable?: string;
16
+ }
17
+
18
+ /**
19
+ * Checks the application's data in a document (WORKFLOW-002 §3.1) when a draft
20
+ * is saved and a version is published: the application lists its namespaces and
21
+ * registers its implementation in any module. A namespace nobody checks is an
22
+ * error. A published version with such a key still runs: the engine does not
23
+ * read `extensions`, so a removed module does not break live processes.
24
+ */
25
+ export abstract class WorkflowExtensions {
26
+ abstract namespaces(): readonly string[];
27
+ /** Every problem of `value`, the data of one namespace at one place. */
28
+ abstract check(namespace: string, value: unknown, target: ExtensionTarget, context: WorkflowPortContext): readonly string[] | Promise<readonly string[]>;
29
+ }
30
+
31
+ /** By default there are no namespaces. */
32
+ export class DefaultWorkflowExtensions extends WorkflowExtensions {
33
+ namespaces(): readonly string[] {
34
+ return [];
35
+ }
36
+
37
+ check(): readonly string[] {
38
+ return [];
39
+ }
40
+ }
@@ -1,16 +1,17 @@
1
1
  import { validateForm, type FormIssue, type FormSpec } from "../../../library/workflow";
2
+ import type { WorkflowPortContext } from "./WorkflowExtensions";
2
3
 
3
4
  /** What a value is checked for: a process input or a task form. */
4
- export interface WorkflowInputContext {
5
+ export interface WorkflowInputContext extends WorkflowPortContext {
5
6
  readonly purpose: "input" | "task";
6
7
  readonly processKey: string;
7
- readonly partition: string | null;
8
8
  }
9
9
 
10
10
  /**
11
- * Checks a process input and task forms against their fields (WORKFLOW-001 §9).
12
- * The default checks the types of `bazis.workflow/v1`; an application with its
13
- * own field rules registers its implementation of this class in any module.
11
+ * Checks a process input and task forms against their fields (WORKFLOW-002 §3.4):
12
+ * a field carries the application's rules in `extensions`. The default checks the
13
+ * types; an application with its own field rules registers its implementation of
14
+ * this class in any module.
14
15
  */
15
16
  export abstract class WorkflowInputValidator {
16
17
  abstract validate(spec: FormSpec | undefined, value: unknown, context: WorkflowInputContext): { readonly value: Record<string, unknown>; readonly issues: readonly FormIssue[] } | Promise<{ readonly value: Record<string, unknown>; readonly issues: readonly FormIssue[] }>;
@@ -1,19 +1,42 @@
1
+ import type { ListQuery, SortField } from "../../../library/jsonapi";
2
+
1
3
  /** Who acts: a user or the system (schedules, events). Steps of an instance run on behalf of who started it. */
2
4
  export interface WorkflowActor {
3
5
  readonly kind: "user" | "system";
4
6
  readonly id: string;
5
7
  }
6
8
 
9
+ /** Where an action came from, for the audit: `null` in its place means code called. */
10
+ export interface WorkflowRequest {
11
+ readonly ip: string | null;
12
+ readonly userAgent: string | null;
13
+ }
14
+
15
+ /** What the audit events of an action carry besides its actor. */
16
+ export interface WorkflowAuditOptions {
17
+ readonly request?: WorkflowRequest | null;
18
+ }
19
+
20
+ /** What `context.metadata` holds when a process calls a Tool: enough to name the cause in its own events. */
21
+ export interface WorkflowToolMetadata {
22
+ readonly instanceId: string;
23
+ readonly processKey: string;
24
+ readonly nodeId: string;
25
+ readonly partition: string | null;
26
+ readonly test: boolean;
27
+ }
28
+
7
29
  /** A page of a list. */
8
30
  export interface WorkflowPage<T> {
9
31
  readonly items: readonly T[];
10
32
  readonly total: number;
11
33
  }
12
34
 
13
- /** Paging of the list operations: `limit` 1–200 (50 by default), `offset` from 0. */
35
+ /** Paging of the list operations: `limit` 1–200 (50 by default), `offset` from 0; `sort` replaces the list's own order. */
14
36
  export interface WorkflowPaging {
15
37
  readonly limit?: number;
16
38
  readonly offset?: number;
39
+ readonly sort?: readonly SortField[];
17
40
  }
18
41
 
19
42
  export function pagingOf(paging: WorkflowPaging): { readonly limit: number; readonly offset: number } {
@@ -22,6 +45,19 @@ export function pagingOf(paging: WorkflowPaging): { readonly limit: number; read
22
45
  return { limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 200) : 50, offset: Number.isFinite(offset) ? Math.max(offset, 0) : 0 };
23
46
  }
24
47
 
48
+ /** The page of a list operation as `paginate` takes it. */
49
+ export function pageQueryOf(paging: WorkflowPaging, order: SortField): ListQuery {
50
+ const { limit, offset } = pagingOf(paging);
51
+ return {
52
+ sort: paging.sort ?? [order],
53
+ filters: [],
54
+ or: [],
55
+ include: [],
56
+ fields: {},
57
+ page: { limit, offset, size: limit, number: Math.floor(offset / limit) + 1 },
58
+ };
59
+ }
60
+
25
61
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
62
 
27
63
  /** Whether an id can be a row key; anything else would reach PostgreSQL as an invalid uuid. */
@@ -1,9 +1,16 @@
1
1
  import { AccessModule } from "../../access";
2
2
  import { HOSTED_SERVICE, Module, scoped, singleton } from "../../di";
3
3
  import { SystemWorkflowClock, WorkflowClock } from "../contracts/WorkflowClock";
4
+ import { workflowConfig } from "../contracts/Workflow.config";
5
+ import { DefaultWorkflowExtensions, WorkflowExtensions } from "../contracts/WorkflowExtensions";
6
+ import { WorkflowEvents } from "../internal/WorkflowEvents";
7
+ import { WorkflowLanguages } from "../internal/WorkflowLanguages";
4
8
  import { WorkflowTools } from "../internal/WorkflowTools";
5
9
  import { definitionsPermissions } from "./access/Definitions.permissions";
10
+ import { FolderReferences } from "./access/Folder.references";
6
11
  import { ProcessReferences } from "./access/Process.references";
12
+ import { ProcessFolderLookup } from "./access/ProcessFolder.lookup";
13
+ import { CatalogImport } from "./code/CatalogImport";
7
14
  import { CodeProcessCatalog } from "./code/CodeProcessCatalog";
8
15
  import { CodeProcessPublisher } from "./code/CodeProcessPublisher";
9
16
  import { ProcessesController } from "./http/Processes.controller";
@@ -15,22 +22,30 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
15
22
 
16
23
  /**
17
24
  * The process catalog: drafts and published versions, and the processes
18
- * declared in code ([passport](MODULE.md)).
25
+ * declared in code ([passport](MODULE.md)). The application checks its own
26
+ * `extensions` by registering its `WorkflowExtensions`.
19
27
  */
20
28
  @Module({
21
29
  imports: [AccessModule],
22
30
  permissions: definitionsPermissions,
23
- references: { "workflow.process": ProcessReferences },
31
+ references: { "workflow.process": ProcessReferences, "workflow.folder": FolderReferences },
32
+ config: [workflowConfig],
24
33
  ormBazis: { context: DefinitionsDbContext, entities: [WfProcess, WfVersion], ownedStore: definitionsStore, registerRepositories: false },
25
34
  providers: [
26
35
  singleton(WorkflowClock, SystemWorkflowClock),
27
36
  singleton(WorkflowTools),
28
37
  singleton(CodeProcessCatalog),
38
+ singleton(WorkflowLanguages),
39
+ singleton(WorkflowExtensions, DefaultWorkflowExtensions),
40
+ scoped(WorkflowEvents),
41
+ scoped(CatalogImport),
29
42
  scoped(IWorkflowDefinitions, WorkflowDefinitions),
30
43
  scoped(ProcessReferences),
44
+ scoped(FolderReferences),
45
+ scoped(ProcessFolderLookup),
31
46
  singleton(HOSTED_SERVICE, CodeProcessPublisher),
32
47
  ],
33
48
  controllers: [ProcessesController],
34
- exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock],
49
+ exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, ProcessFolderLookup],
35
50
  })
36
51
  export class WorkflowDefinitionsModule {}
@@ -1,6 +1,7 @@
1
1
  import { definePermissions } from "../../../access";
2
2
 
3
3
  const process = { ref: "workflow.process", column: "key" };
4
+ const folder = { ref: "workflow.folder", column: "folder" };
4
5
 
5
6
  /**
6
7
  * The rights of the process catalog (WORKFLOW-002 §6.2). A process is one per
@@ -10,14 +11,14 @@ const process = { ref: "workflow.process", column: "key" };
10
11
  export const definitionsPermissions = definePermissions({
11
12
  "workflow.processes.read": {
12
13
  title: { en: "See processes", ru: "Видеть процессы" },
13
- on: { process },
14
+ on: { process, folder },
14
15
  },
15
16
  "workflow.processes.manage": {
16
17
  title: { en: "Edit processes", ru: "Редактировать процессы" },
17
- on: { process },
18
+ on: { process, folder },
18
19
  },
19
20
  "workflow.processes.publish": {
20
21
  title: { en: "Publish processes", ru: "Публиковать процессы" },
21
- on: { process },
22
+ on: { process, folder },
22
23
  },
23
24
  });
@@ -0,0 +1,37 @@
1
+ import { References, type ReferenceItem, type ReferencePage } from "../../../access";
2
+ import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
3
+
4
+ /**
5
+ * Reference kind `workflow.folder`: the folders of the catalog. A folder exists
6
+ * while a process lies in it or below it: there is no folder entity.
7
+ */
8
+ export class FolderReferences extends References {
9
+ constructor(private readonly db: DefinitionsDbContext) {
10
+ super();
11
+ }
12
+
13
+ async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
14
+ const folders = await this.folders();
15
+ return new Set(ids.filter((id) => folders.has(id)));
16
+ }
17
+
18
+ async describe(ids: readonly string[]): Promise<readonly ReferenceItem[]> {
19
+ const folders = await this.folders();
20
+ return ids.filter((id) => folders.has(id)).map((id) => ({ id, title: id }));
21
+ }
22
+
23
+ async search(text: string, _context: unknown, page: ReferencePage): Promise<readonly ReferenceItem[]> {
24
+ const found = text.trim().toLowerCase();
25
+ return [...await this.folders()].filter((folder) => folder.toLowerCase().includes(found)).sort().slice(page.offset, page.offset + page.limit).map((id) => ({ id, title: id }));
26
+ }
27
+
28
+ private async folders(): Promise<ReadonlySet<string>> {
29
+ const rows = await this.db.processes.asNoTracking().where((p) => p.folder.isNotNull()).select((p) => ({ folder: p.folder })).toList();
30
+ const all = new Set<string>();
31
+ for (const { folder } of rows) {
32
+ const parts = folder!.split("/");
33
+ for (let length = 1; length <= parts.length; length += 1) all.add(parts.slice(0, length).join("/"));
34
+ }
35
+ return all;
36
+ }
37
+ }
@@ -20,7 +20,7 @@ export class ProcessReferences extends References {
20
20
  async search(text: string, _context: unknown, page: ReferencePage): Promise<readonly ReferenceItem[]> {
21
21
  const found = text.trim();
22
22
  let source = this.db.processes.asNoTracking();
23
- if (found !== "") source = source.where((p) => p.key.contains(found, { ignoreCase: true }).or(p.title.contains(found, { ignoreCase: true })));
23
+ if (found !== "") source = source.where((p) => p.key.contains(found, { ignoreCase: true }).or(p.titleText.contains(found, { ignoreCase: true })));
24
24
  const rows = await source.orderBy((p) => p.key).skip(page.offset).take(page.limit).toList();
25
25
  return rows.map((row) => ({ id: row.key, title: row.title }));
26
26
  }
@@ -0,0 +1,20 @@
1
+ import { AttributeLookup, type AccessValue, type LookupColumn } from "../../../access";
2
+ import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
3
+
4
+ /** `folder` of rows that only know their process: folder → the keys of its processes (WORKFLOW-002 §6.2). */
5
+ export class ProcessFolderLookup extends AttributeLookup {
6
+ constructor(private readonly db: DefinitionsDbContext) {
7
+ super();
8
+ }
9
+
10
+ async toColumn(values: readonly AccessValue[]): Promise<LookupColumn> {
11
+ const folders = values.filter((value): value is string => typeof value === "string");
12
+ const withoutFolder = values.includes(null);
13
+ if (folders.length === 0 && !withoutFolder) return { column: "processKey", in: [] };
14
+ const rows = await this.db.processes.asNoTracking()
15
+ .where((p) => (folders.length === 0 ? p.folder.isNull() : withoutFolder ? p.folder.isNull().or(p.folder.in(folders)) : p.folder.in(folders)))
16
+ .select((p) => ({ key: p.key }))
17
+ .toList();
18
+ return { column: "processKey", in: rows.map((row) => row.key) };
19
+ }
20
+ }
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { upgradeDocument, type LocalizedText } from "../../../../library/workflow";
3
+ import { canonicalJson } from "../../internal/canonicalJson";
4
+ import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
5
+ import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
6
+ import { WfProcess } from "../model/WfProcess.model";
7
+ import { WfVersion } from "../model/WfVersion.model";
8
+
9
+
10
+ /**
11
+ * Brings the catalog of the store before WORKFLOW-002 into the new one, once, at
12
+ * start (§9): processes and versions, documents through `upgradeDocument`,
13
+ * a title into the default language. Nothing happens without the old tables or
14
+ * when the new catalog has processes. Instances are not brought over. The old
15
+ * tables stay until they are dropped by hand (`upgrading.md`). Goes away with
16
+ * reading of version 1.
17
+ */
18
+ export class CatalogImport {
19
+ constructor(
20
+ private readonly db: DefinitionsDbContext,
21
+ private readonly languages: WorkflowLanguages,
22
+ ) {}
23
+
24
+ /** How many processes came over. */
25
+ async run(): Promise<number> {
26
+ return await this.db.transactionScope(async () => {
27
+ // One importer at a time across application instances.
28
+ await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext('bazis.workflow.import'))");
29
+ const [{ old } = { old: null }] = await this.db.database.querySqlRaw("SELECT to_regclass('public.bazis_wf_def_processes')::text AS old");
30
+ if (old === null || await this.db.processes.asNoTracking().count() > 0) return 0;
31
+ const processes = await this.db.database.querySqlRaw('SELECT * FROM "bazis_wf_def_processes"');
32
+ const versions = await this.db.database.querySqlRaw('SELECT * FROM "bazis_wf_def_versions"');
33
+ const stamps: { readonly table: string; readonly column: string; readonly id: string; readonly at: Date }[] = [];
34
+ for (const row of processes) {
35
+ const title = text(row.title);
36
+ const process = Object.assign(new WfProcess(), {
37
+ id: String(row.id),
38
+ key: text(row.key),
39
+ title: { [this.languages.default]: title } satisfies LocalizedText,
40
+ titleText: title,
41
+ description: row.description === null ? null : { [this.languages.default]: text(row.description) } satisfies LocalizedText,
42
+ draft: upgradeDocument(json(row.draft)),
43
+ draftUpdatedAt: date(row.draftUpdatedAt),
44
+ draftUpdatedByKind: row.draftUpdatedByKind as string | null,
45
+ draftUpdatedById: row.draftUpdatedById as string | null,
46
+ latestVersion: row.latestVersion as number | null,
47
+ retentionDays: row.retentionDays as number | null,
48
+ archivedAt: date(row.archivedAt),
49
+ });
50
+ this.db.processes.add(process);
51
+ stamps.push({ table: "bazis_wf2_def_processes", column: "createdAt", id: process.id, at: date(row.createdAt)! });
52
+ }
53
+ await this.db.saveChanges();
54
+ for (const row of versions) {
55
+ const document = upgradeDocument(json(row.document));
56
+ const version = Object.assign(new WfVersion(), {
57
+ id: String(row.id),
58
+ processId: String(row.processId),
59
+ version: row.version as number,
60
+ document,
61
+ documentHash: createHash("sha256").update(canonicalJson(document)).digest("hex"),
62
+ publishedByKind: text(row.publishedByKind),
63
+ publishedById: text(row.publishedById),
64
+ });
65
+ this.db.versions.add(version);
66
+ stamps.push({ table: "bazis_wf2_def_versions", column: "publishedAt", id: version.id, at: date(row.publishedAt)! });
67
+ }
68
+ await this.db.saveChanges();
69
+ // These columns fill themselves on insert: the history keeps its dates.
70
+ for (const { table, column, id, at } of stamps) {
71
+ await this.db.database.executeSqlRaw(`UPDATE "${table}" SET "${column}" = {0} WHERE "id" = {1}::uuid`, at, id);
72
+ }
73
+ return processes.length;
74
+ });
75
+ }
76
+ }
77
+
78
+ function text(value: unknown): string {
79
+ return typeof value === "string" ? value : String(value ?? "");
80
+ }
81
+
82
+ function date(value: unknown): Date | null {
83
+ return value === null || value === undefined ? null : new Date(value as string | Date);
84
+ }
85
+
86
+ /** A raw `json` column may come parsed or as text, depending on the driver. */
87
+ function json(value: unknown): unknown {
88
+ if (typeof value !== "string") return value;
89
+ try {
90
+ return JSON.parse(value);
91
+ } catch {
92
+ return value;
93
+ }
94
+ }
95
+
@@ -3,14 +3,16 @@ import type { HostedService } from "../../../di/extensions/hosted-service";
3
3
  import { LOGGER } from "../../../kernel";
4
4
  import { WorkflowTools } from "../../internal/WorkflowTools";
5
5
  import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
6
+ import { CatalogImport } from "./CatalogImport";
6
7
  import { CodeProcessCatalog } from "./CodeProcessCatalog";
7
8
  import { definitionProblems, WorkflowSetupError } from "./codeProcesses";
8
9
 
9
10
  /**
10
- * Publishes the processes of `@Module({ processes })` at start, after the
11
- * tables exist (WORKFLOW-001 §14). A document with an unknown Tool stops the
12
- * start with every finding of every process; the same document again gives no
13
- * new version.
11
+ * At start, after the tables exist: brings over the catalog of the old store
12
+ * (WORKFLOW-002 §9), then publishes the processes of `@Module({ processes })`
13
+ * (WORKFLOW-001 §14). A document with an unknown Tool or an `extensions` namespace
14
+ * nobody checks stops the start with every finding of every process; the same
15
+ * document again gives no new version.
14
16
  */
15
17
  export class CodeProcessPublisher implements HostedService {
16
18
  readonly diagnosticName = "workflow:processes";
@@ -18,20 +20,28 @@ export class CodeProcessPublisher implements HostedService {
18
20
  constructor(private readonly services: ServiceProvider) {}
19
21
 
20
22
  async start(): Promise<void> {
21
- const entries = this.services.resolve(CodeProcessCatalog).list();
22
- if (entries.length === 0) return;
23
- const tools = this.services.resolve(WorkflowTools);
24
- const problems = entries.flatMap(({ process, module }) => {
25
- const found = definitionProblems(process, (name) => tools.has(name));
26
- return found.length === 0 ? [] : [`${module}: process "${process.key}":\n${found.map((line) => ` - ${line}`).join("\n")}`];
27
- });
28
- if (problems.length > 0) {
29
- throw markConfigurationError(new WorkflowSetupError(`Processes declared in code cannot be published:\n${problems.join("\n")}`));
30
- }
23
+ const logger = this.services.tryResolve(LOGGER);
31
24
  const scope = this.services.createScope();
32
25
  try {
33
- const results = await scope.resolve(IWorkflowDefinitions).publishCode(entries);
34
- this.services.tryResolve(LOGGER)?.info("workflow processes from code", {
26
+ const imported = await scope.resolve(CatalogImport).run();
27
+ if (imported > 0) logger?.info("workflow catalog brought over from the old store", { processes: imported });
28
+ const entries = this.services.resolve(CodeProcessCatalog).list();
29
+ if (entries.length === 0) return;
30
+ const tools = this.services.resolve(WorkflowTools);
31
+ const definitions = scope.resolve(IWorkflowDefinitions);
32
+ const problems: string[] = [];
33
+ for (const { process, module } of entries) {
34
+ const found = [...definitionProblems(process, (name) => tools.has(name))];
35
+ for (const issue of await definitions.validate(process.document)) {
36
+ if (issue.severity === "error" && (issue.code === "extension_unknown" || issue.code === "extension_invalid")) found.push(`${issue.message} (${issue.code})`);
37
+ }
38
+ if (found.length > 0) problems.push(`${module}: process "${process.key}":\n${found.map((line) => ` - ${line}`).join("\n")}`);
39
+ }
40
+ if (problems.length > 0) {
41
+ throw markConfigurationError(new WorkflowSetupError(`Processes declared in code cannot be published:\n${problems.join("\n")}`));
42
+ }
43
+ const results = await definitions.publishCode(entries);
44
+ logger?.info("workflow processes from code", {
35
45
  published: results.filter((result) => result.published).map((result) => `${result.key} v${result.version}`),
36
46
  unchanged: results.filter((result) => !result.published).map((result) => result.key),
37
47
  });
@@ -6,7 +6,7 @@ import {
6
6
  type BazisModuleMetadata,
7
7
  type ServiceProvider,
8
8
  } from "../../../di";
9
- import { validateProcess, type ProcessIssue } from "../../../../library/workflow";
9
+ import { checkLocalizedText, validateProcess, type ProcessIssue } from "../../../../library/workflow";
10
10
  import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
11
11
  import { isCodeProcess, type CodeProcess } from "./defineProcess";
12
12
 
@@ -66,8 +66,12 @@ export function codeProcessesOf(services: ServiceProvider): readonly OwnedCodePr
66
66
  export function definitionProblems(process: CodeProcess, tools?: (name: string) => boolean): readonly string[] {
67
67
  const problems: string[] = [];
68
68
  if (typeof process.key !== "string" || !KEY.test(process.key)) problems.push("key must match ^[a-z][a-z0-9_.-]{0,63}$");
69
- if (typeof process.title !== "string" || process.title.trim() === "" || process.title.length > 200) problems.push("title must be 1–200 characters");
70
- if (process.description != null && (typeof process.description !== "string" || process.description.length > 2000)) problems.push("description must be up to 2000 characters");
69
+ const title = checkLocalizedText(process.title, 200);
70
+ if (title) problems.push(`title ${title}`);
71
+ const description = process.description == null ? null : checkLocalizedText(process.description, 2000, 0);
72
+ if (description) problems.push(`description ${description}`);
73
+ if (process.folder != null && (typeof process.folder !== "string" || !/^[^/\s](?:[^/]*[^/\s])?(?:\/[^/\s](?:[^/]*[^/\s])?)*$/.test(process.folder))) problems.push("folder must be a path like Sales/Orders");
74
+ if (process.owner != null && (typeof process.owner !== "string" || process.owner === "" || process.owner.length > 200)) problems.push("owner must be up to 200 characters");
71
75
  if (process.retentionDays != null && (!Number.isSafeInteger(process.retentionDays) || process.retentionDays < 1 || process.retentionDays > 36_500)) problems.push("retentionDays must be an integer 1–36500");
72
76
  problems.push(...documentErrors(validateProcess(process.document, tools === undefined ? {} : { tools })));
73
77
  return problems;