bazis 0.126.0 → 0.128.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 (41) hide show
  1. package/cli/generateModule.ts +5 -5
  2. package/cli/generateProcess.ts +56 -0
  3. package/cli/main.ts +35 -1
  4. package/cli/moduleRegistration.ts +23 -10
  5. package/cli/parseCli.ts +45 -3
  6. package/cli/templates/process.ts +28 -0
  7. package/cli/workflow.ts +66 -0
  8. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  9. package/core/workflow/Workflow.module.ts +4 -2
  10. package/core/workflow/contracts/WorkflowClock.ts +21 -0
  11. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +14 -6
  12. package/core/workflow/definitions_module/code/CodeProcessCatalog.ts +19 -0
  13. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +44 -0
  14. package/core/workflow/definitions_module/code/codeProcesses.ts +85 -0
  15. package/core/workflow/definitions_module/code/defineProcess.ts +40 -0
  16. package/core/workflow/definitions_module/code/inspectProcesses.ts +74 -0
  17. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +11 -2
  18. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +106 -45
  19. package/core/workflow/index.ts +21 -0
  20. package/core/workflow/internal/http.ts +6 -0
  21. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +2 -0
  22. package/core/workflow/runtime_module/background/WorkflowRetention.ts +9 -3
  23. package/core/workflow/runtime_module/background/WorkflowWorker.ts +11 -88
  24. package/core/workflow/runtime_module/events/Runtime.events.ts +6 -0
  25. package/core/workflow/runtime_module/internal/Schedules.ts +4 -2
  26. package/core/workflow/runtime_module/internal/ToolStep.ts +3 -3
  27. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +18 -11
  28. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +100 -0
  29. package/core/workflow/tasks_module/WorkflowTasks.module.ts +31 -0
  30. package/core/workflow/tasks_module/events/TaskEvents.ts +23 -0
  31. package/core/workflow/tasks_module/http/Tasks.controller.ts +67 -0
  32. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +14 -0
  33. package/core/workflow/tasks_module/model/WfTask.model.ts +69 -0
  34. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +52 -0
  35. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +169 -0
  36. package/core/workflow/testing/index.ts +162 -0
  37. package/library/terminal/messages.ts +28 -0
  38. package/library/terminal/screen.ts +1 -0
  39. package/library/workflow/index.ts +1 -0
  40. package/library/workflow/mermaid.ts +87 -0
  41. package/package.json +2 -1
@@ -0,0 +1,85 @@
1
+ import {
2
+ createModuleOwnedMetadataChannel,
3
+ registerModuleOwnedContributionValidator,
4
+ registerModuleOwnedProviderContributor,
5
+ DiContainer,
6
+ type BazisModuleMetadata,
7
+ type ServiceProvider,
8
+ } from "../../../di";
9
+ import { validateProcess, type ProcessIssue } from "../../../../library/workflow";
10
+ import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
11
+ import { isCodeProcess, type CodeProcess } from "./defineProcess";
12
+
13
+ /** A process of `@Module({ processes })` with the module that declares it. */
14
+ export interface OwnedCodeProcess {
15
+ readonly process: CodeProcess;
16
+ readonly module: string;
17
+ }
18
+
19
+ const PROCESS_CHANNEL = createModuleOwnedMetadataChannel<CodeProcess>("workflow.process/v1");
20
+ const KEY = /^[a-z][a-z0-9_.-]{0,63}$/;
21
+
22
+ /** A problem of the processes declared in code; the application reports it as a configuration error. */
23
+ export class WorkflowSetupError extends Error {
24
+ override readonly name = "WorkflowSetupError";
25
+ }
26
+
27
+ // Internal imports must not depend on the public barrel having augmented DI's metadata interface.
28
+ interface WorkflowModuleFields {
29
+ readonly processes?: readonly unknown[];
30
+ }
31
+
32
+ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context) => {
33
+ const processes = (metadata as BazisModuleMetadata & WorkflowModuleFields).processes;
34
+ if (processes === undefined) return;
35
+ const module = moduleName(metadata);
36
+ if (!Array.isArray(processes)) throw new WorkflowSetupError(`${module}: processes must be an array of defineProcess(...) results.`);
37
+ for (const process of processes) {
38
+ if (!isCodeProcess(process)) throw new WorkflowSetupError(`${module}: processes must list defineProcess(...) results.`);
39
+ const problems = definitionProblems(process);
40
+ if (problems.length > 0) throw new WorkflowSetupError(`${module}: process "${process.key}" ${problems.length === 1 ? "has an error" : "has errors"}:\n${problems.map((line) => ` - ${line}`).join("\n")}`);
41
+ context.addMetadata(PROCESS_CHANNEL, process);
42
+ }
43
+ });
44
+
45
+ registerModuleOwnedContributionValidator((snapshot) => {
46
+ const contributions = snapshot.getMetadataContributions(PROCESS_CHANNEL);
47
+ if (contributions.length === 0) return;
48
+ const owners = new Map<string, string>();
49
+ for (const { payload, ownerName } of contributions) {
50
+ const other = owners.get(payload.key);
51
+ if (other !== undefined) throw new WorkflowSetupError(`Process "${payload.key}" is declared twice: in ${other} and in ${ownerName}.`);
52
+ owners.set(payload.key, ownerName);
53
+ }
54
+ if (snapshot.countProviders(IWorkflowDefinitions) === 0) {
55
+ throw new WorkflowSetupError(`${contributions[0]!.ownerName} declares processes, but the application does not import WorkflowModule (bazis/core/workflow).`);
56
+ }
57
+ });
58
+
59
+ /** The processes declared in the modules of the application's container. */
60
+ export function codeProcessesOf(services: ServiceProvider): readonly OwnedCodeProcess[] {
61
+ if (!(services instanceof DiContainer)) throw new WorkflowSetupError("Processes declared in code require the framework DiContainer.");
62
+ return services.getModuleOwnedMetadataContributions(PROCESS_CHANNEL).map(({ payload, ownerName }) => ({ process: payload, module: ownerName }));
63
+ }
64
+
65
+ /** What is wrong with a definition before the Tools are known: the catalog fields and the document's errors. */
66
+ export function definitionProblems(process: CodeProcess, tools?: (name: string) => boolean): readonly string[] {
67
+ const problems: string[] = [];
68
+ if (typeof process.key !== "string" || !KEY.test(process.key)) problems.push("key must match ^[a-z][a-z0-9_.-]{0,63}$");
69
+ if (typeof process.title !== "string" || process.title.trim() === "" || process.title.length > 200) problems.push("title must be 1–200 characters");
70
+ if (process.description != null && (typeof process.description !== "string" || process.description.length > 2000)) problems.push("description must be up to 2000 characters");
71
+ 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
+ problems.push(...documentErrors(validateProcess(process.document, tools === undefined ? {} : { tools })));
73
+ return problems;
74
+ }
75
+
76
+ function documentErrors(issues: readonly ProcessIssue[]): string[] {
77
+ return issues.filter((issue) => issue.severity === "error").map((issue) => {
78
+ const at = issue.node !== undefined ? `node ${issue.node}: ` : issue.flow !== undefined ? `flow ${issue.flow}: ` : "";
79
+ return `${at}${issue.message} (${issue.code})`;
80
+ });
81
+ }
82
+
83
+ function moduleName(metadata: BazisModuleMetadata): string {
84
+ return typeof metadata === "function" ? (metadata as { name: string }).name || "<anonymous module>" : "<module>";
85
+ }
@@ -0,0 +1,40 @@
1
+ import type { ProcessDocument } from "../../../../library/workflow";
2
+
3
+ const CODE_PROCESS = Symbol.for("bazis/core/workflow/code-process");
4
+
5
+ /** What `defineProcess` takes: the catalog fields of the process and its document. */
6
+ export interface ProcessDefinition {
7
+ /** The key it is started by: `^[a-z][a-z0-9_.-]{0,63}$`. */
8
+ readonly key: string;
9
+ readonly title: string;
10
+ readonly description?: string | null;
11
+ /** Days to keep completed and cancelled instances; omitted keeps them. */
12
+ readonly retentionDays?: number | null;
13
+ readonly document: ProcessDocument;
14
+ }
15
+
16
+ /** A process described in code: listed in `@Module({ processes })` and published at start (WORKFLOW-001 §14). */
17
+ export interface CodeProcess extends ProcessDefinition {
18
+ readonly [CODE_PROCESS]: true;
19
+ }
20
+
21
+ /**
22
+ * Describes a process in the code of the module that owns it. It is only a
23
+ * description: checks happen when the container is built and at start.
24
+ *
25
+ * ```ts
26
+ * // orders_module/processes/OrderApproval.process.ts
27
+ * export const OrderApprovalProcess = defineProcess({
28
+ * key: "order-approval",
29
+ * title: "Order approval",
30
+ * document: { schemaVersion: "bazis.workflow/v1", nodes: { … }, flows: { … } },
31
+ * });
32
+ * ```
33
+ */
34
+ export function defineProcess(definition: ProcessDefinition): CodeProcess {
35
+ return Object.freeze({ ...definition, [CODE_PROCESS]: true as const });
36
+ }
37
+
38
+ export function isCodeProcess(value: unknown): value is CodeProcess {
39
+ return typeof value === "object" && value !== null && (value as Record<symbol, unknown>)[CODE_PROCESS] === true;
40
+ }
@@ -0,0 +1,74 @@
1
+ import { expandModuleMetadata, type BazisModuleRef } from "../../../di";
2
+ import { toolMetadataOf } from "../../../agent";
3
+ import { validateProcess } from "../../../../library/workflow";
4
+ import { WORKFLOW_CALLER } from "../../internal/WorkflowTools";
5
+ import { WorkflowDefinitionsModule } from "../WorkflowDefinitions.module";
6
+ import { definitionProblems, type OwnedCodeProcess } from "./codeProcesses";
7
+ import { isCodeProcess } from "./defineProcess";
8
+
9
+ /** What `bazis workflow check` reports for one process. */
10
+ export interface InspectedProcess extends OwnedCodeProcess {
11
+ readonly errors: readonly string[];
12
+ readonly warnings: readonly string[];
13
+ }
14
+
15
+ export interface ProcessInspection {
16
+ readonly processes: readonly InspectedProcess[];
17
+ /** Problems across processes: one key in two modules, a `processes` entry that is not a process. */
18
+ readonly errors: readonly string[];
19
+ /** Tools of the module tree that processes may call. */
20
+ readonly tools: readonly string[];
21
+ }
22
+
23
+ /**
24
+ * The processes and Tools of a module tree without building the application
25
+ * (`bazis workflow check`): the checks of the build and of the start at once.
26
+ */
27
+ export function inspectProcesses(root: BazisModuleRef): ProcessInspection {
28
+ const found: OwnedCodeProcess[] = [];
29
+ const errors: string[] = [];
30
+ const tools = new Set<string>();
31
+ const visited = new Set<BazisModuleRef>();
32
+ const visit = (moduleRef: BazisModuleRef): void => {
33
+ if (visited.has(moduleRef)) return;
34
+ visited.add(moduleRef);
35
+ const module = typeof moduleRef === "function" ? moduleRef.name || "<anonymous module>" : "<module>";
36
+ const fields = moduleRef as { readonly processes?: unknown; readonly tools?: unknown };
37
+ if (Array.isArray(fields.tools)) {
38
+ for (const target of fields.tools) {
39
+ const metadata = typeof target === "function" ? toolMetadataOf(target as new () => object) : undefined;
40
+ if (metadata?.callers.includes(WORKFLOW_CALLER)) tools.add(metadata.name);
41
+ }
42
+ }
43
+ if (fields.processes !== undefined) {
44
+ if (!Array.isArray(fields.processes)) errors.push(`${module}: processes must be an array of defineProcess(...) results.`);
45
+ else for (const process of fields.processes) {
46
+ if (isCodeProcess(process)) found.push({ process, module });
47
+ else errors.push(`${module}: processes must list defineProcess(...) results.`);
48
+ }
49
+ }
50
+ for (const imported of moduleRef.imports ?? []) visit(imported);
51
+ for (const expanded of expandModuleMetadata(moduleRef)) visit(expanded);
52
+ };
53
+ visit(root);
54
+ if (found.length > 0 && !visited.has(WorkflowDefinitionsModule)) {
55
+ errors.push(`${found[0]!.module} declares processes, but the application does not import WorkflowModule (bazis/core/workflow).`);
56
+ }
57
+ const owners = new Map<string, string>();
58
+ for (const { process, module } of found) {
59
+ const other = owners.get(process.key);
60
+ if (other !== undefined) errors.push(`Process "${process.key}" is declared twice: in ${other} and in ${module}.`);
61
+ else owners.set(process.key, module);
62
+ }
63
+ const allowed = (name: string) => tools.has(name);
64
+ return {
65
+ processes: found.map((entry) => ({
66
+ ...entry,
67
+ errors: definitionProblems(entry.process, allowed),
68
+ warnings: validateProcess(entry.process.document, { tools: allowed }).filter((issue) => issue.severity === "warning")
69
+ .map((issue) => `${issue.node !== undefined ? `node ${issue.node}: ` : issue.flow !== undefined ? `flow ${issue.flow}: ` : ""}${issue.message} (${issue.code})`),
70
+ })),
71
+ errors,
72
+ tools: [...tools].sort(),
73
+ };
74
+ }
@@ -9,6 +9,11 @@ export interface ProcessSummary {
9
9
  readonly latestVersion: number | null;
10
10
  readonly retentionDays: number | null;
11
11
  readonly archived: boolean;
12
+ /**
13
+ * The module whose `@Module({ processes })` declares it; such a process
14
+ * changes only through the code (WORKFLOW-001 §14). `null` for the catalog's own.
15
+ */
16
+ readonly module: string | null;
12
17
  readonly createdAt: Date;
13
18
  readonly updatedAt: Date;
14
19
  }
@@ -61,10 +66,14 @@ export abstract class IWorkflowDefinitions {
61
66
  abstract list(query?: ProcessListQuery): Promise<WorkflowPage<ProcessSummary>>;
62
67
  abstract get(key: string): Promise<ProcessDetails>;
63
68
  abstract create(process: CreateProcess, actor: WorkflowActor): Promise<ProcessDetails>;
69
+ /** A process declared in code takes only `archived`; other fields give `conflict`. */
64
70
  abstract update(key: string, changes: UpdateProcess, actor: WorkflowActor): Promise<ProcessDetails>;
65
- /** Stores the draft even with errors and returns every validation finding. */
71
+ /** Stores the draft even with errors and returns every validation finding; `conflict` for a process declared in code. */
66
72
  abstract saveDraft(key: string, document: unknown, actor: WorkflowActor): Promise<{ readonly issues: readonly ProcessIssue[] }>;
67
- /** Validates the draft and stores it as the next version; the same document again returns the latest version. */
73
+ /**
74
+ * Validates the draft and stores it as the next version; the same document
75
+ * again returns the latest version. `conflict` for a process declared in code.
76
+ */
68
77
  abstract publish(key: string, actor: WorkflowActor): Promise<VersionDetails>;
69
78
  abstract versions(key: string): Promise<readonly VersionSummary[]>;
70
79
  abstract version(key: string, version: number): Promise<VersionDetails>;
@@ -3,8 +3,11 @@ import { UniqueViolationError } from "../../../orm";
3
3
  import type { AgentToolContract } from "../../../agent";
4
4
  import { emptyProcess, hasErrors, validateProcess, type ProcessDocument, type ProcessIssue } from "../../../../library/workflow";
5
5
  import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
6
+ import type { WorkflowClock } from "../../contracts/WorkflowClock";
6
7
  import { WorkflowError } from "../../contracts/WorkflowError";
7
8
  import type { WorkflowTools } from "../../internal/WorkflowTools";
9
+ import type { CodeProcessCatalog } from "../code/CodeProcessCatalog";
10
+ import type { OwnedCodeProcess } from "../code/codeProcesses";
8
11
  import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
9
12
  import { WfProcess } from "../model/WfProcess.model";
10
13
  import { WfVersion } from "../model/WfVersion.model";
@@ -20,11 +23,15 @@ import {
20
23
  } from "./IWorkflowDefinitions.service";
21
24
 
22
25
  const KEY = /^[a-z][a-z0-9_.-]{0,63}$/;
26
+ /** Who publishes the processes declared in code. */
27
+ const CODE_ACTOR: WorkflowActor = { kind: "system", id: "code" };
23
28
 
24
29
  export class WorkflowDefinitions extends IWorkflowDefinitions {
25
30
  constructor(
26
31
  private readonly db: DefinitionsDbContext,
27
32
  private readonly workflowTools: WorkflowTools,
33
+ private readonly code: CodeProcessCatalog,
34
+ private readonly clock: WorkflowClock,
28
35
  ) {
29
36
  super();
30
37
  }
@@ -40,11 +47,11 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
40
47
  }
41
48
  const total = await source.count();
42
49
  const rows = await source.orderBy((p) => p.key).skip(offset).take(limit).toList();
43
- return { items: rows.map(summaryOf), total };
50
+ return { items: rows.map((row) => this.summaryOf(row)), total };
44
51
  }
45
52
 
46
53
  async get(key: string): Promise<ProcessDetails> {
47
- return detailsOf(await this.process(key, false));
54
+ return this.detailsOf(await this.process(key, false));
48
55
  }
49
56
 
50
57
  async create(process: CreateProcess, actor: WorkflowActor): Promise<ProcessDetails> {
@@ -55,7 +62,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
55
62
  description: description(process.description),
56
63
  retentionDays: retention(process.retentionDays),
57
64
  draft: emptyProcess(),
58
- draftUpdatedAt: new Date(),
65
+ draftUpdatedAt: this.clock.now(),
59
66
  draftUpdatedByKind: actor.kind,
60
67
  draftUpdatedById: actor.id,
61
68
  });
@@ -66,25 +73,28 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
66
73
  if (error instanceof UniqueViolationError) throw new WorkflowError("conflict", `Process ${process.key} already exists`);
67
74
  throw error;
68
75
  }
69
- return detailsOf(row);
76
+ return this.detailsOf(row);
70
77
  }
71
78
 
72
79
  async update(key: string, changes: UpdateProcess, _actor: WorkflowActor): Promise<ProcessDetails> {
73
80
  const row = await this.process(key, true);
81
+ const { archived, ...fields } = changes;
82
+ if (Object.values(fields).some((value) => value !== undefined)) this.assertNotCode(key);
74
83
  if (changes.title !== undefined) row.title = title(changes.title);
75
84
  if (changes.description !== undefined) row.description = description(changes.description);
76
85
  if (changes.retentionDays !== undefined) row.retentionDays = retention(changes.retentionDays);
77
- if (changes.archived !== undefined) row.archivedAt = changes.archived ? row.archivedAt ?? new Date() : null;
86
+ if (archived !== undefined) row.archivedAt = archived ? row.archivedAt ?? this.clock.now() : null;
78
87
  await this.db.saveChanges();
79
- return detailsOf(row);
88
+ return this.detailsOf(row);
80
89
  }
81
90
 
82
91
  async saveDraft(key: string, document: unknown, actor: WorkflowActor): Promise<{ readonly issues: readonly ProcessIssue[] }> {
83
92
  const issues = this.validate(document);
84
93
  if (issues.some((issue) => issue.code === "too_large")) throw new WorkflowError("invalid_document", "The document is too large", issues);
85
94
  const row = await this.process(key, true);
95
+ this.assertNotCode(key);
86
96
  row.draft = document;
87
- row.draftUpdatedAt = new Date();
97
+ row.draftUpdatedAt = this.clock.now();
88
98
  row.draftUpdatedByKind = actor.kind;
89
99
  row.draftUpdatedById = actor.id;
90
100
  await this.db.saveChanges();
@@ -95,27 +105,71 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
95
105
  return await this.db.transactionScope(async () => {
96
106
  const row = await this.db.processes.where((p) => p.key.eq(key)).forUpdate().firstOrDefault();
97
107
  if (!row) throw notFound(key);
108
+ this.assertNotCode(key);
98
109
  if (row.archivedAt !== null) throw new WorkflowError("archived", `Process ${key} is archived`);
99
110
  const issues = this.validate(row.draft);
100
111
  if (hasErrors(issues)) throw new WorkflowError("invalid_document", `Process ${key} has errors and cannot be published`, issues);
101
- const hash = createHash("sha256").update(canonicalJson(row.draft)).digest("hex");
102
- if (row.latestVersion !== null) {
103
- const latest = await this.db.versions.asNoTracking().firstOrDefault((v) => v.processId.eq(row.id).and(v.version.eq(row.latestVersion!)));
104
- if (latest?.documentHash === hash) return versionOf(row.key, latest);
112
+ return (await this.publishDraft(row, actor)).version;
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Brings the processes declared in code into the catalog (WORKFLOW-001 §14):
118
+ * creates or updates each, puts its document into the draft and publishes it,
119
+ * under one lock across application instances. Archived ones stay archived
120
+ * and unpublished. The caller has checked the documents with the Tools.
121
+ */
122
+ async publishCode(entries: readonly OwnedCodeProcess[]): Promise<readonly { readonly key: string; readonly version: number | null; readonly published: boolean }[]> {
123
+ return await this.db.transactionScope(async () => {
124
+ await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext('bazis.workflow.code'))");
125
+ const results: { key: string; version: number | null; published: boolean }[] = [];
126
+ for (const { process } of [...entries].sort((a, b) => a.process.key.localeCompare(b.process.key))) {
127
+ let row = await this.db.processes.where((p) => p.key.eq(process.key)).forUpdate().firstOrDefault();
128
+ if (!row) {
129
+ row = Object.assign(new WfProcess(), { key: process.key });
130
+ this.db.processes.add(row);
131
+ }
132
+ const fields = { title: title(process.title), description: description(process.description), retentionDays: retention(process.retentionDays) };
133
+ if (row.title !== fields.title) row.title = fields.title;
134
+ if (row.description !== fields.description) row.description = fields.description;
135
+ if (row.retentionDays !== fields.retentionDays) row.retentionDays = fields.retentionDays;
136
+ if (row.draft === null || canonicalJson(row.draft) !== canonicalJson(process.document)) {
137
+ row.draft = process.document;
138
+ row.draftUpdatedAt = this.clock.now();
139
+ row.draftUpdatedByKind = CODE_ACTOR.kind;
140
+ row.draftUpdatedById = CODE_ACTOR.id;
141
+ }
142
+ await this.db.saveChanges();
143
+ if (row.archivedAt !== null) {
144
+ results.push({ key: row.key, version: row.latestVersion, published: false });
145
+ continue;
146
+ }
147
+ const { version, created } = await this.publishDraft(row, CODE_ACTOR);
148
+ results.push({ key: row.key, version: version.version, published: created });
105
149
  }
106
- const version = Object.assign(new WfVersion(), {
107
- processId: row.id,
108
- version: (row.latestVersion ?? 0) + 1,
109
- document: row.draft,
110
- documentHash: hash,
111
- publishedByKind: actor.kind,
112
- publishedById: actor.id,
113
- });
114
- this.db.versions.add(version);
115
- row.latestVersion = version.version;
116
- await this.db.saveChanges();
117
- return versionOf(row.key, version);
150
+ return results;
151
+ });
152
+ }
153
+
154
+ /** Stores the draft of a locked row as the next version, unless it is the latest one already. */
155
+ private async publishDraft(row: WfProcess, actor: WorkflowActor): Promise<{ readonly version: VersionDetails; readonly created: boolean }> {
156
+ const hash = createHash("sha256").update(canonicalJson(row.draft)).digest("hex");
157
+ if (row.latestVersion !== null) {
158
+ const latest = await this.db.versions.asNoTracking().firstOrDefault((v) => v.processId.eq(row.id).and(v.version.eq(row.latestVersion!)));
159
+ if (latest?.documentHash === hash) return { version: versionOf(row.key, latest), created: false };
160
+ }
161
+ const version = Object.assign(new WfVersion(), {
162
+ processId: row.id,
163
+ version: (row.latestVersion ?? 0) + 1,
164
+ document: row.draft,
165
+ documentHash: hash,
166
+ publishedByKind: actor.kind,
167
+ publishedById: actor.id,
118
168
  });
169
+ this.db.versions.add(version);
170
+ row.latestVersion = version.version;
171
+ await this.db.saveChanges();
172
+ return { version: versionOf(row.key, version), created: true };
119
173
  }
120
174
 
121
175
  async versions(key: string): Promise<readonly VersionSummary[]> {
@@ -168,6 +222,35 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
168
222
  return this.workflowTools.list();
169
223
  }
170
224
 
225
+ /** A process declared in code changes only through the code. */
226
+ private assertNotCode(key: string): void {
227
+ const module = this.code.moduleOf(key);
228
+ if (module !== undefined) throw new WorkflowError("conflict", `Process ${key} is declared in code by ${module} and changes only there`);
229
+ }
230
+
231
+ private summaryOf(row: WfProcess): ProcessSummary {
232
+ return {
233
+ key: row.key,
234
+ title: row.title,
235
+ description: row.description,
236
+ latestVersion: row.latestVersion,
237
+ retentionDays: row.retentionDays,
238
+ archived: row.archivedAt !== null,
239
+ module: this.code.moduleOf(row.key) ?? null,
240
+ createdAt: row.createdAt,
241
+ updatedAt: row.updatedAt,
242
+ };
243
+ }
244
+
245
+ private detailsOf(row: WfProcess): ProcessDetails {
246
+ return {
247
+ ...this.summaryOf(row),
248
+ draft: row.draft,
249
+ draftUpdatedAt: row.draftUpdatedAt,
250
+ draftUpdatedBy: row.draftUpdatedById === null ? null : { kind: row.draftUpdatedByKind === "system" ? "system" : "user", id: row.draftUpdatedById },
251
+ };
252
+ }
253
+
171
254
  private validate(document: unknown): ProcessIssue[] {
172
255
  return validateProcess(document, { tools: (name) => this.workflowTools.has(name) });
173
256
  }
@@ -201,28 +284,6 @@ function retention(value: unknown): number | null {
201
284
  return value as number;
202
285
  }
203
286
 
204
- function summaryOf(row: WfProcess): ProcessSummary {
205
- return {
206
- key: row.key,
207
- title: row.title,
208
- description: row.description,
209
- latestVersion: row.latestVersion,
210
- retentionDays: row.retentionDays,
211
- archived: row.archivedAt !== null,
212
- createdAt: row.createdAt,
213
- updatedAt: row.updatedAt,
214
- };
215
- }
216
-
217
- function detailsOf(row: WfProcess): ProcessDetails {
218
- return {
219
- ...summaryOf(row),
220
- draft: row.draft,
221
- draftUpdatedAt: row.draftUpdatedAt,
222
- draftUpdatedBy: row.draftUpdatedById === null ? null : { kind: row.draftUpdatedByKind === "system" ? "system" : "user", id: row.draftUpdatedById },
223
- };
224
- }
225
-
226
287
  function versionOf(key: string, row: WfVersion): VersionDetails {
227
288
  return {
228
289
  id: row.id,
@@ -3,9 +3,17 @@
3
3
  * with the Definitions and Runtime parts, their services, HTTP, events and
4
4
  * extension points. The document format is `bazis/library/workflow`.
5
5
  */
6
+ import type { CodeProcess } from "./definitions_module/code/defineProcess";
7
+
6
8
  export { WorkflowModule } from "./Workflow.module";
9
+ export { defineProcess, type CodeProcess, type ProcessDefinition } from "./definitions_module/code/defineProcess";
10
+ export { WorkflowSetupError } from "./definitions_module/code/codeProcesses";
11
+ export { WorkflowClock, SystemWorkflowClock } from "./contracts/WorkflowClock";
7
12
  export { WorkflowDefinitionsModule } from "./definitions_module/WorkflowDefinitions.module";
8
13
  export { WorkflowRuntimeModule } from "./runtime_module/WorkflowRuntime.module";
14
+ export { WorkflowTasksModule } from "./tasks_module/WorkflowTasks.module";
15
+ export { IWorkflowTasks, type TaskDetails, type TaskListQuery } from "./tasks_module/services/IWorkflowTasks.service";
16
+ export type { TaskStatus } from "./tasks_module/model/WfTask.model";
9
17
  export {
10
18
  IWorkflowDefinitions,
11
19
  type CreateProcess,
@@ -18,6 +26,8 @@ export {
18
26
  } from "./definitions_module/services/IWorkflowDefinitions.service";
19
27
  export {
20
28
  IWorkflowRuntime,
29
+ type EmitOptions,
30
+ type EmitResult,
21
31
  type HistoryEntry,
22
32
  type InstanceDetails,
23
33
  type InstanceListQuery,
@@ -27,9 +37,11 @@ export {
27
37
  } from "./runtime_module/services/IWorkflowRuntime.service";
28
38
  export {
29
39
  WORKFLOW_INSTANCE_FINISHED,
40
+ WORKFLOW_INSTANCES_DELETED,
30
41
  WORKFLOW_TASK_REQUESTED,
31
42
  WORKFLOW_TOKEN_CLOSED,
32
43
  type WorkflowInstanceFinished,
44
+ type WorkflowInstancesDeleted,
33
45
  type WorkflowTaskRequested,
34
46
  type WorkflowTokenClosed,
35
47
  } from "./runtime_module/events/Runtime.events";
@@ -41,3 +53,12 @@ export { WorkflowAccess, DenyWorkflowAccess, type WorkflowAccessTarget, type Wor
41
53
  export { WorkflowInputValidator, DefaultWorkflowInputValidator, type WorkflowInputContext } from "./contracts/WorkflowInputValidator";
42
54
  export { WorkflowError, type WorkflowErrorCode } from "./contracts/WorkflowError";
43
55
  export type { WorkflowActor, WorkflowPage, WorkflowPaging } from "./contracts/types";
56
+
57
+ // Augment the declaring file, not the "../di" re-export: re-export targets
58
+ // depend on program file order and silently stop merging in some projects.
59
+ declare module "../di/module/types/BazisModule" {
60
+ interface BazisModuleMetadata {
61
+ /** Processes of the module, from `defineProcess`: published at start (WORKFLOW-001 §14). */
62
+ readonly processes?: readonly CodeProcess[];
63
+ }
64
+ }
@@ -16,6 +16,12 @@ export async function authorize(ctx: HttpContext, action: WorkflowAction, target
16
16
  return actor;
17
17
  }
18
18
 
19
+ /** Whom the caller's tasks are addressed to, from the application's {@link WorkflowAccess}. */
20
+ export async function assigneesOf(ctx: HttpContext, actor: WorkflowActor): Promise<readonly string[]> {
21
+ const access = ctx.services.tryResolve(WorkflowAccess);
22
+ return access === undefined ? [] : await access.assignees(ctx, actor);
23
+ }
24
+
19
25
  /** A route parameter as a string. */
20
26
  export function param(ctx: HttpContext, name: string): string {
21
27
  return String(ctx.params[name] ?? "");
@@ -1,5 +1,6 @@
1
1
  import { DI, Module, SERVICE_PROVIDER, scoped } from "../../di";
2
2
  import { EventBus, LOGGER } from "../../kernel";
3
+ import { WorkflowClock } from "../contracts/WorkflowClock";
3
4
  import { DefaultWorkflowInputValidator, WorkflowInputValidator } from "../contracts/WorkflowInputValidator";
4
5
  import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
5
6
  import { IWorkflowDefinitions } from "../definitions_module/services/IWorkflowDefinitions.service";
@@ -36,6 +37,7 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
36
37
  resolver.resolve(EventBus),
37
38
  resolver,
38
39
  resolver.tryResolve(LOGGER),
40
+ resolver.resolve(WorkflowClock),
39
41
  ))),
40
42
  scoped(ToolStep, ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools] as const),
41
43
  scoped(TimerStep, TimerStep, [RuntimeDbContext, WorkflowEngine] as const),
@@ -1,7 +1,9 @@
1
1
  import { BackgroundService, delay } from "../../../background";
2
2
  import type { ServiceProvider } from "../../../di";
3
- import type { ConfigView } from "../../../kernel";
3
+ import { EventBus, type ConfigView } from "../../../kernel";
4
+ import { WorkflowClock } from "../../contracts/WorkflowClock";
4
5
  import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
6
+ import { WORKFLOW_INSTANCES_DELETED } from "../events/Runtime.events";
5
7
  import { RuntimeDbContext } from "../model/Runtime.dbContext";
6
8
  import type { WorkflowSettings } from "./WorkflowWorker";
7
9
 
@@ -11,6 +13,8 @@ const DAY_MS = 86_400_000;
11
13
  * Deletes `completed` and `cancelled` instances older than their process's
12
14
  * `retentionDays`, with tokens, jobs, subscriptions and history (by cascade),
13
15
  * `retention.batchSize` at a time. `failed` instances wait for a person and stay.
16
+ * Each deleted batch is published as `WORKFLOW_INSTANCES_DELETED`, so parts
17
+ * with their own data (tasks) drop theirs.
14
18
  */
15
19
  export class WorkflowRetention extends BackgroundService {
16
20
  constructor(
@@ -21,7 +25,7 @@ export class WorkflowRetention extends BackgroundService {
21
25
  }
22
26
 
23
27
  protected override async execute(signal: AbortSignal): Promise<void> {
24
- if (!this.config.get("retention.enabled")) return;
28
+ if (!this.config.get("retention.enabled") || this.services.resolve(WorkflowClock).manual === true) return;
25
29
  const intervalMs = this.config.get("retention.intervalMs");
26
30
  while (!signal.aborted) {
27
31
  try {
@@ -42,8 +46,9 @@ export class WorkflowRetention extends BackgroundService {
42
46
  try {
43
47
  const definitions = scope.resolve(IWorkflowDefinitions);
44
48
  const db = scope.resolve(RuntimeDbContext);
49
+ const now = scope.resolve(WorkflowClock).now().getTime();
45
50
  for (const policy of await definitions.retentionPolicies()) {
46
- const cutoff = new Date(Date.now() - policy.retentionDays * DAY_MS);
51
+ const cutoff = new Date(now - policy.retentionDays * DAY_MS);
47
52
  for (;;) {
48
53
  if (signal?.aborted) return deleted;
49
54
  const ids = (await db.instances.asNoTracking()
@@ -55,6 +60,7 @@ export class WorkflowRetention extends BackgroundService {
55
60
  if (ids.length === 0) break;
56
61
  await db.instances.asNoTracking().where((i) => i.id.in(ids)).executeDelete();
57
62
  deleted += ids.length;
63
+ await scope.resolve(EventBus).publishScoped(scope, WORKFLOW_INSTANCES_DELETED, { instanceIds: ids });
58
64
  if (ids.length < batchSize) break;
59
65
  }
60
66
  }