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.
Files changed (38) hide show
  1. package/core/agent/AgentToolExecutor.ts +5 -0
  2. package/core/agent/Tool.contract.ts +2 -2
  3. package/core/agent/metadata.ts +11 -0
  4. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  5. package/core/scripts/di-generate.ts +8 -0
  6. package/core/tools/StandardTools.module.ts +14 -0
  7. package/core/tools/generated/deps.ts +10 -0
  8. package/core/tools/index.ts +8 -0
  9. package/core/tools/orm/OrmEntity.references.ts +71 -0
  10. package/core/tools/orm/ormEntityTools.ts +423 -0
  11. package/core/workflow/contracts/WorkflowError.ts +2 -1
  12. package/core/workflow/contracts/WorkflowPartitions.ts +29 -0
  13. package/core/workflow/contracts/WorkflowProcessUsage.ts +8 -0
  14. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +7 -2
  15. package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -2
  16. package/core/workflow/definitions_module/access/ProcessPartition.lookup.ts +27 -0
  17. package/core/workflow/definitions_module/events/Definitions.events.ts +1 -1
  18. package/core/workflow/definitions_module/http/Processes.controller.ts +19 -4
  19. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +8 -0
  20. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +24 -0
  21. package/core/workflow/generated/deps.ts +8 -2
  22. package/core/workflow/index.ts +5 -0
  23. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +5 -0
  24. package/core/workflow/runtime_module/internal/EventDelivery.ts +234 -0
  25. package/core/workflow/runtime_module/internal/RunLimits.ts +13 -3
  26. package/core/workflow/runtime_module/internal/RuntimeProcessUsage.ts +13 -0
  27. package/core/workflow/runtime_module/internal/Schedules.ts +97 -30
  28. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +29 -3
  29. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +7 -0
  30. package/core/workflow/runtime_module/model/WfJob.model.ts +7 -6
  31. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +21 -1
  32. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +29 -40
  33. package/library/workflow/cron.ts +103 -24
  34. package/library/workflow/index.ts +2 -2
  35. package/library/workflow/nodeKinds.ts +64 -9
  36. package/library/workflow/paths.ts +2 -2
  37. package/library/workflow/validateProcess.ts +12 -0
  38. 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, so these have no partition until `WorkflowPartitions` connects
9
- * processes to partitions (stage 4).
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.objectOf(key));
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.objectOf(key));
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.objectOf(key));
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
- [Schedules, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions]],
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"]],
@@ -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
+ }