bazis 0.130.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 +3 -1
- package/core/workflow/definitions_module/events/Definitions.events.ts +16 -0
- package/core/workflow/definitions_module/http/Processes.controller.ts +17 -12
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +18 -6
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +44 -21
- package/core/workflow/generated/deps.ts +5 -3
- package/core/workflow/index.ts +13 -1
- package/core/workflow/internal/WorkflowEvents.ts +23 -0
- package/core/workflow/internal/http.ts +34 -5
- package/core/workflow/runtime_module/events/Runtime.events.ts +28 -1
- package/core/workflow/runtime_module/http/Events.controller.ts +2 -1
- package/core/workflow/runtime_module/http/Instances.controller.ts +19 -13
- 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 +14 -5
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +12 -11
- package/core/workflow/tasks_module/events/Tasks.events.ts +15 -0
- package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -12
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -2
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +9 -6
- 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
|
@@ -58,6 +58,8 @@ export interface AgentToolExecutionContext {
|
|
|
58
58
|
readonly maxAttempts: number;
|
|
59
59
|
readonly idempotencyKey?: string;
|
|
60
60
|
readonly approval: AgentToolExecutionApproval;
|
|
61
|
+
/** On whose behalf the call runs, when the caller says so. */
|
|
62
|
+
readonly actor?: ToolActor;
|
|
61
63
|
readonly metadata: JsonObject;
|
|
62
64
|
readonly signal: AbortSignal;
|
|
63
65
|
}
|
|
@@ -1664,6 +1666,7 @@ export class AgentToolExecutor {
|
|
|
1664
1666
|
maxAttempts: attempt.maxAttempts,
|
|
1665
1667
|
...(attempt.idempotencyKey !== undefined ? { idempotencyKey: attempt.idempotencyKey } : {}),
|
|
1666
1668
|
approval,
|
|
1669
|
+
...(options.actor !== undefined ? { actor: options.actor } : {}),
|
|
1667
1670
|
metadata,
|
|
1668
1671
|
signal,
|
|
1669
1672
|
});
|
|
@@ -15,6 +15,6 @@ export function describeTool(tool: ToolDefinition) {
|
|
|
15
15
|
name: tool.metadata.name, description: tool.metadata.description,
|
|
16
16
|
input: tool.metadata.input ? schema(tool.metadata.input) : undefined,
|
|
17
17
|
output: tool.metadata.output ? schema(tool.metadata.output) : undefined,
|
|
18
|
-
sideEffect: tool.metadata.sideEffect, approval: tool.metadata.approval, timeoutMs: tool.metadata.timeoutMs,
|
|
18
|
+
sideEffect: tool.metadata.sideEffect, approval: tool.metadata.approval, timeoutMs: tool.metadata.timeoutMs, dryRun: tool.metadata.dryRun,
|
|
19
19
|
});
|
|
20
20
|
}
|
package/core/agent/index.ts
CHANGED
package/core/agent/metadata.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface ToolActor {
|
|
|
15
15
|
readonly id: string;
|
|
16
16
|
}
|
|
17
17
|
export type ToolApproval = "never" | "policy" | "required";
|
|
18
|
+
/** Whether a Tool can run without changing anything, when a process is tried out (WORKFLOW-002 §5.4). */
|
|
19
|
+
export type ToolDryRun = "supported" | "unsupported";
|
|
18
20
|
|
|
19
21
|
export interface ToolOptions {
|
|
20
22
|
readonly name: string;
|
|
@@ -24,6 +26,8 @@ export interface ToolOptions {
|
|
|
24
26
|
readonly sideEffect?: ToolSideEffect;
|
|
25
27
|
readonly approval?: ToolApproval;
|
|
26
28
|
readonly timeoutMs?: number;
|
|
29
|
+
/** `"unsupported"` by default. */
|
|
30
|
+
readonly dryRun?: ToolDryRun;
|
|
27
31
|
/**
|
|
28
32
|
* Who may call the Tool: `"agent"` for LLM agents, any other id for code that
|
|
29
33
|
* runs Tools itself (`ToolRunner.as("workflow")`). Required and non-empty;
|
|
@@ -42,6 +46,7 @@ export interface ToolMetadata {
|
|
|
42
46
|
readonly sideEffect: ToolSideEffect;
|
|
43
47
|
readonly approval: ToolApproval;
|
|
44
48
|
readonly timeoutMs?: number;
|
|
49
|
+
readonly dryRun: ToolDryRun;
|
|
45
50
|
readonly callers: readonly string[];
|
|
46
51
|
readonly tags: readonly string[];
|
|
47
52
|
}
|
|
@@ -103,6 +108,9 @@ export function defineToolMetadata(_target: object, metadata: object, options: T
|
|
|
103
108
|
if (options.approval !== undefined && options.approval !== "never" && options.approval !== "policy" && options.approval !== "required") {
|
|
104
109
|
throw new AgentSetupError("@Tool.approval must be never, policy or required.");
|
|
105
110
|
}
|
|
111
|
+
if (options.dryRun !== undefined && options.dryRun !== "supported" && options.dryRun !== "unsupported") {
|
|
112
|
+
throw new AgentSetupError("@Tool.dryRun must be supported or unsupported.");
|
|
113
|
+
}
|
|
106
114
|
const normalized: ToolMetadata = Object.freeze({
|
|
107
115
|
name: text(options.name, "@Tool.name"),
|
|
108
116
|
description: text(options.description, "@Tool.description"),
|
|
@@ -111,6 +119,7 @@ export function defineToolMetadata(_target: object, metadata: object, options: T
|
|
|
111
119
|
sideEffect,
|
|
112
120
|
approval: approvalFor(sideEffect, options.approval),
|
|
113
121
|
timeoutMs: positiveInteger(options.timeoutMs, "@Tool.timeoutMs"),
|
|
122
|
+
dryRun: options.dryRun ?? "unsupported",
|
|
114
123
|
// Checked when the module registers the Tool, so a missing list prints as
|
|
115
124
|
// one configuration-error line at start instead of failing the import.
|
|
116
125
|
callers: Array.isArray(options.callers) ? Object.freeze(options.callers.map((caller) => callerId(caller))) : Object.freeze([]),
|
package/core/agent/semantic.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AgentSemanticError } from "./errors";
|
|
2
|
-
import type { ToolApproval, ToolSideEffect } from "./metadata";
|
|
2
|
+
import type { ToolApproval, ToolDryRun, ToolSideEffect } from "./metadata";
|
|
3
3
|
|
|
4
4
|
const FORBIDDEN_JSON_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
5
5
|
|
|
@@ -162,6 +162,7 @@ export interface AgentToolContract {
|
|
|
162
162
|
readonly sideEffect: ToolSideEffect;
|
|
163
163
|
readonly approval: ToolApproval;
|
|
164
164
|
readonly timeoutMs?: number;
|
|
165
|
+
readonly dryRun: ToolDryRun;
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
export interface AgentToolContractOptions {
|
|
@@ -172,6 +173,7 @@ export interface AgentToolContractOptions {
|
|
|
172
173
|
readonly sideEffect?: ToolSideEffect;
|
|
173
174
|
readonly approval?: ToolApproval;
|
|
174
175
|
readonly timeoutMs?: number;
|
|
176
|
+
readonly dryRun?: ToolDryRun;
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
export interface AgentModelCapabilities {
|
|
@@ -533,6 +535,10 @@ export function agentToolContract(options: AgentToolContractOptions): AgentToolC
|
|
|
533
535
|
if ((sideEffect === "write" || sideEffect === "external") && approval === "never") {
|
|
534
536
|
throw new AgentSemanticError('write/external tools cannot use approval: "never".');
|
|
535
537
|
}
|
|
538
|
+
const dryRun = options.dryRun ?? "unsupported";
|
|
539
|
+
if (dryRun !== "supported" && dryRun !== "unsupported") {
|
|
540
|
+
throw new AgentSemanticError("tool.dryRun must be supported or unsupported.");
|
|
541
|
+
}
|
|
536
542
|
return freezeWithoutUndefined({
|
|
537
543
|
name: requiredName(options.name, "tool.name"),
|
|
538
544
|
description: requiredContentText(options.description, "tool.description"),
|
|
@@ -541,6 +547,7 @@ export function agentToolContract(options: AgentToolContractOptions): AgentToolC
|
|
|
541
547
|
sideEffect,
|
|
542
548
|
approval,
|
|
543
549
|
timeoutMs: optionalPositiveInteger(options.timeoutMs, "tool.timeoutMs"),
|
|
550
|
+
dryRun,
|
|
544
551
|
});
|
|
545
552
|
}
|
|
546
553
|
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.131.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -1,19 +1,42 @@
|
|
|
1
|
+
import type { ListQuery, SortField } from "../../../library/jsonapi";
|
|
2
|
+
|
|
1
3
|
/** Who acts: a user or the system (schedules, events). Steps of an instance run on behalf of who started it. */
|
|
2
4
|
export interface WorkflowActor {
|
|
3
5
|
readonly kind: "user" | "system";
|
|
4
6
|
readonly id: string;
|
|
5
7
|
}
|
|
6
8
|
|
|
9
|
+
/** Where an action came from, for the audit: `null` in its place means code called. */
|
|
10
|
+
export interface WorkflowRequest {
|
|
11
|
+
readonly ip: string | null;
|
|
12
|
+
readonly userAgent: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What the audit events of an action carry besides its actor. */
|
|
16
|
+
export interface WorkflowAuditOptions {
|
|
17
|
+
readonly request?: WorkflowRequest | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What `context.metadata` holds when a process calls a Tool: enough to name the cause in its own events. */
|
|
21
|
+
export interface WorkflowToolMetadata {
|
|
22
|
+
readonly instanceId: string;
|
|
23
|
+
readonly processKey: string;
|
|
24
|
+
readonly nodeId: string;
|
|
25
|
+
readonly partition: string | null;
|
|
26
|
+
readonly test: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
7
29
|
/** A page of a list. */
|
|
8
30
|
export interface WorkflowPage<T> {
|
|
9
31
|
readonly items: readonly T[];
|
|
10
32
|
readonly total: number;
|
|
11
33
|
}
|
|
12
34
|
|
|
13
|
-
/** Paging of the list operations: `limit` 1–200 (50 by default), `offset` from 0. */
|
|
35
|
+
/** Paging of the list operations: `limit` 1–200 (50 by default), `offset` from 0; `sort` replaces the list's own order. */
|
|
14
36
|
export interface WorkflowPaging {
|
|
15
37
|
readonly limit?: number;
|
|
16
38
|
readonly offset?: number;
|
|
39
|
+
readonly sort?: readonly SortField[];
|
|
17
40
|
}
|
|
18
41
|
|
|
19
42
|
export function pagingOf(paging: WorkflowPaging): { readonly limit: number; readonly offset: number } {
|
|
@@ -22,6 +45,19 @@ export function pagingOf(paging: WorkflowPaging): { readonly limit: number; read
|
|
|
22
45
|
return { limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 200) : 50, offset: Number.isFinite(offset) ? Math.max(offset, 0) : 0 };
|
|
23
46
|
}
|
|
24
47
|
|
|
48
|
+
/** The page of a list operation as `paginate` takes it. */
|
|
49
|
+
export function pageQueryOf(paging: WorkflowPaging, order: SortField): ListQuery {
|
|
50
|
+
const { limit, offset } = pagingOf(paging);
|
|
51
|
+
return {
|
|
52
|
+
sort: paging.sort ?? [order],
|
|
53
|
+
filters: [],
|
|
54
|
+
or: [],
|
|
55
|
+
include: [],
|
|
56
|
+
fields: {},
|
|
57
|
+
page: { limit, offset, size: limit, number: Math.floor(offset / limit) + 1 },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
25
61
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
26
62
|
|
|
27
63
|
/** Whether an id can be a row key; anything else would reach PostgreSQL as an invalid uuid. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AccessModule } from "../../access";
|
|
2
2
|
import { HOSTED_SERVICE, Module, scoped, singleton } from "../../di";
|
|
3
3
|
import { SystemWorkflowClock, WorkflowClock } from "../contracts/WorkflowClock";
|
|
4
|
+
import { WorkflowEvents } from "../internal/WorkflowEvents";
|
|
4
5
|
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
5
6
|
import { definitionsPermissions } from "./access/Definitions.permissions";
|
|
6
7
|
import { ProcessReferences } from "./access/Process.references";
|
|
@@ -26,11 +27,12 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
|
|
|
26
27
|
singleton(WorkflowClock, SystemWorkflowClock),
|
|
27
28
|
singleton(WorkflowTools),
|
|
28
29
|
singleton(CodeProcessCatalog),
|
|
30
|
+
scoped(WorkflowEvents),
|
|
29
31
|
scoped(IWorkflowDefinitions, WorkflowDefinitions),
|
|
30
32
|
scoped(ProcessReferences),
|
|
31
33
|
singleton(HOSTED_SERVICE, CodeProcessPublisher),
|
|
32
34
|
],
|
|
33
35
|
controllers: [ProcessesController],
|
|
34
|
-
exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock],
|
|
36
|
+
exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock, WorkflowEvents],
|
|
35
37
|
})
|
|
36
38
|
export class WorkflowDefinitionsModule {}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createEventToken } from "../../../kernel";
|
|
2
|
+
import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
|
|
3
|
+
|
|
4
|
+
export type ProcessChange = "created" | "updated" | "draftSaved" | "published" | "archived" | "restored";
|
|
5
|
+
|
|
6
|
+
/** A process of the catalog changed, for the application's audit. Published after the commit. */
|
|
7
|
+
export interface WorkflowProcessChanged {
|
|
8
|
+
readonly change: ProcessChange;
|
|
9
|
+
readonly key: string;
|
|
10
|
+
/** The version a `published` change made; otherwise `null`. */
|
|
11
|
+
readonly version: number | null;
|
|
12
|
+
readonly actor: WorkflowActor;
|
|
13
|
+
readonly request: WorkflowRequest | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const WORKFLOW_PROCESS_CHANGED = createEventToken<WorkflowProcessChanged>("workflow.process-changed");
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
2
2
|
import { can } from "../../../access";
|
|
3
3
|
import { Authorize, Controller, Created, Get, Patch, Post, Put, type HttpContext } from "../../../http";
|
|
4
|
-
import {
|
|
4
|
+
import { buildListDocument } from "../../../../library/jsonapi";
|
|
5
|
+
import { body, callerOf, filterOf, listQuery, optionalInteger, optionalString, pagingOfList, param, requiredString } from "../../internal/http";
|
|
5
6
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
6
7
|
import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
|
|
7
8
|
|
|
@@ -14,10 +15,11 @@ export class ProcessesController {
|
|
|
14
15
|
@Authorize(can("workflow.processes.read"))
|
|
15
16
|
async list(ctx: HttpContext) {
|
|
16
17
|
const { access } = await callerOf(ctx);
|
|
17
|
-
const
|
|
18
|
+
const list = listQuery(ctx, { filter: { archived: ["eq"], search: ["eq"] }, sort: ["key", "title", "createdAt", "updatedAt"] });
|
|
19
|
+
const archived = filterOf(list, "archived");
|
|
18
20
|
if (archived !== undefined && archived !== "true" && archived !== "false") throw new WorkflowError("invalid_input", "archived must be true or false");
|
|
19
|
-
const page = await this.definitions.list({ ...
|
|
20
|
-
return
|
|
21
|
+
const page = await this.definitions.list({ ...pagingOfList(list), access: await access.filter("workflow.processes.read"), archived: archived === undefined ? undefined : archived === "true", search: filterOf(list, "search") });
|
|
22
|
+
return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
@Post("processes")
|
|
@@ -25,13 +27,13 @@ export class ProcessesController {
|
|
|
25
27
|
async create(ctx: HttpContext) {
|
|
26
28
|
const request = await body(ctx);
|
|
27
29
|
const key = requiredString(request.key, "key", 64);
|
|
28
|
-
const { actor } = await callerOf(ctx, "workflow.processes.manage", { process: key });
|
|
30
|
+
const { actor, request: from } = await callerOf(ctx, "workflow.processes.manage", { process: key });
|
|
29
31
|
const process = await this.definitions.create({
|
|
30
32
|
key,
|
|
31
33
|
title: requiredString(request.title, "title"),
|
|
32
34
|
description: optionalString(request.description, "description", 2000) ?? null,
|
|
33
35
|
retentionDays: optionalInteger(request.retentionDays, "retentionDays") ?? null,
|
|
34
|
-
}, actor);
|
|
36
|
+
}, actor, { request: from });
|
|
35
37
|
return Created(`${ctx.path}/${process.key}`, process);
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -48,14 +50,14 @@ export class ProcessesController {
|
|
|
48
50
|
async update(ctx: HttpContext) {
|
|
49
51
|
const key = param(ctx, "key");
|
|
50
52
|
const request = await body(ctx);
|
|
51
|
-
const { actor } = await callerOf(ctx, "workflow.processes.manage", { process: key });
|
|
53
|
+
const { actor, request: from } = await callerOf(ctx, "workflow.processes.manage", { process: key });
|
|
52
54
|
if (request.archived !== undefined && typeof request.archived !== "boolean") throw new WorkflowError("invalid_input", "archived must be a boolean");
|
|
53
55
|
return await this.definitions.update(key, {
|
|
54
56
|
...(request.title === undefined ? {} : { title: requiredString(request.title, "title") }),
|
|
55
57
|
...(request.description === undefined ? {} : { description: optionalString(request.description, "description", 2000) ?? null }),
|
|
56
58
|
...(request.retentionDays === undefined ? {} : { retentionDays: optionalInteger(request.retentionDays, "retentionDays") ?? null }),
|
|
57
59
|
...(request.archived === undefined ? {} : { archived: request.archived as boolean }),
|
|
58
|
-
}, actor);
|
|
60
|
+
}, actor, { request: from });
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
@Put("processes/:key/draft")
|
|
@@ -63,16 +65,19 @@ export class ProcessesController {
|
|
|
63
65
|
async saveDraft(ctx: HttpContext) {
|
|
64
66
|
const key = param(ctx, "key");
|
|
65
67
|
const request = await body(ctx);
|
|
66
|
-
const { actor } = await callerOf(ctx, "workflow.processes.manage", { process: key });
|
|
67
|
-
|
|
68
|
+
const { actor, request: from } = await callerOf(ctx, "workflow.processes.manage", { process: key });
|
|
69
|
+
const expected = optionalString(request.expected, "expected");
|
|
70
|
+
const expectedAt = expected === undefined ? undefined : new Date(expected);
|
|
71
|
+
if (expectedAt !== undefined && Number.isNaN(expectedAt.getTime())) throw new WorkflowError("invalid_input", "expected must be the draftUpdatedAt that was read");
|
|
72
|
+
return await this.definitions.saveDraft(key, request.document, actor, { request: from, ...(expectedAt === undefined ? {} : { expected: expectedAt }) });
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
@Post("processes/:key/publish")
|
|
71
76
|
@Authorize(can("workflow.processes.publish"))
|
|
72
77
|
async publish(ctx: HttpContext) {
|
|
73
78
|
const key = param(ctx, "key");
|
|
74
|
-
const { actor } = await callerOf(ctx, "workflow.processes.publish", { process: key });
|
|
75
|
-
const version = await this.definitions.publish(key, actor);
|
|
79
|
+
const { actor, request } = await callerOf(ctx, "workflow.processes.publish", { process: key });
|
|
80
|
+
const version = await this.definitions.publish(key, actor, { request });
|
|
76
81
|
return Created(`${ctx.path.replace(/\/publish$/, "")}/versions/${version.version}`, version);
|
|
77
82
|
}
|
|
78
83
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AccessFilter } from "../../../access";
|
|
2
2
|
import type { AgentToolContract } from "../../../agent";
|
|
3
3
|
import type { ProcessDocument, ProcessIssue } from "../../../../library/workflow";
|
|
4
|
-
import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
|
|
4
|
+
import type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging } from "../../contracts/types";
|
|
5
5
|
import type { OwnedCodeProcess } from "../code/codeProcesses";
|
|
6
6
|
|
|
7
7
|
export interface ProcessSummary {
|
|
@@ -61,6 +61,18 @@ export interface ProcessListQuery extends WorkflowPaging {
|
|
|
61
61
|
readonly search?: string;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
export interface SaveDraftOptions extends WorkflowAuditOptions {
|
|
65
|
+
/** The `draftUpdatedAt` the caller read: another save since then is `conflict`. Without it the last save wins. */
|
|
66
|
+
readonly expected?: Date;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SavedDraft {
|
|
70
|
+
/** Every validation finding: a draft is stored with errors too. */
|
|
71
|
+
readonly issues: readonly ProcessIssue[];
|
|
72
|
+
/** Goes to `expected` of the next save. */
|
|
73
|
+
readonly draftUpdatedAt: Date;
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
/**
|
|
65
77
|
* The process catalog: drafts and immutable published versions
|
|
66
78
|
* (WORKFLOW-001 §3). Errors are {@link WorkflowError}: `not_found`, `conflict`,
|
|
@@ -69,16 +81,16 @@ export interface ProcessListQuery extends WorkflowPaging {
|
|
|
69
81
|
export abstract class IWorkflowDefinitions {
|
|
70
82
|
abstract list(query?: ProcessListQuery): Promise<WorkflowPage<ProcessSummary>>;
|
|
71
83
|
abstract get(key: string): Promise<ProcessDetails>;
|
|
72
|
-
abstract create(process: CreateProcess, actor: WorkflowActor): Promise<ProcessDetails>;
|
|
84
|
+
abstract create(process: CreateProcess, actor: WorkflowActor, options?: WorkflowAuditOptions): Promise<ProcessDetails>;
|
|
73
85
|
/** A process declared in code takes only `archived`; other fields give `conflict`. */
|
|
74
|
-
abstract update(key: string, changes: UpdateProcess, actor: WorkflowActor): Promise<ProcessDetails>;
|
|
75
|
-
/** Stores the draft even with errors
|
|
76
|
-
abstract saveDraft(key: string, document: unknown, actor: WorkflowActor): Promise<
|
|
86
|
+
abstract update(key: string, changes: UpdateProcess, actor: WorkflowActor, options?: WorkflowAuditOptions): Promise<ProcessDetails>;
|
|
87
|
+
/** Stores the draft even with errors; `conflict` for a process declared in code and when `expected` is out of date. */
|
|
88
|
+
abstract saveDraft(key: string, document: unknown, actor: WorkflowActor, options?: SaveDraftOptions): Promise<SavedDraft>;
|
|
77
89
|
/**
|
|
78
90
|
* Validates the draft and stores it as the next version; the same document
|
|
79
91
|
* again returns the latest version. `conflict` for a process declared in code.
|
|
80
92
|
*/
|
|
81
|
-
abstract publish(key: string, actor: WorkflowActor): Promise<VersionDetails>;
|
|
93
|
+
abstract publish(key: string, actor: WorkflowActor, options?: WorkflowAuditOptions): Promise<VersionDetails>;
|
|
82
94
|
abstract versions(key: string): Promise<readonly VersionSummary[]>;
|
|
83
95
|
abstract version(key: string, version: number): Promise<VersionDetails>;
|
|
84
96
|
/** The latest published version of a process that is not archived. */
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { grantsToWhere } from "../../../access";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { UniqueViolationError } from "../../../orm";
|
|
3
|
+
import { paginate, UniqueViolationError } from "../../../orm";
|
|
4
4
|
import type { AgentToolContract } from "../../../agent";
|
|
5
5
|
import { emptyProcess, hasErrors, validateProcess, type ProcessDocument, type ProcessIssue } from "../../../../library/workflow";
|
|
6
|
-
import { isUuid,
|
|
6
|
+
import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowAuditOptions, type WorkflowPage } from "../../contracts/types";
|
|
7
7
|
import type { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
8
8
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
9
|
+
import type { WorkflowEvents } from "../../internal/WorkflowEvents";
|
|
9
10
|
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
10
11
|
import type { CodeProcessCatalog } from "../code/CodeProcessCatalog";
|
|
11
12
|
import type { OwnedCodeProcess } from "../code/codeProcesses";
|
|
13
|
+
import { WORKFLOW_PROCESS_CHANGED, type ProcessChange } from "../events/Definitions.events";
|
|
12
14
|
import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
|
|
13
15
|
import { WfProcess } from "../model/WfProcess.model";
|
|
14
16
|
import { WfVersion } from "../model/WfVersion.model";
|
|
@@ -18,6 +20,8 @@ import {
|
|
|
18
20
|
type ProcessDetails,
|
|
19
21
|
type ProcessListQuery,
|
|
20
22
|
type ProcessSummary,
|
|
23
|
+
type SavedDraft,
|
|
24
|
+
type SaveDraftOptions,
|
|
21
25
|
type UpdateProcess,
|
|
22
26
|
type VersionDetails,
|
|
23
27
|
type VersionSummary,
|
|
@@ -33,12 +37,12 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
33
37
|
private readonly workflowTools: WorkflowTools,
|
|
34
38
|
private readonly code: CodeProcessCatalog,
|
|
35
39
|
private readonly clock: WorkflowClock,
|
|
40
|
+
private readonly events: WorkflowEvents,
|
|
36
41
|
) {
|
|
37
42
|
super();
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
async list(query: ProcessListQuery = {}): Promise<WorkflowPage<ProcessSummary>> {
|
|
41
|
-
const { limit, offset } = pagingOf(query);
|
|
42
46
|
const where = query.access === undefined ? "all" : grantsToWhere(query.access);
|
|
43
47
|
if (where === "none") return { items: [], total: 0 };
|
|
44
48
|
let source = this.db.processes.asNoTracking();
|
|
@@ -49,16 +53,15 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
49
53
|
const text = query.search.trim();
|
|
50
54
|
source = source.where((p) => p.key.contains(text, { ignoreCase: true }).or(p.title.contains(text, { ignoreCase: true })));
|
|
51
55
|
}
|
|
52
|
-
const total = await source
|
|
53
|
-
|
|
54
|
-
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 };
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
async get(key: string): Promise<ProcessDetails> {
|
|
58
61
|
return this.detailsOf(await this.process(key, false));
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
async create(process: CreateProcess, actor: WorkflowActor): Promise<ProcessDetails> {
|
|
64
|
+
async create(process: CreateProcess, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<ProcessDetails> {
|
|
62
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}$");
|
|
63
66
|
const row = Object.assign(new WfProcess(), {
|
|
64
67
|
key: process.key,
|
|
@@ -77,43 +80,57 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
77
80
|
if (error instanceof UniqueViolationError) throw new WorkflowError("conflict", `Process ${process.key} already exists`);
|
|
78
81
|
throw error;
|
|
79
82
|
}
|
|
83
|
+
await this.changed("created", row.key, actor, options);
|
|
80
84
|
return this.detailsOf(row);
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
async update(key: string, changes: UpdateProcess,
|
|
87
|
+
async update(key: string, changes: UpdateProcess, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<ProcessDetails> {
|
|
84
88
|
const row = await this.process(key, true);
|
|
85
89
|
const { archived, ...fields } = changes;
|
|
86
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 };
|
|
87
92
|
if (changes.title !== undefined) row.title = title(changes.title);
|
|
88
93
|
if (changes.description !== undefined) row.description = description(changes.description);
|
|
89
94
|
if (changes.retentionDays !== undefined) row.retentionDays = retention(changes.retentionDays);
|
|
90
95
|
if (archived !== undefined) row.archivedAt = archived ? row.archivedAt ?? this.clock.now() : null;
|
|
91
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);
|
|
92
99
|
return this.detailsOf(row);
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
async saveDraft(key: string, document: unknown, actor: WorkflowActor
|
|
102
|
+
async saveDraft(key: string, document: unknown, actor: WorkflowActor, options: SaveDraftOptions = {}): Promise<SavedDraft> {
|
|
96
103
|
const issues = this.validate(document);
|
|
97
104
|
if (issues.some((issue) => issue.code === "too_large")) throw new WorkflowError("invalid_document", "The document is too large", issues);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
});
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
async publish(key: string, actor: WorkflowActor): Promise<VersionDetails> {
|
|
109
|
-
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) => {
|
|
110
125
|
const row = await this.db.processes.where((p) => p.key.eq(key)).forUpdate().firstOrDefault();
|
|
111
126
|
if (!row) throw notFound(key);
|
|
112
127
|
this.assertNotCode(key);
|
|
113
128
|
if (row.archivedAt !== null) throw new WorkflowError("archived", `Process ${key} is archived`);
|
|
114
129
|
const issues = this.validate(row.draft);
|
|
115
130
|
if (hasErrors(issues)) throw new WorkflowError("invalid_document", `Process ${key} has errors and cannot be published`, issues);
|
|
116
|
-
|
|
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;
|
|
117
134
|
});
|
|
118
135
|
}
|
|
119
136
|
|
|
@@ -124,7 +141,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
124
141
|
* and unpublished. The caller has checked the documents with the Tools.
|
|
125
142
|
*/
|
|
126
143
|
async publishCode(entries: readonly OwnedCodeProcess[]): Promise<readonly { readonly key: string; readonly version: number | null; readonly published: boolean }[]> {
|
|
127
|
-
return await this.db.transactionScope(async () => {
|
|
144
|
+
return await this.db.transactionScope(async (transaction) => {
|
|
128
145
|
await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext('bazis.workflow.code'))");
|
|
129
146
|
const results: { key: string; version: number | null; published: boolean }[] = [];
|
|
130
147
|
for (const { process } of [...entries].sort((a, b) => a.process.key.localeCompare(b.process.key))) {
|
|
@@ -132,6 +149,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
132
149
|
if (!row) {
|
|
133
150
|
row = Object.assign(new WfProcess(), { key: process.key });
|
|
134
151
|
this.db.processes.add(row);
|
|
152
|
+
transaction.afterCommit(() => this.changed("created", process.key, CODE_ACTOR));
|
|
135
153
|
}
|
|
136
154
|
const fields = { title: title(process.title), description: description(process.description), retentionDays: retention(process.retentionDays) };
|
|
137
155
|
if (row.title !== fields.title) row.title = fields.title;
|
|
@@ -149,6 +167,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
149
167
|
continue;
|
|
150
168
|
}
|
|
151
169
|
const { version, created } = await this.publishDraft(row, CODE_ACTOR);
|
|
170
|
+
if (created) transaction.afterCommit(() => this.changed("published", process.key, CODE_ACTOR, {}, version.version));
|
|
152
171
|
results.push({ key: row.key, version: version.version, published: created });
|
|
153
172
|
}
|
|
154
173
|
return results;
|
|
@@ -226,6 +245,10 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
226
245
|
return this.workflowTools.list();
|
|
227
246
|
}
|
|
228
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
|
+
|
|
229
252
|
/** A process declared in code changes only through the code. */
|
|
230
253
|
private assertNotCode(key: string): void {
|
|
231
254
|
const module = this.code.moduleOf(key);
|
|
@@ -12,6 +12,7 @@ import { ProcessesController } from "../definitions_module/http/Processes.contro
|
|
|
12
12
|
import { DefinitionsDbContext } from "../definitions_module/model/Definitions.dbContext";
|
|
13
13
|
import { IWorkflowDefinitions } from "../definitions_module/services/IWorkflowDefinitions.service";
|
|
14
14
|
import { WorkflowDefinitions } from "../definitions_module/services/WorkflowDefinitions.service";
|
|
15
|
+
import { WorkflowEvents } from "../internal/WorkflowEvents";
|
|
15
16
|
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
16
17
|
import { WorkflowRetention } from "../runtime_module/background/WorkflowRetention";
|
|
17
18
|
import { WorkflowWorker } from "../runtime_module/background/WorkflowWorker";
|
|
@@ -46,11 +47,12 @@ registerFrameworkClassDeps([
|
|
|
46
47
|
[TasksDbContext, [DbContextOptions]],
|
|
47
48
|
[TimerStep, [RuntimeDbContext, WorkflowEngine]],
|
|
48
49
|
[ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools]],
|
|
49
|
-
[WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock]],
|
|
50
|
-
[WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache,
|
|
50
|
+
[WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents]],
|
|
51
|
+
[WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache, WorkflowEvents, WorkflowClock]],
|
|
52
|
+
[WorkflowEvents, [EventBus, ServiceProvider, "optional:Logger"]],
|
|
51
53
|
[WorkflowRetention, [ServiceProvider, "Config:workflow"]],
|
|
52
54
|
[WorkflowRuntime, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions, ServiceProvider]],
|
|
53
|
-
[WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock]],
|
|
55
|
+
[WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock, WorkflowEvents]],
|
|
54
56
|
[WorkflowTools, [ServiceProvider]],
|
|
55
57
|
[WorkflowWorker, [ServiceProvider, "Config:workflow"]],
|
|
56
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,
|
|
@@ -52,7 +64,7 @@ export type { InstanceStatus, StartedVia } from "./runtime_module/model/WfInstan
|
|
|
52
64
|
export type { TokenStatus } from "./runtime_module/model/WfToken.model";
|
|
53
65
|
export { WorkflowInputValidator, DefaultWorkflowInputValidator, type WorkflowInputContext } from "./contracts/WorkflowInputValidator";
|
|
54
66
|
export { WorkflowError, type WorkflowErrorCode } from "./contracts/WorkflowError";
|
|
55
|
-
export type { WorkflowActor, WorkflowPage, WorkflowPaging } from "./contracts/types";
|
|
67
|
+
export type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging, WorkflowRequest, WorkflowToolMetadata } from "./contracts/types";
|
|
56
68
|
|
|
57
69
|
// Augment the declaring file, not the "../di" re-export: re-export targets
|
|
58
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
|
+
}
|