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.
Files changed (38) 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 +10 -1
  9. package/core/workflow/definitions_module/access/Definitions.permissions.ts +23 -0
  10. package/core/workflow/definitions_module/access/Process.references.ts +27 -0
  11. package/core/workflow/definitions_module/events/Definitions.events.ts +16 -0
  12. package/core/workflow/definitions_module/http/Processes.controller.ts +34 -20
  13. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +21 -6
  14. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +48 -21
  15. package/core/workflow/generated/deps.ts +7 -3
  16. package/core/workflow/index.ts +13 -2
  17. package/core/workflow/internal/WorkflowEvents.ts +23 -0
  18. package/core/workflow/internal/http.ts +51 -21
  19. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -1
  20. package/core/workflow/runtime_module/access/Runtime.permissions.ts +28 -0
  21. package/core/workflow/runtime_module/events/Runtime.events.ts +28 -1
  22. package/core/workflow/runtime_module/http/Events.controller.ts +6 -3
  23. package/core/workflow/runtime_module/http/Instances.controller.ts +38 -22
  24. package/core/workflow/runtime_module/internal/ToolStep.ts +2 -1
  25. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +23 -21
  26. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +17 -5
  27. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +16 -11
  28. package/core/workflow/tasks_module/WorkflowTasks.module.ts +4 -1
  29. package/core/workflow/tasks_module/access/Tasks.permissions.ts +13 -0
  30. package/core/workflow/tasks_module/events/Tasks.events.ts +15 -0
  31. package/core/workflow/tasks_module/http/Tasks.controller.ts +33 -22
  32. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +10 -5
  33. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +50 -15
  34. package/library/openapi/codegen.ts +9 -0
  35. package/library/validation/types/ValidatorOptions.ts +10 -0
  36. package/library/workflow/formFields.ts +2 -1
  37. package/package.json +1 -1
  38. 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, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
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 { limit, offset } = pagingOf(query);
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.count();
49
- const rows = await source.orderBy((p) => p.key).skip(offset).take(limit).toList();
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, _actor: WorkflowActor): Promise<ProcessDetails> {
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): Promise<{ readonly issues: readonly ProcessIssue[] }> {
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
- const row = await this.process(key, true);
95
- this.assertNotCode(key);
96
- row.draft = document;
97
- row.draftUpdatedAt = this.clock.now();
98
- row.draftUpdatedByKind = actor.kind;
99
- row.draftUpdatedById = actor.id;
100
- await this.db.saveChanges();
101
- 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
+ });
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
- 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;
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, 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"]],
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
  ]);
@@ -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 { ForbiddenError, UnauthorizedError, type HttpContext } from "../../http";
2
- import { WorkflowAccess, type WorkflowAccessTarget, type WorkflowAction } from "../access/WorkflowAccess";
3
- import type { WorkflowActor } from "../contracts/types";
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
- * Asks the application's {@link WorkflowAccess} who calls and whether the action
8
- * is allowed. Without a registered access every route answers 403.
9
- */
10
- export async function authorize(ctx: HttpContext, action: WorkflowAction, target: WorkflowAccessTarget = {}): Promise<WorkflowActor> {
11
- const access = ctx.services.tryResolve(WorkflowAccess);
12
- if (access === undefined) throw new ForbiddenError("Workflow HTTP is closed: register a WorkflowAccess implementation");
13
- const actor = await access.actor(ctx);
14
- if (actor === undefined) throw new UnauthorizedError();
15
- if (!await access.can(ctx, action, target)) throw new ForbiddenError();
16
- return actor;
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
- /** Whom the caller's tasks are addressed to, from the application's {@link WorkflowAccess}. */
20
- export async function assigneesOf(ctx: HttpContext, actor: WorkflowActor): Promise<readonly string[]> {
21
- const access = ctx.services.tryResolve(WorkflowAccess);
22
- return access === undefined ? [] : await access.assignees(ctx, actor);
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
- /** `limit` and `offset` of a list query. */
67
- export function pagingQuery(ctx: HttpContext): { limit?: number; offset?: number } {
68
- return { limit: optionalInteger(ctx.query("limit"), "limit"), offset: optionalInteger(ctx.query("offset"), "offset") };
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 { InstanceStatus } from "../model/WfInstance.model";
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 { Controller, Post, type HttpContext } from "../../../http";
3
- import { authorize, body, optionalString, param } from "../../internal/http";
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 authorize(ctx, "events.emit", { partition });
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 { Controller, Created, Get, Post, type HttpContext } from "../../../http";
3
- import { WorkflowError } from "../../contracts/WorkflowError";
4
- import { authorize, body, optionalString, pagingQuery, param, requiredString } from "../../internal/http";
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 come from `WorkflowAccess` with the instance's partition. */
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 authorize(ctx, "instances.start", { processKey, partition });
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 partition = optionalString(ctx.query("partition"), "partition");
31
- const processKey = optionalString(ctx.query("processKey"), "processKey");
32
- await authorize(ctx, "instances.read", { partition: partition ?? null, ...(processKey === undefined ? {} : { processKey }) });
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
- ...pagingQuery(ctx),
35
- partition,
36
- processKey,
37
- status: optionalString(ctx.query("status"), "status") as InstanceStatus | undefined,
38
- businessKey: optionalString(ctx.query("businessKey"), "businessKey"),
39
- startedById: optionalString(ctx.query("startedById"), "startedById"),
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 { data: page.items, meta: { total: page.total } };
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 authorize(ctx, "instances.read", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
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 authorize(ctx, "instances.read", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
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 authorize(ctx, "instances.manage", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
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 authorize(ctx, "instances.manage", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
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, partition: instance.partition, processKey: instance.processKey, nodeId: token.nodeId },
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