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,10 +1,10 @@
1
- import type { ServiceProvider } from "../../../di";
2
- import { EventBus, type EventToken, type Logger } from "../../../kernel";
1
+ import type { EventToken } from "../../../kernel";
3
2
  import { durationMs, evaluateCondition, nodeKindOf, readPath, type FormSpec, type InstanceData, type ProcessFlow, type ProcessNode } from "../../../../library/workflow";
4
- import type { WorkflowActor } from "../../contracts/types";
3
+ import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
5
4
  import type { WorkflowClock } from "../../contracts/WorkflowClock";
6
5
  import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
7
- import { WORKFLOW_INSTANCE_FINISHED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED } from "../events/Runtime.events";
6
+ import type { WorkflowEvents } from "../../internal/WorkflowEvents";
7
+ import { WORKFLOW_INSTANCE_CANCELLED, WORKFLOW_INSTANCE_FINISHED, WORKFLOW_INSTANCE_RETRIED, WORKFLOW_INSTANCE_STARTED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED } from "../events/Runtime.events";
8
8
  import type { RuntimeDbContext } from "../model/Runtime.dbContext";
9
9
  import { WfHistory, type HistoryType } from "../model/WfHistory.model";
10
10
  import { WfInstance, type StartedVia } from "../model/WfInstance.model";
@@ -36,6 +36,8 @@ export interface BeginOptions {
36
36
  readonly businessKey: string | null;
37
37
  readonly startedVia: StartedVia;
38
38
  readonly actor: WorkflowActor;
39
+ /** Where the start came from; `null` for code, schedules and the worker. */
40
+ readonly request?: WorkflowRequest | null;
39
41
  }
40
42
 
41
43
  const LIVE = new Set(["active", "waiting", "failed"]);
@@ -51,11 +53,8 @@ export class WorkflowEngine {
51
53
  private readonly db: RuntimeDbContext,
52
54
  private readonly definitions: IWorkflowDefinitions,
53
55
  private readonly graphs: GraphCache,
54
- private readonly events: EventBus,
55
- /** The scope of the engine: event handlers run in it. */
56
- private readonly scope: ServiceProvider,
56
+ private readonly events: WorkflowEvents,
57
57
  private readonly clock: WorkflowClock,
58
- private readonly logger?: Logger,
59
58
  ) {}
60
59
 
61
60
  /** The engine's time ({@link WorkflowClock}). */
@@ -97,6 +96,10 @@ export class WorkflowEngine {
97
96
  await this.db.saveChanges();
98
97
  const step: Step = { instance, tokens: [], graph, events: [] };
99
98
  this.history(step, null, null, "started", { via: options.startedVia }, options.actor);
99
+ step.events.push({
100
+ event: WORKFLOW_INSTANCE_STARTED as EventToken<unknown>,
101
+ payload: { instanceId: instance.id, processKey: graph.processKey, version: graph.version, partition: options.partition, businessKey: options.businessKey, startedVia: options.startedVia, actor: options.actor, request: options.request ?? null },
102
+ });
100
103
  await this.advance(step, [this.spawn(step, startNodeId, null)]);
101
104
  await this.db.saveChanges();
102
105
  this.publishAfter(transaction, step);
@@ -231,8 +234,12 @@ export class WorkflowEngine {
231
234
  }
232
235
 
233
236
  /** Retries the failed steps: their tokens are back on their nodes and the instance runs again. */
234
- async retry(step: Step, actor: WorkflowActor): Promise<void> {
237
+ async retry(step: Step, actor: WorkflowActor, request: WorkflowRequest | null = null): Promise<void> {
235
238
  const failed = step.tokens.filter((t) => t.status === "failed");
239
+ step.events.push({
240
+ event: WORKFLOW_INSTANCE_RETRIED as EventToken<unknown>,
241
+ payload: { instanceId: step.instance.id, processKey: step.instance.processKey, partition: step.instance.partition, actor, request },
242
+ });
236
243
  step.instance.status = "running";
237
244
  step.instance.error = null;
238
245
  for (const token of failed) {
@@ -243,8 +250,12 @@ export class WorkflowEngine {
243
250
  }
244
251
 
245
252
  /** Closes every live token, job and subscription; the application drops the tasks on `WORKFLOW_TOKEN_CLOSED`. */
246
- async cancel(step: Step, reason: string | null, actor: WorkflowActor): Promise<void> {
253
+ async cancel(step: Step, reason: string | null, actor: WorkflowActor, request: WorkflowRequest | null = null): Promise<void> {
247
254
  const open = step.tokens.filter((t) => LIVE.has(t.status));
255
+ step.events.push({
256
+ event: WORKFLOW_INSTANCE_CANCELLED as EventToken<unknown>,
257
+ payload: { instanceId: step.instance.id, processKey: step.instance.processKey, partition: step.instance.partition, reason, actor, request },
258
+ });
248
259
  for (const token of open) {
249
260
  token.status = "cancelled";
250
261
  token.endedAt = this.now();
@@ -493,21 +504,12 @@ export class WorkflowEngine {
493
504
  });
494
505
  }
495
506
 
496
- /**
497
- * Events go out after the commit, so a handler never sees a step that rolled
498
- * back. A failing handler is logged: the step is already saved.
499
- */
507
+ /** Events go out after the commit, so a handler never sees a step that rolled back. */
500
508
  private publishAfter(transaction: { afterCommit(callback: () => void | Promise<void>): void }, step: Step): void {
501
509
  if (step.events.length === 0) return;
502
510
  const events = [...step.events];
503
511
  transaction.afterCommit(async () => {
504
- for (const { event, payload } of events) {
505
- try {
506
- await this.events.publishScoped(this.scope, event, payload);
507
- } catch (error) {
508
- this.logger?.error("workflow event handler failed", { event: event.name, instance: step.instance.id, error: error instanceof Error ? error.message : String(error) });
509
- }
510
- }
512
+ for (const { event, payload } of events) await this.events.publish(event, payload);
511
513
  });
512
514
  }
513
515
  }
@@ -1,10 +1,11 @@
1
+ import type { AccessFilter } from "../../../access";
1
2
  import type { FormIssue } from "../../../../library/workflow";
2
- import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
3
+ import type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging } from "../../contracts/types";
3
4
  import type { HistoryType } from "../model/WfHistory.model";
4
5
  import type { InstanceStatus, StartedVia } from "../model/WfInstance.model";
5
6
  import type { TokenStatus } from "../model/WfToken.model";
6
7
 
7
- export interface StartInstance {
8
+ export interface StartInstance extends WorkflowAuditOptions {
8
9
  readonly input?: unknown;
9
10
  /** The application's scope: a space, an organization. */
10
11
  readonly partition?: string | null;
@@ -14,6 +15,15 @@ export interface StartInstance {
14
15
  readonly actor: WorkflowActor;
15
16
  }
16
17
 
18
+ export interface CancelInstance extends WorkflowAuditOptions {
19
+ readonly reason?: string | null;
20
+ readonly actor: WorkflowActor;
21
+ }
22
+
23
+ export interface RetryInstance extends WorkflowAuditOptions {
24
+ readonly actor: WorkflowActor;
25
+ }
26
+
17
27
  export interface InstanceSummary {
18
28
  readonly id: string;
19
29
  readonly processKey: string;
@@ -56,7 +66,7 @@ export interface HistoryEntry {
56
66
  readonly at: Date;
57
67
  }
58
68
 
59
- export interface EmitOptions {
69
+ export interface EmitOptions extends WorkflowAuditOptions {
60
70
  /** Which waits it is for: `order:42`. Waits without a correlation take any event of the key. */
61
71
  readonly correlation?: string | null;
62
72
  /** Only the waits of this partition; it is also the partition of instances the event starts. */
@@ -75,6 +85,8 @@ export interface EmitResult {
75
85
  }
76
86
 
77
87
  export interface InstanceListQuery extends WorkflowPaging {
88
+ /** Which instances the caller may see, from `IAccess`; without it the calling code has decided. */
89
+ readonly access?: AccessFilter;
78
90
  readonly partition?: string;
79
91
  readonly status?: InstanceStatus;
80
92
  readonly processKey?: string;
@@ -89,9 +101,9 @@ export interface InstanceListQuery extends WorkflowPaging {
89
101
  export abstract class IWorkflowRuntime {
90
102
  /** Starts the latest version of a process from its manual start. */
91
103
  abstract start(processKey: string, options: StartInstance): Promise<InstanceDetails>;
92
- abstract cancel(instanceId: string, options: { readonly reason?: string | null; readonly actor: WorkflowActor }): Promise<InstanceDetails>;
104
+ abstract cancel(instanceId: string, options: CancelInstance): Promise<InstanceDetails>;
93
105
  /** Retries the failed steps of a `failed` instance. */
94
- abstract retry(instanceId: string, options: { readonly actor: WorkflowActor }): Promise<InstanceDetails>;
106
+ abstract retry(instanceId: string, options: RetryInstance): Promise<InstanceDetails>;
95
107
  /** Finishes a user task's wait with the form output and moves on; `wrong_status` when the token no longer waits. */
96
108
  abstract completeToken(tokenId: string, output: unknown, actor: WorkflowActor): Promise<InstanceDetails>;
97
109
  /**
@@ -1,5 +1,7 @@
1
+ import { grantsToWhere } from "../../../access";
2
+ import { paginate } from "../../../orm";
1
3
  import type { FormIssue, FormSpec } from "../../../../library/workflow";
2
- import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
4
+ import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
3
5
  import { WorkflowError } from "../../contracts/WorkflowError";
4
6
  import type { ServiceProvider } from "../../../di";
5
7
  import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
@@ -11,12 +13,14 @@ import type { WfInstance } from "../model/WfInstance.model";
11
13
  import type { WfToken } from "../model/WfToken.model";
12
14
  import {
13
15
  IWorkflowRuntime,
16
+ type CancelInstance,
14
17
  type EmitOptions,
15
18
  type EmitResult,
16
19
  type HistoryEntry,
17
20
  type InstanceDetails,
18
21
  type InstanceListQuery,
19
22
  type InstanceSummary,
23
+ type RetryInstance,
20
24
  type StartInstance,
21
25
  } from "./IWorkflowRuntime.service";
22
26
 
@@ -43,29 +47,29 @@ export class WorkflowRuntime extends IWorkflowRuntime {
43
47
  if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
44
48
  const { value, issues } = await this.validator().validate(version.document.input, options.input, { purpose: "input", processKey, partition });
45
49
  if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
46
- const id = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor });
50
+ const id = await this.engine.begin(this.engine.cache(version), start[0], { input: value, partition, businessKey, startedVia: "manual", actor: options.actor, request: options.request ?? null });
47
51
  return await this.instance(id);
48
52
  }
49
53
 
50
- async cancel(instanceId: string, options: { readonly reason?: string | null; readonly actor: WorkflowActor }): Promise<InstanceDetails> {
54
+ async cancel(instanceId: string, options: CancelInstance): Promise<InstanceDetails> {
51
55
  actorOf(options.actor);
52
56
  const reason = optionalText(options.reason, "reason", 1000);
53
57
  if (!isUuid(instanceId)) throw notFound(instanceId);
54
58
  const done = await this.engine.inStep(instanceId, async (step) => {
55
59
  if (step.instance.status !== "running" && step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Instance is ${step.instance.status}`);
56
- await this.engine.cancel(step, reason, options.actor);
60
+ await this.engine.cancel(step, reason, options.actor, options.request ?? null);
57
61
  return true;
58
62
  });
59
63
  if (done === null) throw notFound(instanceId);
60
64
  return await this.instance(instanceId);
61
65
  }
62
66
 
63
- async retry(instanceId: string, options: { readonly actor: WorkflowActor }): Promise<InstanceDetails> {
67
+ async retry(instanceId: string, options: RetryInstance): Promise<InstanceDetails> {
64
68
  actorOf(options.actor);
65
69
  if (!isUuid(instanceId)) throw notFound(instanceId);
66
70
  const done = await this.engine.inStep(instanceId, async (step) => {
67
71
  if (step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Only a failed instance is retried; this one is ${step.instance.status}`);
68
- await this.engine.retry(step, options.actor);
72
+ await this.engine.retry(step, options.actor, options.request ?? null);
69
73
  return true;
70
74
  });
71
75
  if (done === null) throw notFound(instanceId);
@@ -125,24 +129,25 @@ export class WorkflowRuntime extends IWorkflowRuntime {
125
129
  rejected.push({ processKey: version.key, issues });
126
130
  continue;
127
131
  }
128
- started.push(await this.engine.begin(this.engine.cache(version), nodeId, { input: value, partition, businessKey: correlation, startedVia: "event", actor }));
132
+ started.push(await this.engine.begin(this.engine.cache(version), nodeId, { input: value, partition, businessKey: correlation, startedVia: "event", actor, request: options.request ?? null }));
129
133
  }
130
134
  }
131
135
  return { started, continued: [...continued], rejected };
132
136
  }
133
137
 
134
138
  async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
135
- const { limit, offset } = pagingOf(query);
136
139
  if (query.status !== undefined && !STATUSES.has(query.status)) throw new WorkflowError("invalid_input", "status must be running, completed, cancelled or failed");
140
+ const where = query.access === undefined ? "all" : grantsToWhere(query.access);
141
+ if (where === "none") return { items: [], total: 0 };
137
142
  let source = this.db.instances.asNoTracking();
143
+ if (where !== "all") source = source.where(() => where);
138
144
  if (query.partition !== undefined) source = source.where((i) => i.partition.eq(query.partition!));
139
145
  if (query.status !== undefined) source = source.where((i) => i.status.eq(query.status!));
140
146
  if (query.processKey !== undefined) source = source.where((i) => i.processKey.eq(query.processKey!));
141
147
  if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
142
148
  if (query.startedById !== undefined) source = source.where((i) => i.startedById.eq(query.startedById!));
143
- const total = await source.count();
144
- const rows = await source.orderByDescending((i) => i.id).skip(offset).take(limit).toList();
145
- return { items: rows.map(summaryOf), total };
149
+ const { items, total } = await paginate(source, pageQueryOf(query, { field: "id", dir: "desc" }));
150
+ return { items: items.map(summaryOf), total };
146
151
  }
147
152
 
148
153
  async instance(instanceId: string): Promise<InstanceDetails> {
@@ -1,7 +1,9 @@
1
+ import { AccessModule } from "../../access";
1
2
  import { Module, scoped } from "../../di";
2
3
  import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
3
4
  import { withEventSubscribers } from "../../kernel";
4
5
  import { WorkflowRuntimeModule } from "../runtime_module/WorkflowRuntime.module";
6
+ import { tasksPermissions } from "./access/Tasks.permissions";
5
7
  import { TaskEvents } from "./events/TaskEvents";
6
8
  import { TasksController } from "./http/Tasks.controller";
7
9
  import { TasksDbContext, tasksStore } from "./model/Tasks.dbContext";
@@ -13,7 +15,8 @@ import { WorkflowTasks } from "./services/WorkflowTasks.service";
13
15
  * Tasks for people: the inbox of `userTask` steps ([passport](MODULE.md)).
14
16
  */
15
17
  @Module({
16
- imports: [WorkflowDefinitionsModule, WorkflowRuntimeModule],
18
+ imports: [AccessModule, WorkflowDefinitionsModule, WorkflowRuntimeModule],
19
+ permissions: tasksPermissions,
17
20
  ormBazis: { context: TasksDbContext, entities: [WfTask], ownedStore: tasksStore, registerRepositories: false },
18
21
  providers: [
19
22
  scoped(IWorkflowTasks, WorkflowTasks),
@@ -0,0 +1,13 @@
1
+ import { definePermissions } from "../../../access";
2
+
3
+ /**
4
+ * The right to work on tasks (WORKFLOW-002 §6.2). Which tasks are the
5
+ * subject's is decided by their addressees, not by an owner column.
6
+ */
7
+ export const tasksPermissions = definePermissions({
8
+ "workflow.tasks.work": {
9
+ title: { en: "Work on tasks", ru: "Работать с задачами" },
10
+ scope: { column: "partition" },
11
+ on: { process: { ref: "workflow.process", column: "processKey" } },
12
+ },
13
+ });
@@ -0,0 +1,15 @@
1
+ import { createEventToken } from "../../../kernel";
2
+ import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
3
+
4
+ /** A person filled a task's form, for the application's audit. Published after it is saved. */
5
+ export interface WorkflowTaskCompleted {
6
+ readonly taskId: string;
7
+ readonly instanceId: string;
8
+ readonly processKey: string;
9
+ readonly nodeId: string;
10
+ readonly partition: string | null;
11
+ readonly actor: WorkflowActor;
12
+ readonly request: WorkflowRequest | null;
13
+ }
14
+
15
+ export const WORKFLOW_TASK_COMPLETED = createEventToken<WorkflowTaskCompleted>("workflow.task-completed");
@@ -1,67 +1,78 @@
1
1
  import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
2
- import { Controller, Get, Post, type HttpContext } from "../../../http";
3
- import type { WorkflowActor } from "../../contracts/types";
2
+ import { can } from "../../../access";
3
+ import { Authorize, Controller, Get, Post, type HttpContext } from "../../../http";
4
+ import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
4
5
  import { WorkflowError } from "../../contracts/WorkflowError";
5
- import { assigneesOf, authorize, body, optionalString, pagingQuery, param } from "../../internal/http";
6
+ import { buildListDocument } from "../../../../library/jsonapi";
7
+ import { body, callerOf, filterOf, listQuery, pagingOfList, param } from "../../internal/http";
6
8
  import type { TaskStatus } from "../model/WfTask.model";
7
9
  import { IWorkflowTasks, type TaskDetails } from "../services/IWorkflowTasks.service";
8
10
 
9
11
  /**
10
- * The caller's tasks (WORKFLOW-001 §5): those addressed to any of
11
- * `WorkflowAccess.assignees` or claimed by the caller. Another person's task
12
- * answers 404, as if it did not exist.
12
+ * The caller's tasks (WORKFLOW-002 §6.2): those addressed to any of the
13
+ * subject's `principals` in the task's partition or claimed by the caller.
14
+ * Another person's task answers 404, as if it did not exist.
13
15
  */
14
16
  @Controller("workflow/tasks")
15
17
  export class TasksController {
16
18
  constructor(private readonly tasks: IWorkflowTasks) {}
17
19
 
18
20
  @Get()
21
+ @Authorize(can("workflow.tasks.work"))
19
22
  async list(ctx: HttpContext) {
20
- const partition = optionalString(ctx.query("partition"), "partition");
21
- const actor = await authorize(ctx, "tasks.work", { partition: partition ?? null });
22
- const status = ctx.query("status");
23
+ const { access, actor } = await callerOf(ctx);
24
+ const list = listQuery(ctx, { filter: { partition: ["eq"], processKey: ["eq"], status: ["eq", "in"] }, sort: ["createdAt", "dueAt", "status", "processKey"] });
25
+ const partition = filterOf(list, "partition");
26
+ const status = filterOf(list, "status");
27
+ const statuses = status === undefined ? {} : { status: status.split(",") as TaskStatus[] };
23
28
  const page = await this.tasks.list({
24
- ...pagingQuery(ctx),
25
- assignees: await assigneesOf(ctx, actor),
29
+ ...pagingOfList(list),
30
+ ...statuses,
31
+ // Addressees are per partition and counted now, not when the task was created.
32
+ addressees: await access.principals(partition === undefined ? await this.tasks.partitions(statuses) : [partition]),
26
33
  actor,
27
- ...(status === undefined ? {} : { status: status.split(",") as TaskStatus[] }),
34
+ access: await access.filter("workflow.tasks.work"),
28
35
  partition,
29
- processKey: optionalString(ctx.query("processKey"), "processKey"),
36
+ processKey: filterOf(list, "processKey"),
30
37
  });
31
- return { data: page.items, meta: { total: page.total } };
38
+ return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
32
39
  }
33
40
 
34
41
  @Get(":id")
42
+ @Authorize(can("workflow.tasks.work"))
35
43
  async get(ctx: HttpContext) {
36
44
  return (await this.visible(ctx)).task;
37
45
  }
38
46
 
39
47
  @Post(":id/claim")
48
+ @Authorize(can("workflow.tasks.work"))
40
49
  async claim(ctx: HttpContext) {
41
50
  const { task, actor } = await this.visible(ctx);
42
51
  return await this.tasks.claim(task.id, actor);
43
52
  }
44
53
 
45
54
  @Post(":id/release")
55
+ @Authorize(can("workflow.tasks.work"))
46
56
  async release(ctx: HttpContext) {
47
57
  const { task, actor } = await this.visible(ctx);
48
58
  return await this.tasks.release(task.id, actor);
49
59
  }
50
60
 
51
61
  @Post(":id/complete")
62
+ @Authorize(can("workflow.tasks.work"))
52
63
  async complete(ctx: HttpContext) {
53
- const { task, actor } = await this.visible(ctx);
64
+ const { task, actor, request: from } = await this.visible(ctx);
54
65
  const request = await body(ctx);
55
- return await this.tasks.complete(task.id, request.output ?? {}, actor);
66
+ return await this.tasks.complete(task.id, request.output ?? {}, actor, { request: from });
56
67
  }
57
68
 
58
- /** The task if the caller may see it: addressed to one of their assignees or claimed by them. */
59
- private async visible(ctx: HttpContext): Promise<{ task: TaskDetails; actor: WorkflowActor }> {
69
+ /** The task if the caller may see it: addressed to one of their principals or claimed by them. */
70
+ private async visible(ctx: HttpContext): Promise<{ task: TaskDetails; actor: WorkflowActor; request: WorkflowRequest }> {
60
71
  const task = await this.tasks.get(param(ctx, "id"));
61
- const actor = await authorize(ctx, "tasks.work", { processKey: task.processKey, partition: task.partition, instanceId: task.instanceId, taskId: task.id });
62
- const assignees = await assigneesOf(ctx, actor);
63
- if (!assignees.includes(task.assignee) && task.claimedBy !== actor.id) throw new WorkflowError("not_found", `Task ${task.id} does not exist`);
64
- return { task, actor };
72
+ const { access, actor, request } = await callerOf(ctx, "workflow.tasks.work", { scope: task.partition, process: task.processKey });
73
+ const addressees = await access.principals([task.partition]);
74
+ if (!addressees.some(({ principals }) => principals.includes(task.assignee)) && task.claimedBy !== actor.id) throw new WorkflowError("not_found", `Task ${task.id} does not exist`);
75
+ return { task, actor, request };
65
76
  }
66
77
  }
67
78
  contextOnlyController(TasksController);
@@ -1,5 +1,6 @@
1
1
  import type { FormSpec } from "../../../../library/workflow";
2
- import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
2
+ import type { AccessFilter, ScopePrincipals } from "../../../access";
3
+ import type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging } from "../../contracts/types";
3
4
  import type { TaskStatus } from "../model/WfTask.model";
4
5
 
5
6
  export interface TaskDetails {
@@ -23,10 +24,12 @@ export interface TaskDetails {
23
24
  }
24
25
 
25
26
  export interface TaskListQuery extends WorkflowPaging {
26
- /** Tasks addressed to any of these (`role:support`, `user:42`), plus those the actor claimed. */
27
- readonly assignees: readonly string[];
27
+ /** Tasks addressed to these (`role:support`, `user:42`) in their partition, plus those the actor claimed. */
28
+ readonly addressees: readonly ScopePrincipals[];
28
29
  /** Whose claimed tasks count as theirs. */
29
30
  readonly actor?: WorkflowActor;
31
+ /** Which tasks the caller may see, from `IAccess`; without it the calling code has decided. */
32
+ readonly access?: AccessFilter;
30
33
  /** `open` and `claimed` by default. */
31
34
  readonly status?: readonly TaskStatus[];
32
35
  readonly partition?: string;
@@ -40,13 +43,15 @@ export interface TaskListQuery extends WorkflowPaging {
40
43
  * by another person), `wrong_status`, `invalid_input` with the form issues.
41
44
  */
42
45
  export abstract class IWorkflowTasks {
43
- /** The tasks of these assignees, oldest first. */
46
+ /** The tasks of these addressees, oldest first. */
44
47
  abstract list(query: TaskListQuery): Promise<WorkflowPage<TaskDetails>>;
48
+ /** The partitions that have tasks in these statuses (`open` and `claimed` by default): where to ask for addressees. */
49
+ abstract partitions(query?: { readonly status?: readonly TaskStatus[] }): Promise<readonly (string | null)[]>;
45
50
  abstract get(taskId: string): Promise<TaskDetails>;
46
51
  /** Takes an open task: nobody else can complete it until it is released. */
47
52
  abstract claim(taskId: string, actor: WorkflowActor): Promise<TaskDetails>;
48
53
  /** Gives a claimed task back. */
49
54
  abstract release(taskId: string, actor: WorkflowActor): Promise<TaskDetails>;
50
55
  /** Fills the form: the process moves on. */
51
- abstract complete(taskId: string, output: unknown, actor: WorkflowActor): Promise<TaskDetails>;
56
+ abstract complete(taskId: string, output: unknown, actor: WorkflowActor, options?: WorkflowAuditOptions): Promise<TaskDetails>;
52
57
  }
@@ -1,7 +1,11 @@
1
- import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
1
+ import { grantsToWhere, type ScopePrincipals } from "../../../access";
2
+ import { paginate } from "../../../orm";
3
+ import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowAuditOptions, type WorkflowPage } from "../../contracts/types";
2
4
  import type { WorkflowClock } from "../../contracts/WorkflowClock";
3
5
  import { WorkflowError } from "../../contracts/WorkflowError";
6
+ import type { WorkflowEvents } from "../../internal/WorkflowEvents";
4
7
  import type { IWorkflowRuntime } from "../../runtime_module/services/IWorkflowRuntime.service";
8
+ import { WORKFLOW_TASK_COMPLETED } from "../events/Tasks.events";
5
9
  import type { TasksDbContext } from "../model/Tasks.dbContext";
6
10
  import { WfTask, type TaskStatus } from "../model/WfTask.model";
7
11
  import { IWorkflowTasks, type TaskDetails, type TaskListQuery } from "./IWorkflowTasks.service";
@@ -13,28 +17,38 @@ export class WorkflowTasks extends IWorkflowTasks {
13
17
  private readonly db: TasksDbContext,
14
18
  private readonly runtime: IWorkflowRuntime,
15
19
  private readonly clock: WorkflowClock,
20
+ private readonly events: WorkflowEvents,
16
21
  ) {
17
22
  super();
18
23
  }
19
24
 
20
25
  async list(query: TaskListQuery): Promise<WorkflowPage<TaskDetails>> {
21
- const { limit, offset } = pagingOf(query);
22
- const statuses = [...(query.status ?? ["open", "claimed"])];
23
- if (statuses.length === 0 || statuses.some((status) => !STATUSES.has(status))) throw new WorkflowError("invalid_input", "status must be open, claimed, completed or cancelled");
24
- const assignees = [...query.assignees];
26
+ const statuses = statusesOf(query.status);
27
+ const groups = groupByPrincipals(query.addressees);
25
28
  const mine = query.actor?.id;
26
- if (assignees.length === 0 && mine === undefined) return { items: [], total: 0 };
29
+ const where = query.access === undefined ? "all" : grantsToWhere(query.access);
30
+ if (where === "none" || (groups.length === 0 && mine === undefined)) return { items: [], total: 0 };
27
31
  let source = this.db.tasks.asNoTracking().where((t) => t.status.in(statuses));
28
- source = mine === undefined
29
- ? source.where((t) => t.assignee.in(assignees))
30
- : assignees.length === 0
31
- ? source.where((t) => t.claimedById.eq(mine))
32
- : source.where((t) => t.assignee.in(assignees).or(t.claimedById.eq(mine)));
32
+ if (where !== "all") source = source.where(() => where);
33
+ source = source.where((t) => {
34
+ const theirs = groups.map(({ scopes, principals }) => {
35
+ const named = scopes.filter((scope): scope is string => scope !== null);
36
+ const partition = !scopes.includes(null) ? t.partition.in(named) : named.length === 0 ? t.partition.isNull() : t.partition.isNull().or(t.partition.in(named));
37
+ return t.assignee.in(principals).and(partition);
38
+ });
39
+ if (mine !== undefined) theirs.push(t.claimedById.eq(mine));
40
+ return theirs.reduce((left, right) => left.or(right));
41
+ });
33
42
  if (query.partition !== undefined) source = source.where((t) => t.partition.eq(query.partition!));
34
43
  if (query.processKey !== undefined) source = source.where((t) => t.processKey.eq(query.processKey!));
35
- const total = await source.count();
36
- const rows = await source.orderBy((t) => t.id).skip(offset).take(limit).toList();
37
- return { items: rows.map(detailsOf), total };
44
+ const { items, total } = await paginate(source, pageQueryOf(query, { field: "id", dir: "asc" }));
45
+ return { items: items.map(detailsOf), total };
46
+ }
47
+
48
+ async partitions(query: { readonly status?: readonly TaskStatus[] } = {}): Promise<readonly (string | null)[]> {
49
+ const statuses = statusesOf(query.status);
50
+ const rows = await this.db.database.querySqlRaw(`SELECT DISTINCT "partition" FROM "bazis_wf_task_items" WHERE "status" IN (${statuses.map((_, index) => `{${index}}`).join(", ")})`, ...statuses);
51
+ return rows.map((row) => row.partition as string | null);
38
52
  }
39
53
 
40
54
  async get(taskId: string): Promise<TaskDetails> {
@@ -67,7 +81,7 @@ export class WorkflowTasks extends IWorkflowTasks {
67
81
  });
68
82
  }
69
83
 
70
- async complete(taskId: string, output: unknown, actor: WorkflowActor): Promise<TaskDetails> {
84
+ async complete(taskId: string, output: unknown, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<TaskDetails> {
71
85
  const task = await this.task(taskId, false);
72
86
  if (task.status === "claimed" && task.claimedById !== actor.id) throw new WorkflowError("conflict", "The task is taken by someone else");
73
87
  if (task.status !== "open" && task.status !== "claimed") throw new WorkflowError("wrong_status", `The task is ${task.status}`);
@@ -86,6 +100,7 @@ export class WorkflowTasks extends IWorkflowTasks {
86
100
  completedAt: this.clock.now(),
87
101
  output: typeof saved === "object" && saved !== null && !Array.isArray(saved) ? saved as Record<string, unknown> : null,
88
102
  });
103
+ await this.events.publish(WORKFLOW_TASK_COMPLETED, { taskId: task.id, instanceId: task.instanceId, processKey: task.processKey, nodeId: task.nodeId, partition: task.partition, actor, request: options.request ?? null });
89
104
  return await this.get(task.id);
90
105
  }
91
106
 
@@ -109,6 +124,26 @@ export class WorkflowTasks extends IWorkflowTasks {
109
124
  }
110
125
  }
111
126
 
127
+ function statusesOf(status: readonly TaskStatus[] | undefined): TaskStatus[] {
128
+ const statuses = [...(status ?? ["open", "claimed"] as const)];
129
+ if (statuses.length === 0 || statuses.some((item) => !STATUSES.has(item))) throw new WorkflowError("invalid_input", "status must be open, claimed, completed or cancelled");
130
+ return statuses;
131
+ }
132
+
133
+ /** Partitions with the same addressees go together: usually all of them, so the query stays short. */
134
+ function groupByPrincipals(addressees: readonly ScopePrincipals[]): { scopes: (string | null)[]; principals: string[] }[] {
135
+ const groups = new Map<string, { scopes: (string | null)[]; principals: string[] }>();
136
+ for (const { scope, principals } of addressees) {
137
+ if (principals.length === 0) continue;
138
+ const list = [...new Set(principals)].sort();
139
+ const key = JSON.stringify(list);
140
+ const group = groups.get(key);
141
+ if (group === undefined) groups.set(key, { scopes: [scope], principals: list });
142
+ else group.scopes.push(scope);
143
+ }
144
+ return [...groups.values()];
145
+ }
146
+
112
147
  function notFound(taskId: string): WorkflowError {
113
148
  return new WorkflowError("not_found", `Task ${taskId} does not exist`);
114
149
  }
@@ -686,6 +686,15 @@ function applyValidatorOptions(schema: OpenApiCodegenSchema, options: Record<str
686
686
  out.type = "string";
687
687
  out.pattern = options.pattern;
688
688
  }
689
+ if (typeof options.ref === "string") {
690
+ out["x-ref"] = options.ref;
691
+ }
692
+ if (typeof options.refKeys === "string") {
693
+ out["x-ref-keys"] = options.refKeys;
694
+ }
695
+ if (typeof options.refScope === "string") {
696
+ out["x-ref-scope"] = options.refScope;
697
+ }
689
698
  return out;
690
699
  }
691
700
 
@@ -150,4 +150,14 @@ export interface ValidatorOptions {
150
150
  * enums are ignored).
151
151
  */
152
152
  enumType?: Record<string, string | number>;
153
+
154
+ // ── Schema ───────────────────────────────────────────────────────────────
155
+ // Literals that go to the generated JSON Schema; they check nothing here.
156
+
157
+ /** The value is a reference of this kind (`x-ref`): `dm.table`. */
158
+ ref?: string;
159
+ /** The keys of the object are references of this kind (`x-ref-keys`): fields of a table. */
160
+ refKeys?: string;
161
+ /** JSON Pointer to the sibling input that sets their area (`x-ref-scope`): `/table`. */
162
+ refScope?: `/${string}`;
153
163
  }
@@ -19,7 +19,8 @@ export interface FormSpec {
19
19
  /** One problem of a value: which field and why. */
20
20
  export interface FormIssue {
21
21
  readonly field: string;
22
- readonly code: "required" | "type" | "enum" | "unknown";
22
+ /** Built in: `required`, `type`, `enum`, `unknown`; an application's validator adds its own. */
23
+ readonly code: string;
23
24
  readonly message: string;
24
25
  }
25
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.129.0",
3
+ "version": "0.131.0",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",