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.
Files changed (31) hide show
  1. package/core/agent/AgentToolExecutor.ts +3 -0
  2. package/core/agent/Tool.contract.ts +1 -1
  3. package/core/agent/index.ts +1 -0
  4. package/core/agent/metadata.ts +9 -0
  5. package/core/agent/semantic.ts +8 -1
  6. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  7. package/core/workflow/contracts/types.ts +37 -1
  8. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -1
  9. package/core/workflow/definitions_module/events/Definitions.events.ts +16 -0
  10. package/core/workflow/definitions_module/http/Processes.controller.ts +17 -12
  11. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +18 -6
  12. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +44 -21
  13. package/core/workflow/generated/deps.ts +5 -3
  14. package/core/workflow/index.ts +13 -1
  15. package/core/workflow/internal/WorkflowEvents.ts +23 -0
  16. package/core/workflow/internal/http.ts +34 -5
  17. package/core/workflow/runtime_module/events/Runtime.events.ts +28 -1
  18. package/core/workflow/runtime_module/http/Events.controller.ts +2 -1
  19. package/core/workflow/runtime_module/http/Instances.controller.ts +19 -13
  20. package/core/workflow/runtime_module/internal/ToolStep.ts +2 -1
  21. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +23 -21
  22. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +14 -5
  23. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +12 -11
  24. package/core/workflow/tasks_module/events/Tasks.events.ts +15 -0
  25. package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -12
  26. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -2
  27. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +9 -6
  28. package/library/openapi/codegen.ts +9 -0
  29. package/library/validation/types/ValidatorOptions.ts +10 -0
  30. package/library/workflow/formFields.ts +2 -1
  31. 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
  }
@@ -88,6 +88,7 @@ export {
88
88
  type ToolApproval,
89
89
  type ToolMetadata,
90
90
  type ToolActor,
91
+ type ToolDryRun,
91
92
  type ToolOptions,
92
93
  type ToolSideEffect,
93
94
  } from "./metadata";
@@ -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([]),
@@ -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.130.0" }, capabilities: { experimentalApi: true },
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 { body, callerOf, optionalInteger, optionalString, pagingQuery, param, requiredString } from "../../internal/http";
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 archived = ctx.query("archived");
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({ ...pagingQuery(ctx), access: await access.filter("workflow.processes.read"), archived: archived === undefined ? undefined : archived === "true", search: optionalString(ctx.query("search"), "search") });
20
- return { data: page.items, meta: { total: page.total } };
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
- return await this.definitions.saveDraft(key, request.document, actor);
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 and returns every validation finding; `conflict` for a process declared in code. */
76
- abstract saveDraft(key: string, document: unknown, actor: WorkflowActor): Promise<{ readonly issues: readonly ProcessIssue[] }>;
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, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
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.count();
53
- const rows = await source.orderBy((p) => p.key).skip(offset).take(limit).toList();
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, _actor: WorkflowActor): Promise<ProcessDetails> {
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): Promise<{ readonly issues: readonly ProcessIssue[] }> {
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
- const row = await this.process(key, true);
99
- this.assertNotCode(key);
100
- row.draft = document;
101
- row.draftUpdatedAt = this.clock.now();
102
- row.draftUpdatedByKind = actor.kind;
103
- row.draftUpdatedById = actor.id;
104
- await this.db.saveChanges();
105
- return { issues };
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
- return (await this.publishDraft(row, actor)).version;
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, EventBus, ServiceProvider, WorkflowClock, "optional:Logger"]],
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
  ]);
@@ -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
+ }