bazis 0.123.0 → 0.124.1
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/index.ts +1 -0
- package/core/app/ui/uiSurfaceHttp.ts +2 -0
- package/core/http/Routing/RouterBuilder.ts +2 -1
- package/core/http/Routing/contextOnlyControllers.ts +16 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/Workflow.module.ts +15 -0
- package/core/workflow/access/WorkflowAccess.ts +52 -0
- package/core/workflow/contracts/WorkflowError.ts +28 -0
- package/core/workflow/contracts/WorkflowInputValidator.ts +24 -0
- package/core/workflow/contracts/types.ts +30 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +24 -0
- package/core/workflow/definitions_module/http/Processes.controller.ts +94 -0
- package/core/workflow/definitions_module/model/Definitions.dbContext.ts +16 -0
- package/core/workflow/definitions_module/model/WfProcess.model.ts +52 -0
- package/core/workflow/definitions_module/model/WfVersion.model.ts +33 -0
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +79 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +234 -0
- package/core/workflow/index.ts +43 -0
- package/core/workflow/internal/WorkflowTools.ts +36 -0
- package/core/workflow/internal/http.ts +63 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +53 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +66 -0
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +92 -0
- package/core/workflow/runtime_module/config/Runtime.config.ts +27 -0
- package/core/workflow/runtime_module/events/Runtime.events.ts +36 -0
- package/core/workflow/runtime_module/http/Instances.controller.ts +74 -0
- package/core/workflow/runtime_module/internal/GraphCache.ts +37 -0
- package/core/workflow/runtime_module/internal/ToolStep.ts +60 -0
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +356 -0
- package/core/workflow/runtime_module/model/Runtime.dbContext.ts +25 -0
- package/core/workflow/runtime_module/model/WfHistory.model.ts +38 -0
- package/core/workflow/runtime_module/model/WfInstance.model.ts +70 -0
- package/core/workflow/runtime_module/model/WfJob.model.ts +49 -0
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +30 -0
- package/core/workflow/runtime_module/model/WfToken.model.ts +39 -0
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +82 -0
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +167 -0
- package/library/workflow/ProcessDocument.ts +94 -0
- package/library/workflow/conditions.ts +44 -0
- package/library/workflow/duration.ts +10 -0
- package/library/workflow/formFields.ts +112 -0
- package/library/workflow/index.ts +24 -0
- package/library/workflow/nodeKinds.ts +111 -0
- package/library/workflow/paths.ts +39 -0
- package/library/workflow/validateProcess.ts +310 -0
- package/package.json +4 -2
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { UniqueViolationError } from "../../../orm";
|
|
3
|
+
import type { AgentToolContract } from "../../../agent";
|
|
4
|
+
import { emptyProcess, hasErrors, validateProcess, type ProcessDocument, type ProcessIssue } from "../../../../library/workflow";
|
|
5
|
+
import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
|
|
6
|
+
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
7
|
+
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
8
|
+
import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
|
|
9
|
+
import { WfProcess } from "../model/WfProcess.model";
|
|
10
|
+
import { WfVersion } from "../model/WfVersion.model";
|
|
11
|
+
import {
|
|
12
|
+
IWorkflowDefinitions,
|
|
13
|
+
type CreateProcess,
|
|
14
|
+
type ProcessDetails,
|
|
15
|
+
type ProcessListQuery,
|
|
16
|
+
type ProcessSummary,
|
|
17
|
+
type UpdateProcess,
|
|
18
|
+
type VersionDetails,
|
|
19
|
+
type VersionSummary,
|
|
20
|
+
} from "./IWorkflowDefinitions.service";
|
|
21
|
+
|
|
22
|
+
const KEY = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
23
|
+
|
|
24
|
+
export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly db: DefinitionsDbContext,
|
|
27
|
+
private readonly workflowTools: WorkflowTools,
|
|
28
|
+
) {
|
|
29
|
+
super();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async list(query: ProcessListQuery = {}): Promise<WorkflowPage<ProcessSummary>> {
|
|
33
|
+
const { limit, offset } = pagingOf(query);
|
|
34
|
+
let source = this.db.processes.asNoTracking();
|
|
35
|
+
if (query.archived === true) source = source.where((p) => p.archivedAt.isNotNull());
|
|
36
|
+
if (query.archived === false) source = source.where((p) => p.archivedAt.isNull());
|
|
37
|
+
if (query.search !== undefined && query.search.trim() !== "") {
|
|
38
|
+
const text = query.search.trim();
|
|
39
|
+
source = source.where((p) => p.key.contains(text, { ignoreCase: true }).or(p.title.contains(text, { ignoreCase: true })));
|
|
40
|
+
}
|
|
41
|
+
const total = await source.count();
|
|
42
|
+
const rows = await source.orderBy((p) => p.key).skip(offset).take(limit).toList();
|
|
43
|
+
return { items: rows.map(summaryOf), total };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async get(key: string): Promise<ProcessDetails> {
|
|
47
|
+
return detailsOf(await this.process(key, false));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async create(process: CreateProcess, actor: WorkflowActor): Promise<ProcessDetails> {
|
|
51
|
+
if (typeof process.key !== "string" || !KEY.test(process.key)) throw new WorkflowError("invalid_input", "key must match ^[a-z][a-z0-9_.-]{0,63}$");
|
|
52
|
+
const row = Object.assign(new WfProcess(), {
|
|
53
|
+
key: process.key,
|
|
54
|
+
title: title(process.title),
|
|
55
|
+
description: description(process.description),
|
|
56
|
+
retentionDays: retention(process.retentionDays),
|
|
57
|
+
draft: emptyProcess(),
|
|
58
|
+
draftUpdatedAt: new Date(),
|
|
59
|
+
draftUpdatedByKind: actor.kind,
|
|
60
|
+
draftUpdatedById: actor.id,
|
|
61
|
+
});
|
|
62
|
+
this.db.processes.add(row);
|
|
63
|
+
try {
|
|
64
|
+
await this.db.saveChanges();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof UniqueViolationError) throw new WorkflowError("conflict", `Process ${process.key} already exists`);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return detailsOf(row);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async update(key: string, changes: UpdateProcess, _actor: WorkflowActor): Promise<ProcessDetails> {
|
|
73
|
+
const row = await this.process(key, true);
|
|
74
|
+
if (changes.title !== undefined) row.title = title(changes.title);
|
|
75
|
+
if (changes.description !== undefined) row.description = description(changes.description);
|
|
76
|
+
if (changes.retentionDays !== undefined) row.retentionDays = retention(changes.retentionDays);
|
|
77
|
+
if (changes.archived !== undefined) row.archivedAt = changes.archived ? row.archivedAt ?? new Date() : null;
|
|
78
|
+
await this.db.saveChanges();
|
|
79
|
+
return detailsOf(row);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async saveDraft(key: string, document: unknown, actor: WorkflowActor): Promise<{ readonly issues: readonly ProcessIssue[] }> {
|
|
83
|
+
const issues = this.validate(document);
|
|
84
|
+
if (issues.some((issue) => issue.code === "too_large")) throw new WorkflowError("invalid_document", "The document is too large", issues);
|
|
85
|
+
const row = await this.process(key, true);
|
|
86
|
+
row.draft = document;
|
|
87
|
+
row.draftUpdatedAt = new Date();
|
|
88
|
+
row.draftUpdatedByKind = actor.kind;
|
|
89
|
+
row.draftUpdatedById = actor.id;
|
|
90
|
+
await this.db.saveChanges();
|
|
91
|
+
return { issues };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async publish(key: string, actor: WorkflowActor): Promise<VersionDetails> {
|
|
95
|
+
return await this.db.transactionScope(async () => {
|
|
96
|
+
const row = await this.db.processes.where((p) => p.key.eq(key)).forUpdate().firstOrDefault();
|
|
97
|
+
if (!row) throw notFound(key);
|
|
98
|
+
if (row.archivedAt !== null) throw new WorkflowError("archived", `Process ${key} is archived`);
|
|
99
|
+
const issues = this.validate(row.draft);
|
|
100
|
+
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);
|
|
105
|
+
}
|
|
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);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async versions(key: string): Promise<readonly VersionSummary[]> {
|
|
122
|
+
const row = await this.process(key, false);
|
|
123
|
+
const rows = await this.db.versions.asNoTracking().where((v) => v.processId.eq(row.id)).orderByDescending((v) => v.version).toList();
|
|
124
|
+
return rows.map((version) => {
|
|
125
|
+
const { document: _document, ...summary } = versionOf(row.key, version);
|
|
126
|
+
return summary;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async version(key: string, version: number): Promise<VersionDetails> {
|
|
131
|
+
const row = await this.process(key, false);
|
|
132
|
+
const found = await this.db.versions.asNoTracking().firstOrDefault((v) => v.processId.eq(row.id).and(v.version.eq(version)));
|
|
133
|
+
if (!found) throw new WorkflowError("not_found", `Process ${key} has no version ${version}`);
|
|
134
|
+
return versionOf(row.key, found);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async latest(key: string): Promise<VersionDetails> {
|
|
138
|
+
const row = await this.process(key, false);
|
|
139
|
+
if (row.archivedAt !== null) throw new WorkflowError("archived", `Process ${key} is archived`);
|
|
140
|
+
if (row.latestVersion === null) throw new WorkflowError("not_found", `Process ${key} has no published version`);
|
|
141
|
+
return await this.version(key, row.latestVersion);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async versionById(id: string): Promise<VersionDetails | undefined> {
|
|
145
|
+
if (!isUuid(id)) return undefined;
|
|
146
|
+
const version = await this.db.versions.asNoTracking().firstOrDefault((v) => v.id.eq(id));
|
|
147
|
+
if (!version) return undefined;
|
|
148
|
+
const process = await this.db.processes.asNoTracking().firstOrDefault((p) => p.id.eq(version.processId));
|
|
149
|
+
return process ? versionOf(process.key, version) : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async retentionPolicies(): Promise<readonly { readonly key: string; readonly retentionDays: number }[]> {
|
|
153
|
+
const rows = await this.db.processes.asNoTracking().where((p) => p.retentionDays.isNotNull()).toList();
|
|
154
|
+
return rows.map((row) => ({ key: row.key, retentionDays: row.retentionDays! }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
tools(): readonly AgentToolContract[] {
|
|
158
|
+
return this.workflowTools.list();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private validate(document: unknown): ProcessIssue[] {
|
|
162
|
+
return validateProcess(document, { tools: (name) => this.workflowTools.has(name) });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async process(key: string, tracked: boolean): Promise<WfProcess> {
|
|
166
|
+
const source = tracked ? this.db.processes : this.db.processes.asNoTracking();
|
|
167
|
+
const row = await source.firstOrDefault((p) => p.key.eq(key));
|
|
168
|
+
if (!row) throw notFound(key);
|
|
169
|
+
return row;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function notFound(key: string): WorkflowError {
|
|
174
|
+
return new WorkflowError("not_found", `Process ${key} does not exist`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function title(value: unknown): string {
|
|
178
|
+
if (typeof value !== "string" || value.trim() === "" || value.length > 200) throw new WorkflowError("invalid_input", "title must be 1–200 characters");
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function description(value: unknown): string | null {
|
|
183
|
+
if (value === undefined || value === null) return null;
|
|
184
|
+
if (typeof value !== "string" || value.length > 2000) throw new WorkflowError("invalid_input", "description must be up to 2000 characters");
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function retention(value: unknown): number | null {
|
|
189
|
+
if (value === undefined || value === null) return null;
|
|
190
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 36_500) throw new WorkflowError("invalid_input", "retentionDays must be an integer 1–36500");
|
|
191
|
+
return value as number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function summaryOf(row: WfProcess): ProcessSummary {
|
|
195
|
+
return {
|
|
196
|
+
key: row.key,
|
|
197
|
+
title: row.title,
|
|
198
|
+
description: row.description,
|
|
199
|
+
latestVersion: row.latestVersion,
|
|
200
|
+
retentionDays: row.retentionDays,
|
|
201
|
+
archived: row.archivedAt !== null,
|
|
202
|
+
createdAt: row.createdAt,
|
|
203
|
+
updatedAt: row.updatedAt,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function detailsOf(row: WfProcess): ProcessDetails {
|
|
208
|
+
return {
|
|
209
|
+
...summaryOf(row),
|
|
210
|
+
draft: row.draft,
|
|
211
|
+
draftUpdatedAt: row.draftUpdatedAt,
|
|
212
|
+
draftUpdatedBy: row.draftUpdatedById === null ? null : { kind: row.draftUpdatedByKind === "system" ? "system" : "user", id: row.draftUpdatedById },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function versionOf(key: string, row: WfVersion): VersionDetails {
|
|
217
|
+
return {
|
|
218
|
+
id: row.id,
|
|
219
|
+
key,
|
|
220
|
+
version: row.version,
|
|
221
|
+
publishedAt: row.publishedAt,
|
|
222
|
+
publishedBy: { kind: row.publishedByKind === "system" ? "system" : "user", id: row.publishedById },
|
|
223
|
+
document: row.document as ProcessDocument,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** JSON with sorted keys: the same document gives the same hash whatever the key order. */
|
|
228
|
+
function canonicalJson(value: unknown): string {
|
|
229
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
230
|
+
if (typeof value === "object" && value !== null) {
|
|
231
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
|
|
232
|
+
}
|
|
233
|
+
return JSON.stringify(value) ?? "null";
|
|
234
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business processes without a UI (WORKFLOW-001): the composite `WorkflowModule`
|
|
3
|
+
* with the Definitions and Runtime parts, their services, HTTP, events and
|
|
4
|
+
* extension points. The document format is `bazis/library/workflow`.
|
|
5
|
+
*/
|
|
6
|
+
export { WorkflowModule } from "./Workflow.module";
|
|
7
|
+
export { WorkflowDefinitionsModule } from "./definitions_module/WorkflowDefinitions.module";
|
|
8
|
+
export { WorkflowRuntimeModule } from "./runtime_module/WorkflowRuntime.module";
|
|
9
|
+
export {
|
|
10
|
+
IWorkflowDefinitions,
|
|
11
|
+
type CreateProcess,
|
|
12
|
+
type ProcessDetails,
|
|
13
|
+
type ProcessListQuery,
|
|
14
|
+
type ProcessSummary,
|
|
15
|
+
type UpdateProcess,
|
|
16
|
+
type VersionDetails,
|
|
17
|
+
type VersionSummary,
|
|
18
|
+
} from "./definitions_module/services/IWorkflowDefinitions.service";
|
|
19
|
+
export {
|
|
20
|
+
IWorkflowRuntime,
|
|
21
|
+
type HistoryEntry,
|
|
22
|
+
type InstanceDetails,
|
|
23
|
+
type InstanceListQuery,
|
|
24
|
+
type InstanceSummary,
|
|
25
|
+
type StartInstance,
|
|
26
|
+
type TokenDetails,
|
|
27
|
+
} from "./runtime_module/services/IWorkflowRuntime.service";
|
|
28
|
+
export {
|
|
29
|
+
WORKFLOW_INSTANCE_FINISHED,
|
|
30
|
+
WORKFLOW_TASK_REQUESTED,
|
|
31
|
+
WORKFLOW_TOKEN_CLOSED,
|
|
32
|
+
type WorkflowInstanceFinished,
|
|
33
|
+
type WorkflowTaskRequested,
|
|
34
|
+
type WorkflowTokenClosed,
|
|
35
|
+
} from "./runtime_module/events/Runtime.events";
|
|
36
|
+
export { workflowRuntimeConfig } from "./runtime_module/config/Runtime.config";
|
|
37
|
+
export type { HistoryType } from "./runtime_module/model/WfHistory.model";
|
|
38
|
+
export type { InstanceStatus, StartedVia } from "./runtime_module/model/WfInstance.model";
|
|
39
|
+
export type { TokenStatus } from "./runtime_module/model/WfToken.model";
|
|
40
|
+
export { WorkflowAccess, DenyWorkflowAccess, type WorkflowAccessTarget, type WorkflowAction } from "./access/WorkflowAccess";
|
|
41
|
+
export { WorkflowInputValidator, DefaultWorkflowInputValidator, type WorkflowInputContext } from "./contracts/WorkflowInputValidator";
|
|
42
|
+
export { WorkflowError, type WorkflowErrorCode } from "./contracts/WorkflowError";
|
|
43
|
+
export type { WorkflowActor, WorkflowPage, WorkflowPaging } from "./contracts/types";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ServiceResolver } from "../../di";
|
|
2
|
+
import { ToolRunner, type AgentToolContract, type BoundToolRunner } from "../../agent";
|
|
3
|
+
|
|
4
|
+
/** The workflow caller id of TOOLS-001: processes run only Tools whose `callers` list it. */
|
|
5
|
+
export const WORKFLOW_CALLER = "workflow";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The application's Tools as processes see them. `ToolRunner` exists only when
|
|
9
|
+
* a module declares `tools`; without it there is nothing to call.
|
|
10
|
+
*/
|
|
11
|
+
export class WorkflowTools {
|
|
12
|
+
private readonly runner: BoundToolRunner | undefined;
|
|
13
|
+
|
|
14
|
+
constructor(services: ServiceResolver) {
|
|
15
|
+
this.runner = services.tryResolve(ToolRunner)?.as(WORKFLOW_CALLER);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether a service task may call `name`. */
|
|
19
|
+
has(name: string): boolean {
|
|
20
|
+
return this.runner?.getTool(name) !== undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Contracts of every Tool processes may call, by name. */
|
|
24
|
+
list(): readonly AgentToolContract[] {
|
|
25
|
+
if (this.runner === undefined) return [];
|
|
26
|
+
return this.runner.listTools()
|
|
27
|
+
.map((tool) => this.runner!.describe(tool.metadata.name))
|
|
28
|
+
.filter((contract): contract is AgentToolContract => contract !== undefined)
|
|
29
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The bound runner; `undefined` when the application has no Tools. */
|
|
33
|
+
get bound(): BoundToolRunner | undefined {
|
|
34
|
+
return this.runner;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ForbiddenError, UnauthorizedError, type HttpContext } from "../../http";
|
|
2
|
+
import { WorkflowAccess, type WorkflowAccessTarget, type WorkflowAction } from "../access/WorkflowAccess";
|
|
3
|
+
import type { WorkflowActor } from "../contracts/types";
|
|
4
|
+
import { WorkflowError } from "../contracts/WorkflowError";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Asks the application's {@link WorkflowAccess} who calls and whether the action
|
|
8
|
+
* is allowed. Without a registered access every route answers 403.
|
|
9
|
+
*/
|
|
10
|
+
export async function authorize(ctx: HttpContext, action: WorkflowAction, target: WorkflowAccessTarget = {}): Promise<WorkflowActor> {
|
|
11
|
+
const access = ctx.services.tryResolve(WorkflowAccess);
|
|
12
|
+
if (access === undefined) throw new ForbiddenError("Workflow HTTP is closed: register a WorkflowAccess implementation");
|
|
13
|
+
const actor = await access.actor(ctx);
|
|
14
|
+
if (actor === undefined) throw new UnauthorizedError();
|
|
15
|
+
if (!await access.can(ctx, action, target)) throw new ForbiddenError();
|
|
16
|
+
return actor;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A route parameter as a string. */
|
|
20
|
+
export function param(ctx: HttpContext, name: string): string {
|
|
21
|
+
return String(ctx.params[name] ?? "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The JSON body as an object; anything else is `invalid_input`. */
|
|
25
|
+
export async function body(ctx: HttpContext): Promise<Record<string, unknown>> {
|
|
26
|
+
const text = await ctx.text();
|
|
27
|
+
if (text.trim() === "") return {};
|
|
28
|
+
let value: unknown;
|
|
29
|
+
try {
|
|
30
|
+
value = JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new WorkflowError("invalid_input", "The body must be JSON");
|
|
33
|
+
}
|
|
34
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new WorkflowError("invalid_input", "The body must be a JSON object");
|
|
35
|
+
return value as Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An optional string field of a body or a query; another type is `invalid_input`. */
|
|
39
|
+
export function optionalString(value: unknown, name: string, max = 200): string | undefined {
|
|
40
|
+
if (value === undefined || value === null) return undefined;
|
|
41
|
+
if (typeof value !== "string" || value.length > max) throw new WorkflowError("invalid_input", `${name} must be a string up to ${max} characters`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A required string field. */
|
|
46
|
+
export function requiredString(value: unknown, name: string, max = 200): string {
|
|
47
|
+
const text = optionalString(value, name, max);
|
|
48
|
+
if (text === undefined || text.trim() === "") throw new WorkflowError("invalid_input", `${name} is required`);
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An optional integer from a query string or a body. */
|
|
53
|
+
export function optionalInteger(value: unknown, name: string): number | undefined {
|
|
54
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
55
|
+
const number = typeof value === "string" ? Number(value) : value;
|
|
56
|
+
if (!Number.isSafeInteger(number)) throw new WorkflowError("invalid_input", `${name} must be an integer`);
|
|
57
|
+
return number as number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `limit` and `offset` of a list query. */
|
|
61
|
+
export function pagingQuery(ctx: HttpContext): { limit?: number; offset?: number } {
|
|
62
|
+
return { limit: optionalInteger(ctx.query("limit"), "limit"), offset: optionalInteger(ctx.query("offset"), "offset") };
|
|
63
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DI, Module, SERVICE_PROVIDER, scoped } from "../../di";
|
|
2
|
+
import { EventBus, LOGGER } from "../../kernel";
|
|
3
|
+
import { DefaultWorkflowInputValidator, WorkflowInputValidator } from "../contracts/WorkflowInputValidator";
|
|
4
|
+
import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
|
|
5
|
+
import { IWorkflowDefinitions } from "../definitions_module/services/IWorkflowDefinitions.service";
|
|
6
|
+
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
7
|
+
import { WorkflowRetention } from "./background/WorkflowRetention";
|
|
8
|
+
import { WorkflowWorker } from "./background/WorkflowWorker";
|
|
9
|
+
import { workflowRuntimeConfig } from "./config/Runtime.config";
|
|
10
|
+
import { InstancesController } from "./http/Instances.controller";
|
|
11
|
+
import { GraphCache } from "./internal/GraphCache";
|
|
12
|
+
import { ToolStep } from "./internal/ToolStep";
|
|
13
|
+
import { WorkflowEngine } from "./internal/WorkflowEngine";
|
|
14
|
+
import { RUNTIME_ENTITIES, RuntimeDbContext, runtimeStore } from "./model/Runtime.dbContext";
|
|
15
|
+
import { IWorkflowRuntime } from "./services/IWorkflowRuntime.service";
|
|
16
|
+
import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs published versions: instances, tokens, jobs, history, the worker and
|
|
20
|
+
* retention ([passport](MODULE.md)). Dependencies are explicit: the framework's
|
|
21
|
+
* classes are not covered by the application's codegen.
|
|
22
|
+
*/
|
|
23
|
+
@Module({
|
|
24
|
+
imports: [WorkflowDefinitionsModule],
|
|
25
|
+
config: [workflowRuntimeConfig],
|
|
26
|
+
ormBazis: { context: RuntimeDbContext, entities: [...RUNTIME_ENTITIES], ownedStore: runtimeStore, registerRepositories: false },
|
|
27
|
+
providers: [
|
|
28
|
+
DI.singleton(DI.factoryProvider(GraphCache, [], () => new GraphCache())),
|
|
29
|
+
DI.scoped(DI.factoryProviderWithResolver(WorkflowEngine, [], (resolver) => new WorkflowEngine(
|
|
30
|
+
resolver.resolve(RuntimeDbContext),
|
|
31
|
+
resolver.resolve(IWorkflowDefinitions),
|
|
32
|
+
resolver.resolve(GraphCache),
|
|
33
|
+
resolver.resolve(EventBus),
|
|
34
|
+
resolver,
|
|
35
|
+
resolver.tryResolve(LOGGER),
|
|
36
|
+
))),
|
|
37
|
+
scoped(ToolStep, ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools] as const),
|
|
38
|
+
// The application's input validator, if it registered one anywhere; the built-in one otherwise.
|
|
39
|
+
DI.scoped(DI.factoryProviderWithResolver(IWorkflowRuntime, [], (resolver) => new WorkflowRuntime(
|
|
40
|
+
resolver.resolve(RuntimeDbContext),
|
|
41
|
+
resolver.resolve(WorkflowEngine),
|
|
42
|
+
resolver.resolve(IWorkflowDefinitions),
|
|
43
|
+
resolver.resolve(SERVICE_PROVIDER).tryResolve(WorkflowInputValidator) ?? new DefaultWorkflowInputValidator(),
|
|
44
|
+
))),
|
|
45
|
+
],
|
|
46
|
+
controllers: [DI.bindDeps(InstancesController, IWorkflowRuntime)],
|
|
47
|
+
background: [
|
|
48
|
+
DI.bindDeps(WorkflowWorker, SERVICE_PROVIDER, workflowRuntimeConfig.token),
|
|
49
|
+
DI.bindDeps(WorkflowRetention, SERVICE_PROVIDER, workflowRuntimeConfig.token),
|
|
50
|
+
],
|
|
51
|
+
exports: [IWorkflowRuntime],
|
|
52
|
+
})
|
|
53
|
+
export class WorkflowRuntimeModule {}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { BackgroundService, delay } from "../../../background";
|
|
2
|
+
import type { ServiceProvider } from "../../../di";
|
|
3
|
+
import type { ConfigView } from "../../../kernel";
|
|
4
|
+
import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
5
|
+
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
6
|
+
import type { WorkflowSettings } from "./WorkflowWorker";
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deletes `completed` and `cancelled` instances older than their process's
|
|
12
|
+
* `retentionDays`, with tokens, jobs, subscriptions and history (by cascade),
|
|
13
|
+
* `retention.batchSize` at a time. `failed` instances wait for a person and stay.
|
|
14
|
+
*/
|
|
15
|
+
export class WorkflowRetention extends BackgroundService {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly services: ServiceProvider,
|
|
18
|
+
private readonly config: ConfigView<WorkflowSettings>,
|
|
19
|
+
) {
|
|
20
|
+
super();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
protected override async execute(signal: AbortSignal): Promise<void> {
|
|
24
|
+
if (!this.config.get("retention.enabled")) return;
|
|
25
|
+
const intervalMs = this.config.get("retention.intervalMs");
|
|
26
|
+
while (!signal.aborted) {
|
|
27
|
+
try {
|
|
28
|
+
await this.sweep(signal);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (signal.aborted) return;
|
|
31
|
+
this.reportFailure("retention sweep failed", error);
|
|
32
|
+
}
|
|
33
|
+
await delay(intervalMs, signal);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One pass over every process with a retention period; returns how many instances went. */
|
|
38
|
+
async sweep(signal?: AbortSignal): Promise<number> {
|
|
39
|
+
const batchSize = this.config.get("retention.batchSize");
|
|
40
|
+
const scope = this.services.createScope();
|
|
41
|
+
let deleted = 0;
|
|
42
|
+
try {
|
|
43
|
+
const definitions = scope.resolve(IWorkflowDefinitions);
|
|
44
|
+
const db = scope.resolve(RuntimeDbContext);
|
|
45
|
+
for (const policy of await definitions.retentionPolicies()) {
|
|
46
|
+
const cutoff = new Date(Date.now() - policy.retentionDays * DAY_MS);
|
|
47
|
+
for (;;) {
|
|
48
|
+
if (signal?.aborted) return deleted;
|
|
49
|
+
const ids = (await db.instances.asNoTracking()
|
|
50
|
+
.where((i) => i.processKey.eq(policy.key).and(i.status.in(["completed", "cancelled"])).and(i.endedAt.lt(cutoff)))
|
|
51
|
+
.orderBy((i) => i.id)
|
|
52
|
+
.take(batchSize)
|
|
53
|
+
.select((i) => ({ id: i.id }))
|
|
54
|
+
.toList()).map((row) => row.id);
|
|
55
|
+
if (ids.length === 0) break;
|
|
56
|
+
await db.instances.asNoTracking().where((i) => i.id.in(ids)).executeDelete();
|
|
57
|
+
deleted += ids.length;
|
|
58
|
+
if (ids.length < batchSize) break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await scope.dispose();
|
|
63
|
+
}
|
|
64
|
+
return deleted;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { BackgroundService, delay } from "../../../background";
|
|
2
|
+
import type { ServiceProvider } from "../../../di";
|
|
3
|
+
import type { ConfigView } from "../../../kernel";
|
|
4
|
+
import type { workflowRuntimeConfig } from "../config/Runtime.config";
|
|
5
|
+
import { ToolStep } from "../internal/ToolStep";
|
|
6
|
+
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
7
|
+
import type { WfJob } from "../model/WfJob.model";
|
|
8
|
+
|
|
9
|
+
/** How long a claimed job belongs to this worker; if it dies, another one takes the job after that. */
|
|
10
|
+
const LEASE_MS = 5 * 60_000;
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Takes due jobs of `bazis_wf_run_jobs` every `worker.intervalMs`, up to
|
|
15
|
+
* `worker.batchSize` at a time, with `FOR UPDATE SKIP LOCKED` and a lease, so
|
|
16
|
+
* application instances never take one job twice. A full batch is followed by
|
|
17
|
+
* the next one at once. Every job runs in its own DI scope.
|
|
18
|
+
*/
|
|
19
|
+
export class WorkflowWorker extends BackgroundService {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly services: ServiceProvider,
|
|
22
|
+
private readonly config: ConfigView<WorkflowSettings>,
|
|
23
|
+
) {
|
|
24
|
+
super();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected override async execute(signal: AbortSignal): Promise<void> {
|
|
28
|
+
if (!this.config.get("worker.enabled")) return;
|
|
29
|
+
const intervalMs = this.config.get("worker.intervalMs");
|
|
30
|
+
const batchSize = this.config.get("worker.batchSize");
|
|
31
|
+
while (!signal.aborted) {
|
|
32
|
+
let claimed = 0;
|
|
33
|
+
try {
|
|
34
|
+
const jobs = await this.claim(batchSize);
|
|
35
|
+
claimed = jobs.length;
|
|
36
|
+
for (const job of jobs) {
|
|
37
|
+
if (signal.aborted) return;
|
|
38
|
+
await this.run(job, signal);
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (signal.aborted) return;
|
|
42
|
+
this.reportFailure("claiming jobs failed", error);
|
|
43
|
+
}
|
|
44
|
+
if (claimed < batchSize) await delay(intervalMs, signal);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
49
|
+
const scope = this.services.createScope();
|
|
50
|
+
try {
|
|
51
|
+
await scope.resolve(ToolStep).run(job, signal);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
this.reportFailure("job failed", error, { job: job.id, instance: job.instanceId });
|
|
54
|
+
} finally {
|
|
55
|
+
await scope.dispose();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async claim(batchSize: number): Promise<WfJob[]> {
|
|
60
|
+
const scope = this.services.createScope();
|
|
61
|
+
try {
|
|
62
|
+
const db = scope.resolve(RuntimeDbContext);
|
|
63
|
+
return await db.transactionScope(async () => {
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const jobs = await db.jobs
|
|
66
|
+
.where((j) => j.kind.eq("tool").and(j.dueAt.lte(now)).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
|
|
67
|
+
.orderBy((j) => j.id)
|
|
68
|
+
.take(batchSize)
|
|
69
|
+
.forUpdate({ skipLocked: true })
|
|
70
|
+
.toList();
|
|
71
|
+
for (const job of jobs) {
|
|
72
|
+
job.lockedUntil = new Date(now.getTime() + LEASE_MS);
|
|
73
|
+
job.attempts += 1;
|
|
74
|
+
}
|
|
75
|
+
await db.saveChanges();
|
|
76
|
+
return jobs;
|
|
77
|
+
});
|
|
78
|
+
} finally {
|
|
79
|
+
await scope.dispose();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The resolved settings of {@link workflowRuntimeConfig}. */
|
|
85
|
+
export interface WorkflowSettings {
|
|
86
|
+
readonly "worker.enabled": boolean;
|
|
87
|
+
readonly "worker.intervalMs": number;
|
|
88
|
+
readonly "worker.batchSize": number;
|
|
89
|
+
readonly "retention.enabled": boolean;
|
|
90
|
+
readonly "retention.intervalMs": number;
|
|
91
|
+
readonly "retention.batchSize": number;
|
|
92
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig } from "../../../kernel";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settings of the workflow background services (WORKFLOW-001 §4), from the
|
|
5
|
+
* application configuration: `BAZIS_WORKFLOW__WORKER__ENABLED=false` keeps an
|
|
6
|
+
* instance from running jobs, for example a web-only one.
|
|
7
|
+
*/
|
|
8
|
+
export const workflowRuntimeConfig = defineConfig("workflow", {
|
|
9
|
+
default: {
|
|
10
|
+
"worker.enabled": true,
|
|
11
|
+
"worker.intervalMs": 1000,
|
|
12
|
+
"worker.batchSize": 10,
|
|
13
|
+
"retention.enabled": true,
|
|
14
|
+
"retention.intervalMs": 3_600_000,
|
|
15
|
+
"retention.batchSize": 500,
|
|
16
|
+
},
|
|
17
|
+
validate: {
|
|
18
|
+
"worker.intervalMs": (value) => positive(value, 3_600_000),
|
|
19
|
+
"worker.batchSize": (value) => positive(value, 1000),
|
|
20
|
+
"retention.intervalMs": (value) => positive(value, 86_400_000),
|
|
21
|
+
"retention.batchSize": (value) => positive(value, 10_000),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function positive(value: unknown, max: number): string | undefined {
|
|
26
|
+
return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= max ? undefined : `must be an integer 1–${max}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createEventToken } from "../../../kernel";
|
|
2
|
+
import type { FormSpec } from "../../../../library/workflow";
|
|
3
|
+
import type { InstanceStatus } from "../model/WfInstance.model";
|
|
4
|
+
|
|
5
|
+
/** A token reached a user task. Published after the step commits. */
|
|
6
|
+
export interface WorkflowTaskRequested {
|
|
7
|
+
readonly instanceId: string;
|
|
8
|
+
readonly tokenId: string;
|
|
9
|
+
readonly processKey: string;
|
|
10
|
+
readonly nodeId: string;
|
|
11
|
+
readonly partition: string | null;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly assignee: { readonly role: string } | { readonly user: string };
|
|
14
|
+
readonly form: FormSpec | null;
|
|
15
|
+
/** When the task is due, from the node's `dueIn`. */
|
|
16
|
+
readonly dueAt: Date | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A waiting token was closed otherwise than by its task: the task is dropped. */
|
|
20
|
+
export interface WorkflowTokenClosed {
|
|
21
|
+
readonly instanceId: string;
|
|
22
|
+
readonly tokenId: string;
|
|
23
|
+
readonly reason: "cancelled";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An instance finished: completed or cancelled. */
|
|
27
|
+
export interface WorkflowInstanceFinished {
|
|
28
|
+
readonly instanceId: string;
|
|
29
|
+
readonly processKey: string;
|
|
30
|
+
readonly partition: string | null;
|
|
31
|
+
readonly status: Extract<InstanceStatus, "completed" | "cancelled">;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const WORKFLOW_TASK_REQUESTED = createEventToken<WorkflowTaskRequested>("workflow.task-requested");
|
|
35
|
+
export const WORKFLOW_TOKEN_CLOSED = createEventToken<WorkflowTokenClosed>("workflow.token-closed");
|
|
36
|
+
export const WORKFLOW_INSTANCE_FINISHED = createEventToken<WorkflowInstanceFinished>("workflow.instance-finished");
|