bazis 0.129.0 → 0.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/agent/AgentToolExecutor.ts +3 -0
- package/core/agent/Tool.contract.ts +1 -1
- package/core/agent/index.ts +1 -0
- package/core/agent/metadata.ts +9 -0
- package/core/agent/semantic.ts +8 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/contracts/types.ts +37 -1
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +10 -1
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +23 -0
- package/core/workflow/definitions_module/access/Process.references.ts +27 -0
- package/core/workflow/definitions_module/events/Definitions.events.ts +16 -0
- package/core/workflow/definitions_module/http/Processes.controller.ts +34 -20
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +21 -6
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +48 -21
- package/core/workflow/generated/deps.ts +7 -3
- package/core/workflow/index.ts +13 -2
- package/core/workflow/internal/WorkflowEvents.ts +23 -0
- package/core/workflow/internal/http.ts +51 -21
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -1
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +28 -0
- package/core/workflow/runtime_module/events/Runtime.events.ts +28 -1
- package/core/workflow/runtime_module/http/Events.controller.ts +6 -3
- package/core/workflow/runtime_module/http/Instances.controller.ts +38 -22
- package/core/workflow/runtime_module/internal/ToolStep.ts +2 -1
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +23 -21
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +17 -5
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +16 -11
- package/core/workflow/tasks_module/WorkflowTasks.module.ts +4 -1
- package/core/workflow/tasks_module/access/Tasks.permissions.ts +13 -0
- package/core/workflow/tasks_module/events/Tasks.events.ts +15 -0
- package/core/workflow/tasks_module/http/Tasks.controller.ts +33 -22
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +10 -5
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +50 -15
- package/library/openapi/codegen.ts +9 -0
- package/library/validation/types/ValidatorOptions.ts +10 -0
- package/library/workflow/formFields.ts +2 -1
- package/package.json +1 -1
- package/core/workflow/access/WorkflowAccess.ts +0 -52
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import { grantsToWhere } from "../../../access";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { UniqueViolationError } from "../../../orm";
|
|
3
|
+
import { paginate, UniqueViolationError } from "../../../orm";
|
|
3
4
|
import type { AgentToolContract } from "../../../agent";
|
|
4
5
|
import { emptyProcess, hasErrors, validateProcess, type ProcessDocument, type ProcessIssue } from "../../../../library/workflow";
|
|
5
|
-
import { isUuid,
|
|
6
|
+
import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowAuditOptions, type WorkflowPage } from "../../contracts/types";
|
|
6
7
|
import type { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
7
8
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
9
|
+
import type { WorkflowEvents } from "../../internal/WorkflowEvents";
|
|
8
10
|
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
9
11
|
import type { CodeProcessCatalog } from "../code/CodeProcessCatalog";
|
|
10
12
|
import type { OwnedCodeProcess } from "../code/codeProcesses";
|
|
13
|
+
import { WORKFLOW_PROCESS_CHANGED, type ProcessChange } from "../events/Definitions.events";
|
|
11
14
|
import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
|
|
12
15
|
import { WfProcess } from "../model/WfProcess.model";
|
|
13
16
|
import { WfVersion } from "../model/WfVersion.model";
|
|
@@ -17,6 +20,8 @@ import {
|
|
|
17
20
|
type ProcessDetails,
|
|
18
21
|
type ProcessListQuery,
|
|
19
22
|
type ProcessSummary,
|
|
23
|
+
type SavedDraft,
|
|
24
|
+
type SaveDraftOptions,
|
|
20
25
|
type UpdateProcess,
|
|
21
26
|
type VersionDetails,
|
|
22
27
|
type VersionSummary,
|
|
@@ -32,29 +37,31 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
32
37
|
private readonly workflowTools: WorkflowTools,
|
|
33
38
|
private readonly code: CodeProcessCatalog,
|
|
34
39
|
private readonly clock: WorkflowClock,
|
|
40
|
+
private readonly events: WorkflowEvents,
|
|
35
41
|
) {
|
|
36
42
|
super();
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
async list(query: ProcessListQuery = {}): Promise<WorkflowPage<ProcessSummary>> {
|
|
40
|
-
const
|
|
46
|
+
const where = query.access === undefined ? "all" : grantsToWhere(query.access);
|
|
47
|
+
if (where === "none") return { items: [], total: 0 };
|
|
41
48
|
let source = this.db.processes.asNoTracking();
|
|
49
|
+
if (where !== "all") source = source.where(() => where);
|
|
42
50
|
if (query.archived === true) source = source.where((p) => p.archivedAt.isNotNull());
|
|
43
51
|
if (query.archived === false) source = source.where((p) => p.archivedAt.isNull());
|
|
44
52
|
if (query.search !== undefined && query.search.trim() !== "") {
|
|
45
53
|
const text = query.search.trim();
|
|
46
54
|
source = source.where((p) => p.key.contains(text, { ignoreCase: true }).or(p.title.contains(text, { ignoreCase: true })));
|
|
47
55
|
}
|
|
48
|
-
const total = await source
|
|
49
|
-
|
|
50
|
-
return { items: rows.map((row) => this.summaryOf(row)), total };
|
|
56
|
+
const { items, total } = await paginate(source, pageQueryOf(query, { field: "key", dir: "asc" }));
|
|
57
|
+
return { items: items.map((row) => this.summaryOf(row)), total };
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
async get(key: string): Promise<ProcessDetails> {
|
|
54
61
|
return this.detailsOf(await this.process(key, false));
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
async create(process: CreateProcess, actor: WorkflowActor): Promise<ProcessDetails> {
|
|
64
|
+
async create(process: CreateProcess, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<ProcessDetails> {
|
|
58
65
|
if (typeof process.key !== "string" || !KEY.test(process.key)) throw new WorkflowError("invalid_input", "key must match ^[a-z][a-z0-9_.-]{0,63}$");
|
|
59
66
|
const row = Object.assign(new WfProcess(), {
|
|
60
67
|
key: process.key,
|
|
@@ -73,43 +80,57 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
73
80
|
if (error instanceof UniqueViolationError) throw new WorkflowError("conflict", `Process ${process.key} already exists`);
|
|
74
81
|
throw error;
|
|
75
82
|
}
|
|
83
|
+
await this.changed("created", row.key, actor, options);
|
|
76
84
|
return this.detailsOf(row);
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
async update(key: string, changes: UpdateProcess,
|
|
87
|
+
async update(key: string, changes: UpdateProcess, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<ProcessDetails> {
|
|
80
88
|
const row = await this.process(key, true);
|
|
81
89
|
const { archived, ...fields } = changes;
|
|
82
90
|
if (Object.values(fields).some((value) => value !== undefined)) this.assertNotCode(key);
|
|
91
|
+
const before = { title: row.title, description: row.description, retentionDays: row.retentionDays, archived: row.archivedAt !== null };
|
|
83
92
|
if (changes.title !== undefined) row.title = title(changes.title);
|
|
84
93
|
if (changes.description !== undefined) row.description = description(changes.description);
|
|
85
94
|
if (changes.retentionDays !== undefined) row.retentionDays = retention(changes.retentionDays);
|
|
86
95
|
if (archived !== undefined) row.archivedAt = archived ? row.archivedAt ?? this.clock.now() : null;
|
|
87
96
|
await this.db.saveChanges();
|
|
97
|
+
if (row.title !== before.title || row.description !== before.description || row.retentionDays !== before.retentionDays) await this.changed("updated", key, actor, options);
|
|
98
|
+
if (archived !== undefined && archived !== before.archived) await this.changed(archived ? "archived" : "restored", key, actor, options);
|
|
88
99
|
return this.detailsOf(row);
|
|
89
100
|
}
|
|
90
101
|
|
|
91
|
-
async saveDraft(key: string, document: unknown, actor: WorkflowActor
|
|
102
|
+
async saveDraft(key: string, document: unknown, actor: WorkflowActor, options: SaveDraftOptions = {}): Promise<SavedDraft> {
|
|
92
103
|
const issues = this.validate(document);
|
|
93
104
|
if (issues.some((issue) => issue.code === "too_large")) throw new WorkflowError("invalid_document", "The document is too large", issues);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
return await this.db.transactionScope(async (transaction) => {
|
|
106
|
+
const row = await this.db.processes.where((p) => p.key.eq(key)).forUpdate().firstOrDefault();
|
|
107
|
+
if (!row) throw notFound(key);
|
|
108
|
+
this.assertNotCode(key);
|
|
109
|
+
const previous = row.draftUpdatedAt?.getTime();
|
|
110
|
+
if (options.expected !== undefined && previous !== options.expected.getTime()) throw new WorkflowError("conflict", `The draft of ${key} was saved by someone else`);
|
|
111
|
+
// Later than the last save even within a millisecond: `expected` tells any two saves apart.
|
|
112
|
+
const draftUpdatedAt = new Date(Math.max(this.clock.now().getTime(), (previous ?? 0) + 1));
|
|
113
|
+
row.draft = document;
|
|
114
|
+
row.draftUpdatedAt = draftUpdatedAt;
|
|
115
|
+
row.draftUpdatedByKind = actor.kind;
|
|
116
|
+
row.draftUpdatedById = actor.id;
|
|
117
|
+
await this.db.saveChanges();
|
|
118
|
+
transaction.afterCommit(() => this.changed("draftSaved", key, actor, options));
|
|
119
|
+
return { issues, draftUpdatedAt };
|
|
120
|
+
});
|
|
102
121
|
}
|
|
103
122
|
|
|
104
|
-
async publish(key: string, actor: WorkflowActor): Promise<VersionDetails> {
|
|
105
|
-
return await this.db.transactionScope(async () => {
|
|
123
|
+
async publish(key: string, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<VersionDetails> {
|
|
124
|
+
return await this.db.transactionScope(async (transaction) => {
|
|
106
125
|
const row = await this.db.processes.where((p) => p.key.eq(key)).forUpdate().firstOrDefault();
|
|
107
126
|
if (!row) throw notFound(key);
|
|
108
127
|
this.assertNotCode(key);
|
|
109
128
|
if (row.archivedAt !== null) throw new WorkflowError("archived", `Process ${key} is archived`);
|
|
110
129
|
const issues = this.validate(row.draft);
|
|
111
130
|
if (hasErrors(issues)) throw new WorkflowError("invalid_document", `Process ${key} has errors and cannot be published`, issues);
|
|
112
|
-
|
|
131
|
+
const { version, created } = await this.publishDraft(row, actor);
|
|
132
|
+
if (created) transaction.afterCommit(() => this.changed("published", key, actor, options, version.version));
|
|
133
|
+
return version;
|
|
113
134
|
});
|
|
114
135
|
}
|
|
115
136
|
|
|
@@ -120,7 +141,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
120
141
|
* and unpublished. The caller has checked the documents with the Tools.
|
|
121
142
|
*/
|
|
122
143
|
async publishCode(entries: readonly OwnedCodeProcess[]): Promise<readonly { readonly key: string; readonly version: number | null; readonly published: boolean }[]> {
|
|
123
|
-
return await this.db.transactionScope(async () => {
|
|
144
|
+
return await this.db.transactionScope(async (transaction) => {
|
|
124
145
|
await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext('bazis.workflow.code'))");
|
|
125
146
|
const results: { key: string; version: number | null; published: boolean }[] = [];
|
|
126
147
|
for (const { process } of [...entries].sort((a, b) => a.process.key.localeCompare(b.process.key))) {
|
|
@@ -128,6 +149,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
128
149
|
if (!row) {
|
|
129
150
|
row = Object.assign(new WfProcess(), { key: process.key });
|
|
130
151
|
this.db.processes.add(row);
|
|
152
|
+
transaction.afterCommit(() => this.changed("created", process.key, CODE_ACTOR));
|
|
131
153
|
}
|
|
132
154
|
const fields = { title: title(process.title), description: description(process.description), retentionDays: retention(process.retentionDays) };
|
|
133
155
|
if (row.title !== fields.title) row.title = fields.title;
|
|
@@ -145,6 +167,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
145
167
|
continue;
|
|
146
168
|
}
|
|
147
169
|
const { version, created } = await this.publishDraft(row, CODE_ACTOR);
|
|
170
|
+
if (created) transaction.afterCommit(() => this.changed("published", process.key, CODE_ACTOR, {}, version.version));
|
|
148
171
|
results.push({ key: row.key, version: version.version, published: created });
|
|
149
172
|
}
|
|
150
173
|
return results;
|
|
@@ -222,6 +245,10 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
222
245
|
return this.workflowTools.list();
|
|
223
246
|
}
|
|
224
247
|
|
|
248
|
+
private async changed(change: ProcessChange, key: string, actor: WorkflowActor, options: WorkflowAuditOptions = {}, version: number | null = null): Promise<void> {
|
|
249
|
+
await this.events.publish(WORKFLOW_PROCESS_CHANGED, { change, key, version, actor, request: options.request ?? null });
|
|
250
|
+
}
|
|
251
|
+
|
|
225
252
|
/** A process declared in code changes only through the code. */
|
|
226
253
|
private assertNotCode(key: string): void {
|
|
227
254
|
const module = this.code.moduleOf(key);
|
|
@@ -5,12 +5,14 @@ import { DbContextOptions } from "../../../library/orm/DbContextOptions";
|
|
|
5
5
|
import { ServiceProvider } from "../../di/ServiceProvider";
|
|
6
6
|
import { EventBus } from "../../kernel/events/EventBus";
|
|
7
7
|
import { WorkflowClock } from "../contracts/WorkflowClock";
|
|
8
|
+
import { ProcessReferences } from "../definitions_module/access/Process.references";
|
|
8
9
|
import { CodeProcessCatalog } from "../definitions_module/code/CodeProcessCatalog";
|
|
9
10
|
import { CodeProcessPublisher } from "../definitions_module/code/CodeProcessPublisher";
|
|
10
11
|
import { ProcessesController } from "../definitions_module/http/Processes.controller";
|
|
11
12
|
import { DefinitionsDbContext } from "../definitions_module/model/Definitions.dbContext";
|
|
12
13
|
import { IWorkflowDefinitions } from "../definitions_module/services/IWorkflowDefinitions.service";
|
|
13
14
|
import { WorkflowDefinitions } from "../definitions_module/services/WorkflowDefinitions.service";
|
|
15
|
+
import { WorkflowEvents } from "../internal/WorkflowEvents";
|
|
14
16
|
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
15
17
|
import { WorkflowRetention } from "../runtime_module/background/WorkflowRetention";
|
|
16
18
|
import { WorkflowWorker } from "../runtime_module/background/WorkflowWorker";
|
|
@@ -37,6 +39,7 @@ registerFrameworkClassDeps([
|
|
|
37
39
|
[EventsController, [IWorkflowRuntime]],
|
|
38
40
|
[InstancesController, [IWorkflowRuntime]],
|
|
39
41
|
[ProcessesController, [IWorkflowDefinitions]],
|
|
42
|
+
[ProcessReferences, [DefinitionsDbContext]],
|
|
40
43
|
[RuntimeDbContext, [DbContextOptions]],
|
|
41
44
|
[Schedules, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions]],
|
|
42
45
|
[TaskEvents, [TasksDbContext]],
|
|
@@ -44,11 +47,12 @@ registerFrameworkClassDeps([
|
|
|
44
47
|
[TasksDbContext, [DbContextOptions]],
|
|
45
48
|
[TimerStep, [RuntimeDbContext, WorkflowEngine]],
|
|
46
49
|
[ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools]],
|
|
47
|
-
[WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock]],
|
|
48
|
-
[WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache,
|
|
50
|
+
[WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents]],
|
|
51
|
+
[WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache, WorkflowEvents, WorkflowClock]],
|
|
52
|
+
[WorkflowEvents, [EventBus, ServiceProvider, "optional:Logger"]],
|
|
49
53
|
[WorkflowRetention, [ServiceProvider, "Config:workflow"]],
|
|
50
54
|
[WorkflowRuntime, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions, ServiceProvider]],
|
|
51
|
-
[WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock]],
|
|
55
|
+
[WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock, WorkflowEvents]],
|
|
52
56
|
[WorkflowTools, [ServiceProvider]],
|
|
53
57
|
[WorkflowWorker, [ServiceProvider, "Config:workflow"]],
|
|
54
58
|
]);
|
package/core/workflow/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { WorkflowDefinitionsModule } from "./definitions_module/WorkflowDefiniti
|
|
|
14
14
|
export { WorkflowRuntimeModule } from "./runtime_module/WorkflowRuntime.module";
|
|
15
15
|
export { WorkflowTasksModule } from "./tasks_module/WorkflowTasks.module";
|
|
16
16
|
export { IWorkflowTasks, type TaskDetails, type TaskListQuery } from "./tasks_module/services/IWorkflowTasks.service";
|
|
17
|
+
export { WORKFLOW_TASK_COMPLETED, type WorkflowTaskCompleted } from "./tasks_module/events/Tasks.events";
|
|
18
|
+
export { WORKFLOW_PROCESS_CHANGED, type ProcessChange, type WorkflowProcessChanged } from "./definitions_module/events/Definitions.events";
|
|
17
19
|
export type { TaskStatus } from "./tasks_module/model/WfTask.model";
|
|
18
20
|
export {
|
|
19
21
|
IWorkflowDefinitions,
|
|
@@ -21,27 +23,37 @@ export {
|
|
|
21
23
|
type ProcessDetails,
|
|
22
24
|
type ProcessListQuery,
|
|
23
25
|
type ProcessSummary,
|
|
26
|
+
type SavedDraft,
|
|
27
|
+
type SaveDraftOptions,
|
|
24
28
|
type UpdateProcess,
|
|
25
29
|
type VersionDetails,
|
|
26
30
|
type VersionSummary,
|
|
27
31
|
} from "./definitions_module/services/IWorkflowDefinitions.service";
|
|
28
32
|
export {
|
|
29
33
|
IWorkflowRuntime,
|
|
34
|
+
type CancelInstance,
|
|
30
35
|
type EmitOptions,
|
|
31
36
|
type EmitResult,
|
|
32
37
|
type HistoryEntry,
|
|
33
38
|
type InstanceDetails,
|
|
34
39
|
type InstanceListQuery,
|
|
35
40
|
type InstanceSummary,
|
|
41
|
+
type RetryInstance,
|
|
36
42
|
type StartInstance,
|
|
37
43
|
type TokenDetails,
|
|
38
44
|
} from "./runtime_module/services/IWorkflowRuntime.service";
|
|
39
45
|
export {
|
|
46
|
+
WORKFLOW_INSTANCE_CANCELLED,
|
|
40
47
|
WORKFLOW_INSTANCE_FINISHED,
|
|
48
|
+
WORKFLOW_INSTANCE_RETRIED,
|
|
49
|
+
WORKFLOW_INSTANCE_STARTED,
|
|
41
50
|
WORKFLOW_INSTANCES_DELETED,
|
|
42
51
|
WORKFLOW_TASK_REQUESTED,
|
|
43
52
|
WORKFLOW_TOKEN_CLOSED,
|
|
53
|
+
type WorkflowInstanceCancelled,
|
|
44
54
|
type WorkflowInstanceFinished,
|
|
55
|
+
type WorkflowInstanceRetried,
|
|
56
|
+
type WorkflowInstanceStarted,
|
|
45
57
|
type WorkflowInstancesDeleted,
|
|
46
58
|
type WorkflowTaskRequested,
|
|
47
59
|
type WorkflowTokenClosed,
|
|
@@ -50,10 +62,9 @@ export { workflowRuntimeConfig } from "./runtime_module/config/Runtime.config";
|
|
|
50
62
|
export type { HistoryType } from "./runtime_module/model/WfHistory.model";
|
|
51
63
|
export type { InstanceStatus, StartedVia } from "./runtime_module/model/WfInstance.model";
|
|
52
64
|
export type { TokenStatus } from "./runtime_module/model/WfToken.model";
|
|
53
|
-
export { WorkflowAccess, DenyWorkflowAccess, type WorkflowAccessTarget, type WorkflowAction } from "./access/WorkflowAccess";
|
|
54
65
|
export { WorkflowInputValidator, DefaultWorkflowInputValidator, type WorkflowInputContext } from "./contracts/WorkflowInputValidator";
|
|
55
66
|
export { WorkflowError, type WorkflowErrorCode } from "./contracts/WorkflowError";
|
|
56
|
-
export type { WorkflowActor, WorkflowPage, WorkflowPaging } from "./contracts/types";
|
|
67
|
+
export type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging, WorkflowRequest, WorkflowToolMetadata } from "./contracts/types";
|
|
57
68
|
|
|
58
69
|
// Augment the declaring file, not the "../di" re-export: re-export targets
|
|
59
70
|
// depend on program file order and silently stop merging in some projects.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ServiceProvider } from "../../di";
|
|
2
|
+
import { EventBus, type EventToken, type Logger } from "../../kernel";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Publishes the events of the workflow services after their action is saved, in
|
|
6
|
+
* the scope of the action. A failing handler is logged: the action stands.
|
|
7
|
+
*/
|
|
8
|
+
export class WorkflowEvents {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly bus: EventBus,
|
|
11
|
+
/** Event handlers run in this scope. */
|
|
12
|
+
private readonly scope: ServiceProvider,
|
|
13
|
+
private readonly logger?: Logger,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async publish<T>(event: EventToken<T>, payload: T): Promise<void> {
|
|
17
|
+
try {
|
|
18
|
+
await this.bus.publishScoped(this.scope, event, payload);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
this.logger?.error("workflow event handler failed", { event: event.name, error: error instanceof Error ? error.message : String(error) });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,25 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import { IAccess, type AccessObject, type SubjectAccess } from "../../access";
|
|
2
|
+
import { ForbiddenError, type HttpContext } from "../../http";
|
|
3
|
+
import { frameworkBadRequest } from "../../http/Errors/HttpError";
|
|
4
|
+
import { ListQueryError, parseListQuery, type ListQuery, type ListQueryOptions, type ListQueryProblem } from "../../../library/jsonapi";
|
|
5
|
+
import type { WorkflowActor, WorkflowPaging, WorkflowRequest } from "../contracts/types";
|
|
4
6
|
import { WorkflowError } from "../contracts/WorkflowError";
|
|
5
7
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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;
|
|
8
|
+
/** Who calls a route: their rights and how they are recorded as an actor. */
|
|
9
|
+
export interface WorkflowCaller {
|
|
10
|
+
readonly access: SubjectAccess;
|
|
11
|
+
readonly actor: WorkflowActor;
|
|
12
|
+
readonly request: WorkflowRequest;
|
|
17
13
|
}
|
|
18
14
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The caller of a route; 401 without a subject. With `permission` and `object`
|
|
17
|
+
* also checks the object the route's `can` could not know: 403 without the
|
|
18
|
+
* right. The actor's id is the first owner id, so that `{ own: true }` means
|
|
19
|
+
* the instances the subject started.
|
|
20
|
+
*/
|
|
21
|
+
export async function callerOf(ctx: HttpContext, permission?: string, object?: AccessObject): Promise<WorkflowCaller> {
|
|
22
|
+
const access = await ctx.services.resolve(IAccess).for(ctx);
|
|
23
|
+
if (permission !== undefined && !await access.can(permission, object)) throw new ForbiddenError();
|
|
24
|
+
const sub = access.subject.sub;
|
|
25
|
+
const [id = sub.slice(sub.lastIndexOf(":") + 1)] = await access.ownerIds();
|
|
26
|
+
return { access, actor: { kind: "user", id }, request: { ip: ctx.clientIp ?? null, userAgent: ctx.header("user-agent") ?? null } };
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
/** A route parameter as a string. */
|
|
@@ -63,7 +67,33 @@ export function optionalInteger(value: unknown, name: string): number | undefine
|
|
|
63
67
|
return number as number;
|
|
64
68
|
}
|
|
65
69
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The list query of a route: `filter[...]`, `sort`, `page[...]`; anything
|
|
72
|
+
* `options` does not list is 400. Each filter here names one service option, so
|
|
73
|
+
* OR groups and a field filtered twice are refused rather than half applied.
|
|
74
|
+
*/
|
|
75
|
+
export function listQuery(ctx: HttpContext, options: Pick<ListQueryOptions, "filter" | "sort">): ListQuery {
|
|
76
|
+
try {
|
|
77
|
+
const list = parseListQuery(ctx.url.searchParams, { ...options, page: { defaultSize: 50, maxSize: 200 } });
|
|
78
|
+
const problems: ListQueryProblem[] = list.or.length > 0 ? [{ parameter: "filter[or]", message: "OR groups are not supported here" }] : [];
|
|
79
|
+
for (const [index, { field }] of list.filters.entries()) {
|
|
80
|
+
if (list.filters.findIndex((rule) => rule.field === field) !== index) problems.push({ parameter: `filter[${field}]`, message: "the field is filtered twice" });
|
|
81
|
+
}
|
|
82
|
+
if (problems.length > 0) throw new ListQueryError(problems);
|
|
83
|
+
return list;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof ListQueryError) throw frameworkBadRequest("invalidListQuery", undefined, error.problems);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The page and order of a list query, for a list operation. */
|
|
91
|
+
export function pagingOfList(list: ListQuery): WorkflowPaging {
|
|
92
|
+
return { limit: list.page.limit, offset: list.page.offset, ...(list.sort.length === 0 ? {} : { sort: list.sort }) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The value of `filter[field]`. */
|
|
96
|
+
export function filterOf(list: ListQuery, field: string): string | undefined {
|
|
97
|
+
const value = list.filters.find((rule) => rule.field === field)?.value;
|
|
98
|
+
return Array.isArray(value) ? value.join(",") : value as string | undefined;
|
|
69
99
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { AccessModule } from "../../access";
|
|
1
2
|
import { Module, scoped, singleton } from "../../di";
|
|
2
3
|
import { DefaultWorkflowInputValidator, WorkflowInputValidator } from "../contracts/WorkflowInputValidator";
|
|
3
4
|
import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
|
|
5
|
+
import { runtimePermissions } from "./access/Runtime.permissions";
|
|
4
6
|
import { WorkflowRetention } from "./background/WorkflowRetention";
|
|
5
7
|
import { WorkflowWorker } from "./background/WorkflowWorker";
|
|
6
8
|
import { workflowRuntimeConfig } from "./config/Runtime.config";
|
|
@@ -21,7 +23,8 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
|
|
|
21
23
|
* validator by registering its own `WorkflowInputValidator`.
|
|
22
24
|
*/
|
|
23
25
|
@Module({
|
|
24
|
-
imports: [WorkflowDefinitionsModule],
|
|
26
|
+
imports: [AccessModule, WorkflowDefinitionsModule],
|
|
27
|
+
permissions: runtimePermissions,
|
|
25
28
|
config: [workflowRuntimeConfig],
|
|
26
29
|
ormBazis: { context: RuntimeDbContext, entities: [...RUNTIME_ENTITIES], ownedStore: runtimeStore, registerRepositories: false },
|
|
27
30
|
providers: [
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { definePermissions } from "../../../access";
|
|
2
|
+
|
|
3
|
+
const process = { ref: "workflow.process", column: "processKey" };
|
|
4
|
+
|
|
5
|
+
/** The rights of running processes (WORKFLOW-002 §6.2). */
|
|
6
|
+
export const runtimePermissions = definePermissions({
|
|
7
|
+
"workflow.instances.start": {
|
|
8
|
+
title: { en: "Start processes", ru: "Запускать процессы" },
|
|
9
|
+
scope: "request",
|
|
10
|
+
on: { process },
|
|
11
|
+
},
|
|
12
|
+
"workflow.instances.read": {
|
|
13
|
+
title: { en: "See instances", ru: "Видеть экземпляры" },
|
|
14
|
+
scope: { column: "partition" },
|
|
15
|
+
on: { process },
|
|
16
|
+
owner: "startedById",
|
|
17
|
+
},
|
|
18
|
+
"workflow.instances.manage": {
|
|
19
|
+
title: { en: "Cancel and retry instances", ru: "Отменять и повторять экземпляры" },
|
|
20
|
+
scope: { column: "partition" },
|
|
21
|
+
on: { process },
|
|
22
|
+
},
|
|
23
|
+
"workflow.events.emit": {
|
|
24
|
+
title: { en: "Send events to processes", ru: "Отправлять события процессам" },
|
|
25
|
+
scope: "request",
|
|
26
|
+
on: { event: { type: "string" } },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createEventToken } from "../../../kernel";
|
|
2
2
|
import type { FormSpec } from "../../../../library/workflow";
|
|
3
|
-
import type {
|
|
3
|
+
import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
|
|
4
|
+
import type { InstanceStatus, StartedVia } from "../model/WfInstance.model";
|
|
4
5
|
|
|
5
6
|
/** A token reached a user task. Published after the step commits. */
|
|
6
7
|
export interface WorkflowTaskRequested {
|
|
@@ -37,7 +38,33 @@ export interface WorkflowInstancesDeleted {
|
|
|
37
38
|
readonly instanceIds: readonly string[];
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** What the audit events of an instance share. Published after the commit. */
|
|
42
|
+
interface WorkflowInstanceAudit {
|
|
43
|
+
readonly instanceId: string;
|
|
44
|
+
readonly processKey: string;
|
|
45
|
+
readonly partition: string | null;
|
|
46
|
+
readonly actor: WorkflowActor;
|
|
47
|
+
readonly request: WorkflowRequest | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An instance started: by hand, from an event or on schedule. */
|
|
51
|
+
export interface WorkflowInstanceStarted extends WorkflowInstanceAudit {
|
|
52
|
+
readonly version: number;
|
|
53
|
+
readonly businessKey: string | null;
|
|
54
|
+
readonly startedVia: StartedVia;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WorkflowInstanceCancelled extends WorkflowInstanceAudit {
|
|
58
|
+
readonly reason: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The failed steps of an instance run again. */
|
|
62
|
+
export type WorkflowInstanceRetried = WorkflowInstanceAudit;
|
|
63
|
+
|
|
40
64
|
export const WORKFLOW_TASK_REQUESTED = createEventToken<WorkflowTaskRequested>("workflow.task-requested");
|
|
41
65
|
export const WORKFLOW_TOKEN_CLOSED = createEventToken<WorkflowTokenClosed>("workflow.token-closed");
|
|
42
66
|
export const WORKFLOW_INSTANCE_FINISHED = createEventToken<WorkflowInstanceFinished>("workflow.instance-finished");
|
|
43
67
|
export const WORKFLOW_INSTANCES_DELETED = createEventToken<WorkflowInstancesDeleted>("workflow.instances-deleted");
|
|
68
|
+
export const WORKFLOW_INSTANCE_STARTED = createEventToken<WorkflowInstanceStarted>("workflow.instance-started");
|
|
69
|
+
export const WORKFLOW_INSTANCE_CANCELLED = createEventToken<WorkflowInstanceCancelled>("workflow.instance-cancelled");
|
|
70
|
+
export const WORKFLOW_INSTANCE_RETRIED = createEventToken<WorkflowInstanceRetried>("workflow.instance-retried");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { can } from "../../../access";
|
|
3
|
+
import { Authorize, Controller, Post, type HttpContext } from "../../../http";
|
|
4
|
+
import { body, callerOf, optionalString, param } from "../../internal/http";
|
|
4
5
|
import { IWorkflowRuntime } from "../services/IWorkflowRuntime.service";
|
|
5
6
|
|
|
6
7
|
/** Events from outside — a webhook, another service — over HTTP (WORKFLOW-001 §4). */
|
|
@@ -9,15 +10,17 @@ export class EventsController {
|
|
|
9
10
|
constructor(private readonly runtime: IWorkflowRuntime) {}
|
|
10
11
|
|
|
11
12
|
@Post(":eventKey")
|
|
13
|
+
@Authorize(can("workflow.events.emit"))
|
|
12
14
|
async emit(ctx: HttpContext) {
|
|
13
15
|
const eventKey = param(ctx, "eventKey");
|
|
14
16
|
const request = await body(ctx);
|
|
15
17
|
const partition = optionalString(request.partition, "partition") ?? null;
|
|
16
|
-
const actor = await
|
|
18
|
+
const { actor, request: from } = await callerOf(ctx, "workflow.events.emit", { scope: partition, event: eventKey });
|
|
17
19
|
const result = await this.runtime.emit(eventKey, request.payload ?? {}, {
|
|
18
20
|
correlation: optionalString(request.correlation, "correlation") ?? null,
|
|
19
21
|
partition,
|
|
20
22
|
actor,
|
|
23
|
+
request: from,
|
|
21
24
|
});
|
|
22
25
|
ctx.res.status(202);
|
|
23
26
|
return result;
|
|
@@ -1,74 +1,90 @@
|
|
|
1
1
|
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { can, type AccessObject } from "../../../access";
|
|
3
|
+
import { Authorize, Controller, Created, Get, Post, type HttpContext } from "../../../http";
|
|
4
|
+
import { buildListDocument } from "../../../../library/jsonapi";
|
|
5
|
+
import { body, callerOf, filterOf, listQuery, optionalString, pagingOfList, param, requiredString } from "../../internal/http";
|
|
5
6
|
import type { InstanceStatus } from "../model/WfInstance.model";
|
|
6
|
-
import { IWorkflowRuntime } from "../services/IWorkflowRuntime.service";
|
|
7
|
+
import { IWorkflowRuntime, type InstanceSummary } from "../services/IWorkflowRuntime.service";
|
|
7
8
|
|
|
8
|
-
/** Instances over HTTP (WORKFLOW-001 §4); rights
|
|
9
|
+
/** Instances over HTTP (WORKFLOW-001 §4); rights are `workflow.instances.*` (WORKFLOW-002 §6.2). */
|
|
9
10
|
@Controller("workflow/instances")
|
|
10
11
|
export class InstancesController {
|
|
11
12
|
constructor(private readonly runtime: IWorkflowRuntime) {}
|
|
12
13
|
|
|
13
14
|
@Post()
|
|
15
|
+
@Authorize(can("workflow.instances.start"))
|
|
14
16
|
async start(ctx: HttpContext) {
|
|
15
17
|
const request = await body(ctx);
|
|
16
18
|
const processKey = requiredString(request.processKey, "processKey", 64);
|
|
17
19
|
const partition = optionalString(request.partition, "partition") ?? null;
|
|
18
|
-
const actor = await
|
|
20
|
+
const { actor, request: from } = await callerOf(ctx, "workflow.instances.start", { scope: partition, process: processKey });
|
|
19
21
|
const instance = await this.runtime.start(processKey, {
|
|
20
22
|
input: request.input,
|
|
21
23
|
partition,
|
|
22
24
|
businessKey: optionalString(request.businessKey, "businessKey") ?? null,
|
|
23
25
|
actor,
|
|
26
|
+
request: from,
|
|
24
27
|
});
|
|
25
28
|
return Created(`${ctx.path}/${instance.id}`, instance);
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
@Get()
|
|
32
|
+
@Authorize(can("workflow.instances.read"))
|
|
29
33
|
async list(ctx: HttpContext) {
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
34
|
+
const { access } = await callerOf(ctx);
|
|
35
|
+
const list = listQuery(ctx, {
|
|
36
|
+
filter: { partition: ["eq"], processKey: ["eq"], status: ["eq"], businessKey: ["eq"], startedById: ["eq"] },
|
|
37
|
+
sort: ["createdAt", "endedAt", "status", "processKey"],
|
|
38
|
+
});
|
|
33
39
|
const page = await this.runtime.instances({
|
|
34
|
-
...
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
...pagingOfList(list),
|
|
41
|
+
access: await access.filter("workflow.instances.read"),
|
|
42
|
+
partition: filterOf(list, "partition"),
|
|
43
|
+
processKey: filterOf(list, "processKey"),
|
|
44
|
+
status: filterOf(list, "status") as InstanceStatus | undefined,
|
|
45
|
+
businessKey: filterOf(list, "businessKey"),
|
|
46
|
+
startedById: filterOf(list, "startedById"),
|
|
40
47
|
});
|
|
41
|
-
return
|
|
48
|
+
return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
@Get(":id")
|
|
52
|
+
@Authorize(can("workflow.instances.read"))
|
|
45
53
|
async get(ctx: HttpContext) {
|
|
46
54
|
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
47
|
-
await
|
|
55
|
+
await callerOf(ctx, "workflow.instances.read", objectOf(instance));
|
|
48
56
|
return instance;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
@Get(":id/history")
|
|
60
|
+
@Authorize(can("workflow.instances.read"))
|
|
52
61
|
async history(ctx: HttpContext) {
|
|
53
62
|
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
54
|
-
await
|
|
63
|
+
await callerOf(ctx, "workflow.instances.read", objectOf(instance));
|
|
55
64
|
return { data: await this.runtime.history(instance.id) };
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
@Post(":id/cancel")
|
|
68
|
+
@Authorize(can("workflow.instances.manage"))
|
|
59
69
|
async cancel(ctx: HttpContext) {
|
|
60
70
|
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
61
71
|
const request = await body(ctx);
|
|
62
|
-
const actor = await
|
|
63
|
-
return await this.runtime.cancel(instance.id, { reason: optionalString(request.reason, "reason", 1000) ?? null, actor });
|
|
72
|
+
const { actor, request: from } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
|
|
73
|
+
return await this.runtime.cancel(instance.id, { reason: optionalString(request.reason, "reason", 1000) ?? null, actor, request: from });
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
@Post(":id/retry")
|
|
77
|
+
@Authorize(can("workflow.instances.manage"))
|
|
67
78
|
async retry(ctx: HttpContext) {
|
|
68
79
|
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
69
|
-
const actor = await
|
|
70
|
-
return await this.runtime.retry(instance.id, { actor });
|
|
80
|
+
const { actor, request } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
|
|
81
|
+
return await this.runtime.retry(instance.id, { actor, request });
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
contextOnlyController(InstancesController);
|
|
74
85
|
|
|
86
|
+
/** The instance as the object of a permission. */
|
|
87
|
+
function objectOf(instance: InstanceSummary): AccessObject {
|
|
88
|
+
return { scope: instance.partition, process: instance.processKey, startedById: instance.startedBy.id };
|
|
89
|
+
}
|
|
90
|
+
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentToolResult } from "../../../agent";
|
|
2
2
|
import { durationMs, resolveInput } from "../../../../library/workflow";
|
|
3
|
+
import type { WorkflowToolMetadata } from "../../contracts/types";
|
|
3
4
|
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
4
5
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
5
6
|
import type { WfJob } from "../model/WfJob.model";
|
|
@@ -36,7 +37,7 @@ export class ToolStep {
|
|
|
36
37
|
: await runner.run(name, input, {
|
|
37
38
|
idempotencyKey: token.id,
|
|
38
39
|
actor: { kind: instance.startedByKind, id: instance.startedById },
|
|
39
|
-
metadata: { instanceId: instance.id,
|
|
40
|
+
metadata: { instanceId: instance.id, processKey: instance.processKey, nodeId: token.nodeId, partition: instance.partition, test: false } satisfies WorkflowToolMetadata,
|
|
40
41
|
signal,
|
|
41
42
|
});
|
|
42
43
|
|