bazis 0.133.0 → 0.134.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/core/agent/AgentToolExecutor.ts +5 -0
- package/core/agent/Tool.contract.ts +2 -2
- package/core/agent/metadata.ts +11 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate.ts +8 -0
- package/core/tools/StandardTools.module.ts +14 -0
- package/core/tools/generated/deps.ts +10 -0
- package/core/tools/index.ts +8 -0
- package/core/tools/orm/OrmEntity.references.ts +71 -0
- package/core/tools/orm/ormEntityTools.ts +423 -0
- package/core/workflow/contracts/WorkflowError.ts +2 -1
- package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
- package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -2
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
- package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
- package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
- package/core/workflow/definitions_module/http/Processes.controller.ts +19 -4
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +8 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +24 -0
- package/core/workflow/generated/deps.ts +8 -2
- package/core/workflow/index.ts +5 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +5 -0
- package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
- package/core/workflow/runtime_module/internal/RunLimits.ts +13 -3
- package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
- package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +29 -3
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +7 -0
- package/core/workflow/runtime_module/model/WfJob.model.ts +7 -6
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +21 -1
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +29 -40
- package/library/workflow/cron.ts +103 -24
- package/library/workflow/index.ts +2 -2
- package/library/workflow/nodeKinds.ts +64 -9
- package/library/workflow/paths.ts +2 -2
- package/library/workflow/validateProcess.ts +12 -0
- package/package.json +2 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { WorkflowPortContext } from "./WorkflowExtensions";
|
|
2
|
+
|
|
3
|
+
/** A partition a process is connected to. */
|
|
4
|
+
export interface ProcessPartition {
|
|
5
|
+
/** `null`: the process runs without a partition. */
|
|
6
|
+
readonly partition: string | null;
|
|
7
|
+
/** An IANA time zone for the schedules of this partition; a time zone on the node goes first. */
|
|
8
|
+
readonly timezone?: string;
|
|
9
|
+
/** A process that is switched off here takes no events and no scheduled starts. */
|
|
10
|
+
readonly enabled: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where a process works (WORKFLOW-002 §6): tenants, spaces, organizations. An
|
|
15
|
+
* event starts a process only in a partition listed here, a schedule with
|
|
16
|
+
* `partitions: "all"` runs in every enabled one, and a process connected to a
|
|
17
|
+
* partition cannot be deleted. The application registers its implementation in
|
|
18
|
+
* any module.
|
|
19
|
+
*/
|
|
20
|
+
export abstract class WorkflowPartitions {
|
|
21
|
+
abstract of(processKey: string, context: WorkflowPortContext): readonly ProcessPartition[] | Promise<readonly ProcessPartition[]>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** By default a process has one place to work: no partition. */
|
|
25
|
+
export class DefaultWorkflowPartitions extends WorkflowPartitions {
|
|
26
|
+
of(): readonly ProcessPartition[] {
|
|
27
|
+
return [{ partition: null, enabled: true }];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the catalog asks before it deletes a process. Runtime answers: the catalog
|
|
3
|
+
* knows nothing of instances, and without Runtime there are none.
|
|
4
|
+
*/
|
|
5
|
+
export abstract class WorkflowProcessUsage {
|
|
6
|
+
/** How many `pending`, `running` and `failed` instances the process has. */
|
|
7
|
+
abstract liveInstances(processKey: string): Promise<number>;
|
|
8
|
+
}
|
|
@@ -3,6 +3,7 @@ import { HOSTED_SERVICE, Module, scoped, singleton } from "../../di";
|
|
|
3
3
|
import { SystemWorkflowClock, WorkflowClock } from "../contracts/WorkflowClock";
|
|
4
4
|
import { workflowConfig } from "../contracts/Workflow.config";
|
|
5
5
|
import { DefaultWorkflowExtensions, WorkflowExtensions } from "../contracts/WorkflowExtensions";
|
|
6
|
+
import { DefaultWorkflowPartitions, WorkflowPartitions } from "../contracts/WorkflowPartitions";
|
|
6
7
|
import { WorkflowEvents } from "../internal/WorkflowEvents";
|
|
7
8
|
import { WorkflowLanguages } from "../internal/WorkflowLanguages";
|
|
8
9
|
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
@@ -10,6 +11,7 @@ import { definitionsPermissions } from "./access/Definitions.permissions";
|
|
|
10
11
|
import { FolderReferences } from "./access/Folder.references";
|
|
11
12
|
import { ProcessReferences } from "./access/Process.references";
|
|
12
13
|
import { ProcessFolderLookup } from "./access/ProcessFolder.lookup";
|
|
14
|
+
import { ProcessPartitionLookup } from "./access/ProcessPartition.lookup";
|
|
13
15
|
import { CatalogImport } from "./code/CatalogImport";
|
|
14
16
|
import { CodeProcessCatalog } from "./code/CodeProcessCatalog";
|
|
15
17
|
import { CodeProcessPublisher } from "./code/CodeProcessPublisher";
|
|
@@ -23,7 +25,8 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
|
|
|
23
25
|
/**
|
|
24
26
|
* The process catalog: drafts and published versions, and the processes
|
|
25
27
|
* declared in code ([passport](MODULE.md)). The application checks its own
|
|
26
|
-
* `extensions` by registering its `WorkflowExtensions
|
|
28
|
+
* `extensions` by registering its `WorkflowExtensions` and says where processes
|
|
29
|
+
* work by registering its `WorkflowPartitions`.
|
|
27
30
|
*/
|
|
28
31
|
@Module({
|
|
29
32
|
imports: [AccessModule],
|
|
@@ -37,15 +40,17 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
|
|
|
37
40
|
singleton(CodeProcessCatalog),
|
|
38
41
|
singleton(WorkflowLanguages),
|
|
39
42
|
singleton(WorkflowExtensions, DefaultWorkflowExtensions),
|
|
43
|
+
singleton(WorkflowPartitions, DefaultWorkflowPartitions),
|
|
40
44
|
scoped(WorkflowEvents),
|
|
41
45
|
scoped(CatalogImport),
|
|
42
46
|
scoped(IWorkflowDefinitions, WorkflowDefinitions),
|
|
43
47
|
scoped(ProcessReferences),
|
|
44
48
|
scoped(FolderReferences),
|
|
45
49
|
scoped(ProcessFolderLookup),
|
|
50
|
+
scoped(ProcessPartitionLookup),
|
|
46
51
|
singleton(HOSTED_SERVICE, CodeProcessPublisher),
|
|
47
52
|
],
|
|
48
53
|
controllers: [ProcessesController],
|
|
49
|
-
exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, ProcessFolderLookup],
|
|
54
|
+
exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, WorkflowPartitions, ProcessFolderLookup],
|
|
50
55
|
})
|
|
51
56
|
export class WorkflowDefinitionsModule {}
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { definePermissions } from "../../../access";
|
|
2
|
+
import { ProcessPartitionLookup } from "./ProcessPartition.lookup";
|
|
2
3
|
|
|
3
4
|
const process = { ref: "workflow.process", column: "key" };
|
|
4
5
|
const folder = { ref: "workflow.folder", column: "folder" };
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The rights of the process catalog (WORKFLOW-002 §6.2). A process is one per
|
|
8
|
-
* application
|
|
9
|
-
*
|
|
9
|
+
* application: only reading it has partitions, those `WorkflowPartitions`
|
|
10
|
+
* connects it to.
|
|
10
11
|
*/
|
|
11
12
|
export const definitionsPermissions = definePermissions({
|
|
12
13
|
"workflow.processes.read": {
|
|
13
14
|
title: { en: "See processes", ru: "Видеть процессы" },
|
|
15
|
+
scope: { lookup: ProcessPartitionLookup },
|
|
14
16
|
on: { process, folder },
|
|
15
17
|
},
|
|
16
18
|
"workflow.processes.manage": {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AttributeLookup, type AccessValue, type LookupColumn } from "../../../access";
|
|
2
|
+
import type { ServiceProvider } from "../../../di";
|
|
3
|
+
import { WorkflowPartitions } from "../../contracts/WorkflowPartitions";
|
|
4
|
+
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
5
|
+
import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
|
|
6
|
+
|
|
7
|
+
/** The partitions of a process are not a column: partition → the keys of the processes connected to it (WORKFLOW-002 §6.2). */
|
|
8
|
+
export class ProcessPartitionLookup extends AttributeLookup {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly db: DefinitionsDbContext,
|
|
11
|
+
private readonly languages: WorkflowLanguages,
|
|
12
|
+
private readonly services: ServiceProvider,
|
|
13
|
+
) {
|
|
14
|
+
super();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async toColumn(values: readonly AccessValue[]): Promise<LookupColumn> {
|
|
18
|
+
const port = this.services.resolve(WorkflowPartitions);
|
|
19
|
+
const rows = await this.db.processes.asNoTracking().select((p) => ({ key: p.key })).toList();
|
|
20
|
+
const keys: string[] = [];
|
|
21
|
+
for (const { key } of rows) {
|
|
22
|
+
const partitions = await port.of(key, { partition: null, language: this.languages.default, actor: null });
|
|
23
|
+
if (partitions.some((item) => item.enabled && values.includes(item.partition))) keys.push(key);
|
|
24
|
+
}
|
|
25
|
+
return { column: "key", in: keys };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createEventToken } from "../../../kernel";
|
|
2
2
|
import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
|
|
3
3
|
|
|
4
|
-
export type ProcessChange = "created" | "updated" | "draftSaved" | "published" | "archived" | "restored";
|
|
4
|
+
export type ProcessChange = "created" | "updated" | "draftSaved" | "published" | "archived" | "restored" | "deleted";
|
|
5
5
|
|
|
6
6
|
/** A process of the catalog changed, for the application's audit. Published after the commit. */
|
|
7
7
|
export interface WorkflowProcessChanged {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
2
2
|
import { can, type AccessObject } from "../../../access";
|
|
3
3
|
import type { LocalizedText } from "../../../../library/workflow";
|
|
4
|
-
import { Authorize, Controller, Created, Get, Patch, Post, Put, type HttpContext } from "../../../http";
|
|
4
|
+
import { Authorize, Controller, Created, Delete, Get, Patch, Post, Put, type HttpContext } from "../../../http";
|
|
5
5
|
import { buildListDocument } from "../../../../library/jsonapi";
|
|
6
6
|
import { body, callerOf, defaultLanguage, filterOf, listQuery, optionalInteger, optionalString, optionalText, pagingOfList, param, requiredString, withTitleText } from "../../internal/http";
|
|
7
7
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
@@ -52,10 +52,20 @@ export class ProcessesController {
|
|
|
52
52
|
@Authorize(can("workflow.processes.read"))
|
|
53
53
|
async get(ctx: HttpContext) {
|
|
54
54
|
const key = param(ctx, "key");
|
|
55
|
-
const { language } = await callerOf(ctx, "workflow.processes.read", await this.
|
|
55
|
+
const { language } = await callerOf(ctx, "workflow.processes.read", await this.readObjectOf(key));
|
|
56
56
|
return withTitleText(await this.definitions.get(key), language, defaultLanguage(ctx));
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** An archived process without unfinished instances and partitions (WORKFLOW-002 §4.3). */
|
|
60
|
+
@Delete("processes/:key")
|
|
61
|
+
@Authorize(can("workflow.processes.manage"))
|
|
62
|
+
async remove(ctx: HttpContext) {
|
|
63
|
+
const key = param(ctx, "key");
|
|
64
|
+
const { actor, request } = await callerOf(ctx, "workflow.processes.manage", await this.objectOf(key));
|
|
65
|
+
await this.definitions.remove(key, actor, { request });
|
|
66
|
+
ctx.res.status(204);
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
@Patch("processes/:key")
|
|
60
70
|
@Authorize(can("workflow.processes.manage"))
|
|
61
71
|
async update(ctx: HttpContext) {
|
|
@@ -102,7 +112,7 @@ export class ProcessesController {
|
|
|
102
112
|
@Authorize(can("workflow.processes.read"))
|
|
103
113
|
async versions(ctx: HttpContext) {
|
|
104
114
|
const key = param(ctx, "key");
|
|
105
|
-
await callerOf(ctx, "workflow.processes.read", await this.
|
|
115
|
+
await callerOf(ctx, "workflow.processes.read", await this.readObjectOf(key));
|
|
106
116
|
return { data: await this.definitions.versions(key) };
|
|
107
117
|
}
|
|
108
118
|
|
|
@@ -110,7 +120,7 @@ export class ProcessesController {
|
|
|
110
120
|
@Authorize(can("workflow.processes.read"))
|
|
111
121
|
async version(ctx: HttpContext) {
|
|
112
122
|
const key = param(ctx, "key");
|
|
113
|
-
await callerOf(ctx, "workflow.processes.read", await this.
|
|
123
|
+
await callerOf(ctx, "workflow.processes.read", await this.readObjectOf(key));
|
|
114
124
|
const version = optionalInteger(param(ctx, "version"), "version");
|
|
115
125
|
if (version === undefined) throw new WorkflowError("invalid_input", "version must be an integer");
|
|
116
126
|
return await this.definitions.version(key, version);
|
|
@@ -144,6 +154,11 @@ export class ProcessesController {
|
|
|
144
154
|
return { process: key, folder: await this.definitions.folderOf(key) };
|
|
145
155
|
}
|
|
146
156
|
|
|
157
|
+
/** Reading a process is allowed by the partitions it works in. */
|
|
158
|
+
private async readObjectOf(key: string): Promise<AccessObject> {
|
|
159
|
+
return { ...await this.objectOf(key), scope: await this.definitions.partitionsOf(key) };
|
|
160
|
+
}
|
|
161
|
+
|
|
147
162
|
private localized(items: readonly ProcessSummary[], language: string, ctx: HttpContext) {
|
|
148
163
|
return items.map((item) => withTitleText(item, language, defaultLanguage(ctx)));
|
|
149
164
|
}
|
|
@@ -102,6 +102,8 @@ export abstract class IWorkflowDefinitions {
|
|
|
102
102
|
abstract get(key: string): Promise<ProcessDetails>;
|
|
103
103
|
/** The folder of a process, for a check of rights; `null` also for a process that does not exist. */
|
|
104
104
|
abstract folderOf(key: string): Promise<string | null>;
|
|
105
|
+
/** The partitions a process works in, for a check of rights. */
|
|
106
|
+
abstract partitionsOf(key: string): Promise<readonly (string | null)[]>;
|
|
105
107
|
/** Every finding of a document, with the application's `extensions` checks; nothing is stored. */
|
|
106
108
|
abstract validate(document: unknown, options?: ValidateOptions): Promise<readonly ProcessIssue[]>;
|
|
107
109
|
/** What a designer builds its forms from: node kinds, functions, paths and Tools. */
|
|
@@ -109,6 +111,12 @@ export abstract class IWorkflowDefinitions {
|
|
|
109
111
|
abstract create(process: CreateProcess, actor: WorkflowActor, options?: WorkflowAuditOptions): Promise<ProcessDetails>;
|
|
110
112
|
/** A process declared in code takes only `archived`; other fields give `conflict`. */
|
|
111
113
|
abstract update(key: string, changes: UpdateProcess, actor: WorkflowActor, options?: WorkflowAuditOptions): Promise<ProcessDetails>;
|
|
114
|
+
/**
|
|
115
|
+
* Deletes an archived process with its versions: `wrong_status` when it is not
|
|
116
|
+
* archived, `conflict` when it is declared in code, `in_use` when it has unfinished
|
|
117
|
+
* instances or partitions. Finished instances and their history stay.
|
|
118
|
+
*/
|
|
119
|
+
abstract remove(key: string, actor: WorkflowActor, options?: WorkflowAuditOptions): Promise<void>;
|
|
112
120
|
/** Stores the draft even with errors; `conflict` for a process declared in code and when `expected` is out of date. */
|
|
113
121
|
abstract saveDraft(key: string, document: unknown, actor: WorkflowActor, options?: SaveDraftOptions): Promise<SavedDraft>;
|
|
114
122
|
/**
|
|
@@ -8,6 +8,8 @@ import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowAuditOptions, typ
|
|
|
8
8
|
import type { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
9
9
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
10
10
|
import { WorkflowExtensions, type ExtensionTarget, type WorkflowPortContext } from "../../contracts/WorkflowExtensions";
|
|
11
|
+
import { WorkflowPartitions } from "../../contracts/WorkflowPartitions";
|
|
12
|
+
import { WorkflowProcessUsage } from "../../contracts/WorkflowProcessUsage";
|
|
11
13
|
import { canonicalJson } from "../../internal/canonicalJson";
|
|
12
14
|
import type { WorkflowEvents } from "../../internal/WorkflowEvents";
|
|
13
15
|
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
@@ -83,6 +85,15 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
83
85
|
return row?.folder ?? null;
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
async partitionsOf(key: string): Promise<readonly (string | null)[]> {
|
|
89
|
+
const partitions = await this.partitions(key, null);
|
|
90
|
+
return partitions.filter((item) => item.enabled).map((item) => item.partition);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async partitions(key: string, actor: WorkflowActor | null) {
|
|
94
|
+
return await this.services.resolve(WorkflowPartitions).of(key, { partition: null, language: this.languages.default, actor });
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
async validate(document: unknown, options: ValidateOptions = {}): Promise<readonly ProcessIssue[]> {
|
|
87
98
|
const port = this.services.resolve(WorkflowExtensions);
|
|
88
99
|
const namespaces = new Set(port.namespaces());
|
|
@@ -153,6 +164,19 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
153
164
|
return this.detailsOf(row);
|
|
154
165
|
}
|
|
155
166
|
|
|
167
|
+
async remove(key: string, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<void> {
|
|
168
|
+
const row = await this.process(key, false);
|
|
169
|
+
this.assertNotCode(key);
|
|
170
|
+
if (row.archivedAt === null) throw new WorkflowError("wrong_status", `Process ${key} is not archived`);
|
|
171
|
+
// An archived process starts nothing more, so what is counted here cannot grow.
|
|
172
|
+
const connected = (await this.partitions(key, actor)).filter((item) => item.partition !== null).map((item) => item.partition);
|
|
173
|
+
if (connected.length > 0) throw new WorkflowError("in_use", `Process ${key} is connected to partitions: ${connected.join(", ")}`);
|
|
174
|
+
const live = await this.services.tryResolve(WorkflowProcessUsage)?.liveInstances(key) ?? 0;
|
|
175
|
+
if (live > 0) throw new WorkflowError("in_use", `Process ${key} has ${live} unfinished instances`);
|
|
176
|
+
await this.db.processes.asNoTracking().where((p) => p.key.eq(key)).executeDelete();
|
|
177
|
+
await this.changed("deleted", key, actor, options);
|
|
178
|
+
}
|
|
179
|
+
|
|
156
180
|
async saveDraft(key: string, document: unknown, actor: WorkflowActor, options: SaveDraftOptions = {}): Promise<SavedDraft> {
|
|
157
181
|
const issues = await this.validate(document, { actor, ...(options.language === undefined ? {} : { language: options.language }) });
|
|
158
182
|
if (issues.some((issue) => issue.code === "too_large")) throw new WorkflowError("invalid_document", "The document is too large", issues);
|
|
@@ -8,6 +8,7 @@ import { WorkflowClock } from "../contracts/WorkflowClock";
|
|
|
8
8
|
import { FolderReferences } from "../definitions_module/access/Folder.references";
|
|
9
9
|
import { ProcessReferences } from "../definitions_module/access/Process.references";
|
|
10
10
|
import { ProcessFolderLookup } from "../definitions_module/access/ProcessFolder.lookup";
|
|
11
|
+
import { ProcessPartitionLookup } from "../definitions_module/access/ProcessPartition.lookup";
|
|
11
12
|
import { CatalogImport } from "../definitions_module/code/CatalogImport";
|
|
12
13
|
import { CodeProcessCatalog } from "../definitions_module/code/CodeProcessCatalog";
|
|
13
14
|
import { CodeProcessPublisher } from "../definitions_module/code/CodeProcessPublisher";
|
|
@@ -23,8 +24,10 @@ import { WorkflowWorker } from "../runtime_module/background/WorkflowWorker";
|
|
|
23
24
|
import { EventsController } from "../runtime_module/http/Events.controller";
|
|
24
25
|
import { InstancesController } from "../runtime_module/http/Instances.controller";
|
|
25
26
|
import { ChildCalls } from "../runtime_module/internal/ChildCalls";
|
|
27
|
+
import { EventDelivery } from "../runtime_module/internal/EventDelivery";
|
|
26
28
|
import { GraphCache } from "../runtime_module/internal/GraphCache";
|
|
27
29
|
import { RunLimits } from "../runtime_module/internal/RunLimits";
|
|
30
|
+
import { RuntimeProcessUsage } from "../runtime_module/internal/RuntimeProcessUsage";
|
|
28
31
|
import { Schedules } from "../runtime_module/internal/Schedules";
|
|
29
32
|
import { TimerStep } from "../runtime_module/internal/TimerStep";
|
|
30
33
|
import { ToolStep } from "../runtime_module/internal/ToolStep";
|
|
@@ -44,15 +47,18 @@ registerFrameworkClassDeps([
|
|
|
44
47
|
[CodeProcessCatalog, [ServiceProvider]],
|
|
45
48
|
[CodeProcessPublisher, [ServiceProvider]],
|
|
46
49
|
[DefinitionsDbContext, [DbContextOptions]],
|
|
50
|
+
[EventDelivery, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions, WorkflowLanguages, ServiceProvider]],
|
|
47
51
|
[EventsController, [IWorkflowRuntime]],
|
|
48
52
|
[FolderReferences, [DefinitionsDbContext]],
|
|
49
53
|
[InstancesController, [IWorkflowRuntime, IWorkflowDefinitions]],
|
|
50
54
|
[ProcessesController, [IWorkflowDefinitions]],
|
|
51
55
|
[ProcessFolderLookup, [DefinitionsDbContext]],
|
|
56
|
+
[ProcessPartitionLookup, [DefinitionsDbContext, WorkflowLanguages, ServiceProvider]],
|
|
52
57
|
[ProcessReferences, [DefinitionsDbContext]],
|
|
53
58
|
[RunLimits, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions]],
|
|
54
59
|
[RuntimeDbContext, [DbContextOptions]],
|
|
55
|
-
[
|
|
60
|
+
[RuntimeProcessUsage, [RuntimeDbContext]],
|
|
61
|
+
[Schedules, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions, WorkflowLanguages, ServiceProvider]],
|
|
56
62
|
[TaskEvents, [TasksDbContext]],
|
|
57
63
|
[TasksController, [IWorkflowTasks]],
|
|
58
64
|
[TasksDbContext, [DbContextOptions]],
|
|
@@ -63,7 +69,7 @@ registerFrameworkClassDeps([
|
|
|
63
69
|
[WorkflowEvents, [EventBus, ServiceProvider, "optional:Logger"]],
|
|
64
70
|
[WorkflowLanguages, ["Config:workflow"]],
|
|
65
71
|
[WorkflowRetention, [ServiceProvider, "Config:workflow"]],
|
|
66
|
-
[WorkflowRuntime, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions, ServiceProvider, WorkflowLanguages]],
|
|
72
|
+
[WorkflowRuntime, [RuntimeDbContext, WorkflowEngine, EventDelivery, IWorkflowDefinitions, ServiceProvider, WorkflowLanguages]],
|
|
67
73
|
[WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock, WorkflowEvents]],
|
|
68
74
|
[WorkflowTools, [ServiceProvider]],
|
|
69
75
|
[WorkflowWorker, [ServiceProvider, "Config:workflow"]],
|
package/core/workflow/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export {
|
|
|
33
33
|
export {
|
|
34
34
|
IWorkflowRuntime,
|
|
35
35
|
type CancelInstance,
|
|
36
|
+
type EmitCause,
|
|
36
37
|
type EmitOptions,
|
|
37
38
|
type EmitResult,
|
|
38
39
|
type HistoryEntry,
|
|
@@ -41,6 +42,9 @@ export {
|
|
|
41
42
|
type InstanceSummary,
|
|
42
43
|
type RetryInstance,
|
|
43
44
|
type StartInstance,
|
|
45
|
+
type StartMany,
|
|
46
|
+
type StartManyItem,
|
|
47
|
+
type StartManyResult,
|
|
44
48
|
type TokenDetails,
|
|
45
49
|
} from "./runtime_module/services/IWorkflowRuntime.service";
|
|
46
50
|
export {
|
|
@@ -65,6 +69,7 @@ export type { InstanceStatus, StartedVia } from "./runtime_module/model/WfInstan
|
|
|
65
69
|
export type { TokenStatus } from "./runtime_module/model/WfToken.model";
|
|
66
70
|
export { WorkflowInputValidator, DefaultWorkflowInputValidator, type WorkflowInputContext } from "./contracts/WorkflowInputValidator";
|
|
67
71
|
export { WorkflowExtensions, DefaultWorkflowExtensions, type ExtensionTarget, type WorkflowPortContext } from "./contracts/WorkflowExtensions";
|
|
72
|
+
export { WorkflowPartitions, DefaultWorkflowPartitions, type ProcessPartition } from "./contracts/WorkflowPartitions";
|
|
68
73
|
export { WorkflowError, type WorkflowErrorCode } from "./contracts/WorkflowError";
|
|
69
74
|
export type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging, WorkflowRequest, WorkflowToolMetadata } from "./contracts/types";
|
|
70
75
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AccessModule } from "../../access";
|
|
2
2
|
import { Module, scoped, singleton } from "../../di";
|
|
3
3
|
import { DefaultWorkflowInputValidator, WorkflowInputValidator } from "../contracts/WorkflowInputValidator";
|
|
4
|
+
import { WorkflowProcessUsage } from "../contracts/WorkflowProcessUsage";
|
|
4
5
|
import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
|
|
5
6
|
import { runtimePermissions } from "./access/Runtime.permissions";
|
|
6
7
|
import { WorkflowRetention } from "./background/WorkflowRetention";
|
|
@@ -8,8 +9,10 @@ import { WorkflowWorker } from "./background/WorkflowWorker";
|
|
|
8
9
|
import { EventsController } from "./http/Events.controller";
|
|
9
10
|
import { InstancesController } from "./http/Instances.controller";
|
|
10
11
|
import { ChildCalls } from "./internal/ChildCalls";
|
|
12
|
+
import { EventDelivery } from "./internal/EventDelivery";
|
|
11
13
|
import { GraphCache } from "./internal/GraphCache";
|
|
12
14
|
import { RunLimits } from "./internal/RunLimits";
|
|
15
|
+
import { RuntimeProcessUsage } from "./internal/RuntimeProcessUsage";
|
|
13
16
|
import { Schedules } from "./internal/Schedules";
|
|
14
17
|
import { TimerStep } from "./internal/TimerStep";
|
|
15
18
|
import { ToolStep } from "./internal/ToolStep";
|
|
@@ -35,6 +38,8 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
|
|
|
35
38
|
scoped(Schedules),
|
|
36
39
|
scoped(ChildCalls),
|
|
37
40
|
scoped(RunLimits),
|
|
41
|
+
scoped(EventDelivery),
|
|
42
|
+
scoped(WorkflowProcessUsage, RuntimeProcessUsage),
|
|
38
43
|
singleton(WorkflowInputValidator, DefaultWorkflowInputValidator),
|
|
39
44
|
scoped(IWorkflowRuntime, WorkflowRuntime),
|
|
40
45
|
],
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { durationMs, evaluateExpression, inputSpecOf, isTruthy, type Expr, type FormIssue, type InstanceData, type ProcessNode } from "../../../../library/workflow";
|
|
3
|
+
import { LOGGER } from "../../../kernel";
|
|
4
|
+
import type { ServiceProvider } from "../../../di";
|
|
5
|
+
import { isUuid, type WorkflowActor, type WorkflowRequest } from "../../contracts/types";
|
|
6
|
+
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
7
|
+
import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
|
|
8
|
+
import { WorkflowPartitions } from "../../contracts/WorkflowPartitions";
|
|
9
|
+
import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
10
|
+
import { canonicalJson } from "../../internal/canonicalJson";
|
|
11
|
+
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
12
|
+
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
13
|
+
import { WfJob } from "../model/WfJob.model";
|
|
14
|
+
import type { EmitCause, EmitResult } from "../services/IWorkflowRuntime.service";
|
|
15
|
+
import type { WorkflowEngine } from "./WorkflowEngine";
|
|
16
|
+
|
|
17
|
+
/** An event on its way: what `emit` got, with the defaults in place. */
|
|
18
|
+
export interface EventMessage {
|
|
19
|
+
readonly eventKey: string;
|
|
20
|
+
readonly payload: Record<string, unknown>;
|
|
21
|
+
readonly correlation: string | null;
|
|
22
|
+
readonly partition: string | null;
|
|
23
|
+
readonly actor: WorkflowActor;
|
|
24
|
+
readonly request: WorkflowRequest | null;
|
|
25
|
+
readonly cause: EmitCause | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface EventStartConfig {
|
|
29
|
+
readonly filter?: Expr;
|
|
30
|
+
readonly debounce?: { readonly key: Expr; readonly window: string };
|
|
31
|
+
readonly delay?: string;
|
|
32
|
+
readonly ignoreOwn?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A process the event is to start, with its checked input. */
|
|
36
|
+
interface PlannedStart {
|
|
37
|
+
readonly version: VersionDetails;
|
|
38
|
+
readonly nodeId: string;
|
|
39
|
+
readonly config: EventStartConfig;
|
|
40
|
+
readonly input: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Takes an event to whoever waits for it and to the processes it starts
|
|
45
|
+
* (WORKFLOW-002 §5.1, §5.2). An `event` job (`emitLater`) turns into an
|
|
46
|
+
* `eventStart` job per process in one transaction, and each of those starts its
|
|
47
|
+
* instance in one transaction with its own removal: a repeat after a crash starts
|
|
48
|
+
* nothing twice. Waits are safe as they are: a subscription leaves with its step.
|
|
49
|
+
*/
|
|
50
|
+
export class EventDelivery {
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly db: RuntimeDbContext,
|
|
53
|
+
private readonly engine: WorkflowEngine,
|
|
54
|
+
private readonly definitions: IWorkflowDefinitions,
|
|
55
|
+
private readonly languages: WorkflowLanguages,
|
|
56
|
+
/** The scope: the application's own ports replace the defaults. */
|
|
57
|
+
private readonly services: ServiceProvider,
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
/** Moves the waits and starts the processes, now or, under `debounce` and `delay`, by a job. */
|
|
61
|
+
async emit(message: EventMessage): Promise<EmitResult> {
|
|
62
|
+
const continued = await this.continueWaits(message);
|
|
63
|
+
const { planned, rejected } = await this.plan(message);
|
|
64
|
+
const started: string[] = [];
|
|
65
|
+
const skipped: { processKey: string; instanceId: string }[] = [];
|
|
66
|
+
const deferred: { processKey: string }[] = [];
|
|
67
|
+
for (const start of planned) {
|
|
68
|
+
if (start.config.debounce !== undefined || start.config.delay !== undefined) {
|
|
69
|
+
await this.defer(start, message);
|
|
70
|
+
deferred.push({ processKey: start.version.key });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const begun = await this.begin(start.version, start.nodeId, start.input, message);
|
|
74
|
+
if (begun.skipped) skipped.push({ processKey: start.version.key, instanceId: begun.id });
|
|
75
|
+
else started.push(begun.id);
|
|
76
|
+
}
|
|
77
|
+
return { started, skipped, deferred, continued, rejected };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** An `event` job: the waits move, and the job becomes an `eventStart` job per process it starts. */
|
|
81
|
+
async deliverLater(job: WfJob): Promise<void> {
|
|
82
|
+
const message = job.payload as EventMessage;
|
|
83
|
+
await this.continueWaits(message);
|
|
84
|
+
const { planned, rejected } = await this.plan(message);
|
|
85
|
+
for (const { processKey, issues } of rejected) this.services.tryResolve(LOGGER)?.warn("workflow event did not start a process", { event: message.eventKey, processKey, issues });
|
|
86
|
+
await this.db.transactionScope(async () => {
|
|
87
|
+
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
88
|
+
for (const start of planned) await this.defer(start, message);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An `eventStart` job: its instance starts together with the removal of the job. An archived process takes nothing. */
|
|
93
|
+
async startLater(job: WfJob): Promise<void> {
|
|
94
|
+
const { message, input } = job.payload as { readonly message: EventMessage; readonly input: Record<string, unknown> };
|
|
95
|
+
const version = job.versionId === null ? undefined : await this.definitions.versionById(job.versionId);
|
|
96
|
+
if (version === undefined || job.nodeId === null || !await this.available(version.key)) {
|
|
97
|
+
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const graph = this.engine.cache(version);
|
|
101
|
+
await this.db.transactionScope(async () => {
|
|
102
|
+
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
103
|
+
await this.engine.begin(graph, job.nodeId!, this.beginOptions(input, message));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async available(processKey: string): Promise<boolean> {
|
|
108
|
+
try {
|
|
109
|
+
await this.definitions.latest(processKey);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof WorkflowError) return false;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Every wait of the event: `eventWait.until` may leave a step waiting. */
|
|
118
|
+
private async continueWaits(message: EventMessage): Promise<string[]> {
|
|
119
|
+
const { eventKey, correlation, partition, payload } = message;
|
|
120
|
+
let waits = this.db.subscriptions.asNoTracking().where((s) => s.eventKey.eq(eventKey));
|
|
121
|
+
waits = correlation === null ? waits.where((s) => s.correlation.isNull()) : waits.where((s) => s.correlation.isNull().or(s.correlation.eq(correlation)));
|
|
122
|
+
if (partition !== null) waits = waits.where((s) => s.partition.eq(partition));
|
|
123
|
+
const continued = new Set<string>();
|
|
124
|
+
for (const wait of await waits.orderBy((s) => s.id).toList()) {
|
|
125
|
+
const moved = await this.engine.inStep(wait.instanceId, async (step) => {
|
|
126
|
+
if (!this.engine.eventAccepted(step, wait.tokenId, payload)) return false;
|
|
127
|
+
await this.db.subscriptions.asNoTracking().where((s) => s.id.eq(wait.id)).executeDelete();
|
|
128
|
+
return await this.engine.eventReceived(step, wait.tokenId, eventKey, payload);
|
|
129
|
+
});
|
|
130
|
+
if (moved === true) continued.add(wait.instanceId);
|
|
131
|
+
}
|
|
132
|
+
return [...continued];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The `eventStart` nodes that take the event: connected to its partition, past `ignoreOwn` and `filter`, with a valid input. */
|
|
136
|
+
private async plan(message: EventMessage): Promise<{ readonly planned: PlannedStart[]; readonly rejected: { processKey: string; issues: readonly FormIssue[] }[] }> {
|
|
137
|
+
const { eventKey, payload, partition, actor } = message;
|
|
138
|
+
const planned: PlannedStart[] = [];
|
|
139
|
+
const rejected: { processKey: string; issues: readonly FormIssue[] }[] = [];
|
|
140
|
+
const data = eventData(message, this.engine.now());
|
|
141
|
+
const own = await this.causeProcess(message.cause);
|
|
142
|
+
for (const version of await this.definitions.published()) {
|
|
143
|
+
for (const [nodeId, node] of Object.entries(version.document.nodes)) {
|
|
144
|
+
if (node.kind !== "eventStart" || node.config?.event !== eventKey) continue;
|
|
145
|
+
const config = eventStartConfig(node);
|
|
146
|
+
if (!await this.connected(version.key, partition)) continue;
|
|
147
|
+
if (config.ignoreOwn === true && own === version.key) continue;
|
|
148
|
+
if (config.filter !== undefined && !isTruthy(evaluateExpression(config.filter, data))) continue;
|
|
149
|
+
// An event carries more than a process needs: fields the input does not declare are dropped, not refused.
|
|
150
|
+
const { value, issues: all } = await this.services.resolve(WorkflowInputValidator).validate(inputSpecOf(version.document), payload, { purpose: "input", processKey: version.key, partition, language: this.languages.default, actor });
|
|
151
|
+
const issues = all.filter((issue) => issue.code !== "unknown");
|
|
152
|
+
if (issues.length > 0) rejected.push({ processKey: version.key, issues });
|
|
153
|
+
else planned.push({ version, nodeId, config, input: value });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { planned, rejected };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Whether the process works in the partition of the event; an event without a partition is for processes without them. */
|
|
160
|
+
private async connected(processKey: string, partition: string | null): Promise<boolean> {
|
|
161
|
+
const partitions = await this.services.resolve(WorkflowPartitions).of(processKey, { partition, language: this.languages.default, actor: null });
|
|
162
|
+
return partitions.some((item) => item.enabled && item.partition === partition);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The process of the instance that caused the event. */
|
|
166
|
+
private async causeProcess(cause: EmitCause | null): Promise<string | undefined> {
|
|
167
|
+
const id = cause?.instanceId;
|
|
168
|
+
if (!isUuid(id)) return undefined;
|
|
169
|
+
const row = await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(id));
|
|
170
|
+
return row?.processKey;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async begin(version: VersionDetails, nodeId: string, input: Record<string, unknown>, message: EventMessage) {
|
|
174
|
+
return await this.engine.begin(this.engine.cache(version), nodeId, this.beginOptions(input, message));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private beginOptions(input: Record<string, unknown>, message: EventMessage) {
|
|
178
|
+
return {
|
|
179
|
+
input,
|
|
180
|
+
partition: message.partition,
|
|
181
|
+
businessKey: message.correlation,
|
|
182
|
+
startedVia: "event" as const,
|
|
183
|
+
actor: message.actor,
|
|
184
|
+
request: message.request,
|
|
185
|
+
event: { key: message.eventKey, payload: message.payload },
|
|
186
|
+
...(message.cause === null ? {} : { cause: message.cause }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* An `eventStart` job due after `delay` and the `debounce` window. The window runs
|
|
192
|
+
* from its first event and later ones replace what the job carries, so a steady
|
|
193
|
+
* stream of events still starts a process once per window. A job a worker holds
|
|
194
|
+
* takes no more events: the next one opens a new window.
|
|
195
|
+
*/
|
|
196
|
+
private async defer(start: PlannedStart, message: EventMessage): Promise<void> {
|
|
197
|
+
const { version, nodeId, config, input } = start;
|
|
198
|
+
const now = this.engine.now();
|
|
199
|
+
const dueAt = new Date(now.getTime() + (config.debounce === undefined ? 0 : durationMs(config.debounce.window) ?? 0) + (config.delay === undefined ? 0 : durationMs(config.delay) ?? 0));
|
|
200
|
+
const payload = { message, input };
|
|
201
|
+
const dedupeKey = config.debounce === undefined ? null : debounceKey(version.key, nodeId, message, config.debounce.key, now);
|
|
202
|
+
const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "eventStart" as const, versionId: version.id, nodeId, partition: message.partition, dueAt, payload, dedupeKey, createdAt: now });
|
|
203
|
+
if (dedupeKey === null) {
|
|
204
|
+
await this.db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
await this.db.transactionScope(async () => {
|
|
208
|
+
await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext({0}))", dedupeKey);
|
|
209
|
+
const merged = await this.db.jobs.asNoTracking().where((j) => j.dedupeKey.eq(dedupeKey).and(j.lockedUntil.isNull())).executeUpdate({ payload, versionId: version.id });
|
|
210
|
+
if (merged.affectedRows === 0) await this.db.jobs.insertIfAbsent(job, { conflictBy: (j) => [j.id] });
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function eventStartConfig(node: ProcessNode): EventStartConfig {
|
|
216
|
+
return (node.config ?? {}) as unknown as EventStartConfig;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** What the expressions of an `eventStart` read: there is no instance yet. */
|
|
220
|
+
function eventData(message: EventMessage, now: Date): InstanceData {
|
|
221
|
+
return {
|
|
222
|
+
input: {},
|
|
223
|
+
vars: {},
|
|
224
|
+
nodes: {},
|
|
225
|
+
event: message.payload,
|
|
226
|
+
trigger: { via: "event", at: now.toISOString(), actor: message.actor, eventKey: message.eventKey, ...(message.cause === null ? {} : { cause: message.cause }) },
|
|
227
|
+
now: now.toISOString(),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function debounceKey(processKey: string, nodeId: string, message: EventMessage, key: Expr, now: Date): string {
|
|
232
|
+
const value = evaluateExpression(key, eventData(message, now)) ?? null;
|
|
233
|
+
return createHash("sha256").update(canonicalJson([processKey, nodeId, message.partition, value])).digest("hex");
|
|
234
|
+
}
|