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.
- package/cli/templates/process.ts +1 -1
- package/core/agent/AgentToolExecutor.ts +3 -0
- package/core/agent/Tool.contract.ts +1 -1
- package/core/agent/index.ts +1 -0
- package/core/agent/metadata.ts +9 -0
- package/core/agent/semantic.ts +8 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
- package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
- package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
- package/core/workflow/contracts/types.ts +37 -1
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +18 -3
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
- package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
- package/core/workflow/definitions_module/access/Process.references.ts +1 -1
- package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
- package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +26 -16
- package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
- package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
- package/core/workflow/definitions_module/events/Definitions.events.ts +16 -0
- package/core/workflow/definitions_module/http/Processes.controller.ts +72 -25
- package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
- package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
- package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +49 -14
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +128 -45
- package/core/workflow/generated/deps.ts +15 -5
- package/core/workflow/index.ts +17 -3
- package/core/workflow/internal/WorkflowEvents.ts +23 -0
- package/core/workflow/internal/WorkflowLanguages.ts +35 -0
- package/core/workflow/internal/canonicalJson.ts +8 -0
- package/core/workflow/internal/http.ts +53 -5
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +0 -2
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +30 -3
- package/core/workflow/runtime_module/http/Events.controller.ts +2 -1
- package/core/workflow/runtime_module/http/Instances.controller.ts +32 -21
- package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
- package/core/workflow/runtime_module/internal/ToolStep.ts +42 -10
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +83 -31
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +2 -4
- package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
- package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
- package/core/workflow/runtime_module/model/WfInstance.model.ts +28 -3
- package/core/workflow/runtime_module/model/WfJob.model.ts +17 -1
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfToken.model.ts +11 -1
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +19 -6
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +20 -17
- package/core/workflow/tasks_module/events/Tasks.events.ts +15 -0
- package/core/workflow/tasks_module/http/Tasks.controller.ts +21 -18
- package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
- package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +4 -4
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +11 -8
- package/library/openapi/codegen.ts +12 -0
- package/library/validation/types/ValidatorOptions.ts +12 -0
- package/library/workflow/ProcessDocument.ts +59 -23
- package/library/workflow/expressionSyntax.ts +243 -0
- package/library/workflow/expressions.ts +343 -0
- package/library/workflow/formFields.ts +120 -34
- package/library/workflow/index.ts +48 -9
- package/library/workflow/localized.ts +35 -0
- package/library/workflow/masking.ts +58 -0
- package/library/workflow/mermaid.ts +18 -8
- package/library/workflow/nodeKinds.ts +88 -17
- package/library/workflow/palette.ts +108 -0
- package/library/workflow/paths.ts +31 -6
- package/library/workflow/structure.ts +282 -0
- package/library/workflow/upgradeDocument.ts +84 -0
- package/library/workflow/validateProcess.ts +130 -48
- package/package.json +1 -1
- package/library/workflow/conditions.ts +0 -44
package/cli/templates/process.ts
CHANGED
|
@@ -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/
|
|
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
|
}
|
package/core/agent/index.ts
CHANGED
package/core/agent/metadata.ts
CHANGED
|
@@ -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([]),
|
package/core/agent/semantic.ts
CHANGED
|
@@ -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.
|
|
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");
|
package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts}
RENAMED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { defineConfig } from "
|
|
1
|
+
import { defineConfig } from "../../kernel";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Settings of the workflow
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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-
|
|
12
|
-
*
|
|
13
|
-
* own field rules registers its implementation of
|
|
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.
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
70
|
-
if (
|
|
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;
|