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
@@ -1,12 +1,15 @@
1
1
  import { IAccess, type AccessObject, type SubjectAccess } from "../../access";
2
2
  import { ForbiddenError, type HttpContext } from "../../http";
3
- import type { WorkflowActor } from "../contracts/types";
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
8
  /** Who calls a route: their rights and how they are recorded as an actor. */
7
9
  export interface WorkflowCaller {
8
10
  readonly access: SubjectAccess;
9
11
  readonly actor: WorkflowActor;
12
+ readonly request: WorkflowRequest;
10
13
  }
11
14
 
12
15
  /**
@@ -20,7 +23,7 @@ export async function callerOf(ctx: HttpContext, permission?: string, object?: A
20
23
  if (permission !== undefined && !await access.can(permission, object)) throw new ForbiddenError();
21
24
  const sub = access.subject.sub;
22
25
  const [id = sub.slice(sub.lastIndexOf(":") + 1)] = await access.ownerIds();
23
- return { access, actor: { kind: "user", id } };
26
+ return { access, actor: { kind: "user", id }, request: { ip: ctx.clientIp ?? null, userAgent: ctx.header("user-agent") ?? null } };
24
27
  }
25
28
 
26
29
  /** A route parameter as a string. */
@@ -64,7 +67,33 @@ export function optionalInteger(value: unknown, name: string): number | undefine
64
67
  return number as number;
65
68
  }
66
69
 
67
- /** `limit` and `offset` of a list query. */
68
- export function pagingQuery(ctx: HttpContext): { limit?: number; offset?: number } {
69
- 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;
70
99
  }
@@ -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");
@@ -15,11 +15,12 @@ export class EventsController {
15
15
  const eventKey = param(ctx, "eventKey");
16
16
  const request = await body(ctx);
17
17
  const partition = optionalString(request.partition, "partition") ?? null;
18
- const { actor } = await callerOf(ctx, "workflow.events.emit", { scope: partition, event: eventKey });
18
+ const { actor, request: from } = await callerOf(ctx, "workflow.events.emit", { scope: partition, event: eventKey });
19
19
  const result = await this.runtime.emit(eventKey, request.payload ?? {}, {
20
20
  correlation: optionalString(request.correlation, "correlation") ?? null,
21
21
  partition,
22
22
  actor,
23
+ request: from,
23
24
  });
24
25
  ctx.res.status(202);
25
26
  return result;
@@ -1,7 +1,8 @@
1
1
  import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
2
2
  import { can, type AccessObject } from "../../../access";
3
3
  import { Authorize, Controller, Created, Get, Post, type HttpContext } from "../../../http";
4
- import { body, callerOf, optionalString, pagingQuery, param, requiredString } from "../../internal/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
7
  import { IWorkflowRuntime, type InstanceSummary } from "../services/IWorkflowRuntime.service";
7
8
 
@@ -16,12 +17,13 @@ export class InstancesController {
16
17
  const request = await body(ctx);
17
18
  const processKey = requiredString(request.processKey, "processKey", 64);
18
19
  const partition = optionalString(request.partition, "partition") ?? null;
19
- const { actor } = await callerOf(ctx, "workflow.instances.start", { scope: partition, process: processKey });
20
+ const { actor, request: from } = await callerOf(ctx, "workflow.instances.start", { scope: partition, process: processKey });
20
21
  const instance = await this.runtime.start(processKey, {
21
22
  input: request.input,
22
23
  partition,
23
24
  businessKey: optionalString(request.businessKey, "businessKey") ?? null,
24
25
  actor,
26
+ request: from,
25
27
  });
26
28
  return Created(`${ctx.path}/${instance.id}`, instance);
27
29
  }
@@ -30,16 +32,20 @@ export class InstancesController {
30
32
  @Authorize(can("workflow.instances.read"))
31
33
  async list(ctx: HttpContext) {
32
34
  const { access } = await callerOf(ctx);
35
+ const list = listQuery(ctx, {
36
+ filter: { partition: ["eq"], processKey: ["eq"], status: ["eq"], businessKey: ["eq"], startedById: ["eq"] },
37
+ sort: ["createdAt", "endedAt", "status", "processKey"],
38
+ });
33
39
  const page = await this.runtime.instances({
34
- ...pagingQuery(ctx),
40
+ ...pagingOfList(list),
35
41
  access: await access.filter("workflow.instances.read"),
36
- partition: optionalString(ctx.query("partition"), "partition"),
37
- processKey: optionalString(ctx.query("processKey"), "processKey"),
38
- status: optionalString(ctx.query("status"), "status") as InstanceStatus | undefined,
39
- businessKey: optionalString(ctx.query("businessKey"), "businessKey"),
40
- startedById: optionalString(ctx.query("startedById"), "startedById"),
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"),
41
47
  });
42
- return { data: page.items, meta: { total: page.total } };
48
+ return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
43
49
  }
44
50
 
45
51
  @Get(":id")
@@ -63,16 +69,16 @@ export class InstancesController {
63
69
  async cancel(ctx: HttpContext) {
64
70
  const instance = await this.runtime.instance(param(ctx, "id"));
65
71
  const request = await body(ctx);
66
- const { actor } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
67
- 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 });
68
74
  }
69
75
 
70
76
  @Post(":id/retry")
71
77
  @Authorize(can("workflow.instances.manage"))
72
78
  async retry(ctx: HttpContext) {
73
79
  const instance = await this.runtime.instance(param(ctx, "id"));
74
- const { actor } = await callerOf(ctx, "workflow.instances.manage", objectOf(instance));
75
- 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 });
76
82
  }
77
83
  }
78
84
  contextOnlyController(InstancesController);
@@ -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
 
@@ -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,11 +1,11 @@
1
1
  import type { AccessFilter } from "../../../access";
2
2
  import type { FormIssue } from "../../../../library/workflow";
3
- import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
3
+ import type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging } from "../../contracts/types";
4
4
  import type { HistoryType } from "../model/WfHistory.model";
5
5
  import type { InstanceStatus, StartedVia } from "../model/WfInstance.model";
6
6
  import type { TokenStatus } from "../model/WfToken.model";
7
7
 
8
- export interface StartInstance {
8
+ export interface StartInstance extends WorkflowAuditOptions {
9
9
  readonly input?: unknown;
10
10
  /** The application's scope: a space, an organization. */
11
11
  readonly partition?: string | null;
@@ -15,6 +15,15 @@ export interface StartInstance {
15
15
  readonly actor: WorkflowActor;
16
16
  }
17
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
+
18
27
  export interface InstanceSummary {
19
28
  readonly id: string;
20
29
  readonly processKey: string;
@@ -57,7 +66,7 @@ export interface HistoryEntry {
57
66
  readonly at: Date;
58
67
  }
59
68
 
60
- export interface EmitOptions {
69
+ export interface EmitOptions extends WorkflowAuditOptions {
61
70
  /** Which waits it is for: `order:42`. Waits without a correlation take any event of the key. */
62
71
  readonly correlation?: string | null;
63
72
  /** Only the waits of this partition; it is also the partition of instances the event starts. */
@@ -92,9 +101,9 @@ export interface InstanceListQuery extends WorkflowPaging {
92
101
  export abstract class IWorkflowRuntime {
93
102
  /** Starts the latest version of a process from its manual start. */
94
103
  abstract start(processKey: string, options: StartInstance): Promise<InstanceDetails>;
95
- abstract cancel(instanceId: string, options: { readonly reason?: string | null; readonly actor: WorkflowActor }): Promise<InstanceDetails>;
104
+ abstract cancel(instanceId: string, options: CancelInstance): Promise<InstanceDetails>;
96
105
  /** Retries the failed steps of a `failed` instance. */
97
- abstract retry(instanceId: string, options: { readonly actor: WorkflowActor }): Promise<InstanceDetails>;
106
+ abstract retry(instanceId: string, options: RetryInstance): Promise<InstanceDetails>;
98
107
  /** Finishes a user task's wait with the form output and moves on; `wrong_status` when the token no longer waits. */
99
108
  abstract completeToken(tokenId: string, output: unknown, actor: WorkflowActor): Promise<InstanceDetails>;
100
109
  /**
@@ -1,6 +1,7 @@
1
1
  import { grantsToWhere } from "../../../access";
2
+ import { paginate } from "../../../orm";
2
3
  import type { FormIssue, FormSpec } from "../../../../library/workflow";
3
- import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
4
+ import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
4
5
  import { WorkflowError } from "../../contracts/WorkflowError";
5
6
  import type { ServiceProvider } from "../../../di";
6
7
  import { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
@@ -12,12 +13,14 @@ import type { WfInstance } from "../model/WfInstance.model";
12
13
  import type { WfToken } from "../model/WfToken.model";
13
14
  import {
14
15
  IWorkflowRuntime,
16
+ type CancelInstance,
15
17
  type EmitOptions,
16
18
  type EmitResult,
17
19
  type HistoryEntry,
18
20
  type InstanceDetails,
19
21
  type InstanceListQuery,
20
22
  type InstanceSummary,
23
+ type RetryInstance,
21
24
  type StartInstance,
22
25
  } from "./IWorkflowRuntime.service";
23
26
 
@@ -44,29 +47,29 @@ export class WorkflowRuntime extends IWorkflowRuntime {
44
47
  if (!start) throw new WorkflowError("wrong_status", `Process ${processKey} has no manual start`);
45
48
  const { value, issues } = await this.validator().validate(version.document.input, options.input, { purpose: "input", processKey, partition });
46
49
  if (issues.length > 0) throw new WorkflowError("invalid_input", "The process input is invalid", issues);
47
- 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 });
48
51
  return await this.instance(id);
49
52
  }
50
53
 
51
- async cancel(instanceId: string, options: { readonly reason?: string | null; readonly actor: WorkflowActor }): Promise<InstanceDetails> {
54
+ async cancel(instanceId: string, options: CancelInstance): Promise<InstanceDetails> {
52
55
  actorOf(options.actor);
53
56
  const reason = optionalText(options.reason, "reason", 1000);
54
57
  if (!isUuid(instanceId)) throw notFound(instanceId);
55
58
  const done = await this.engine.inStep(instanceId, async (step) => {
56
59
  if (step.instance.status !== "running" && step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Instance is ${step.instance.status}`);
57
- await this.engine.cancel(step, reason, options.actor);
60
+ await this.engine.cancel(step, reason, options.actor, options.request ?? null);
58
61
  return true;
59
62
  });
60
63
  if (done === null) throw notFound(instanceId);
61
64
  return await this.instance(instanceId);
62
65
  }
63
66
 
64
- async retry(instanceId: string, options: { readonly actor: WorkflowActor }): Promise<InstanceDetails> {
67
+ async retry(instanceId: string, options: RetryInstance): Promise<InstanceDetails> {
65
68
  actorOf(options.actor);
66
69
  if (!isUuid(instanceId)) throw notFound(instanceId);
67
70
  const done = await this.engine.inStep(instanceId, async (step) => {
68
71
  if (step.instance.status !== "failed") throw new WorkflowError("wrong_status", `Only a failed instance is retried; this one is ${step.instance.status}`);
69
- await this.engine.retry(step, options.actor);
72
+ await this.engine.retry(step, options.actor, options.request ?? null);
70
73
  return true;
71
74
  });
72
75
  if (done === null) throw notFound(instanceId);
@@ -126,14 +129,13 @@ export class WorkflowRuntime extends IWorkflowRuntime {
126
129
  rejected.push({ processKey: version.key, issues });
127
130
  continue;
128
131
  }
129
- 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 }));
130
133
  }
131
134
  }
132
135
  return { started, continued: [...continued], rejected };
133
136
  }
134
137
 
135
138
  async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
136
- const { limit, offset } = pagingOf(query);
137
139
  if (query.status !== undefined && !STATUSES.has(query.status)) throw new WorkflowError("invalid_input", "status must be running, completed, cancelled or failed");
138
140
  const where = query.access === undefined ? "all" : grantsToWhere(query.access);
139
141
  if (where === "none") return { items: [], total: 0 };
@@ -144,9 +146,8 @@ export class WorkflowRuntime extends IWorkflowRuntime {
144
146
  if (query.processKey !== undefined) source = source.where((i) => i.processKey.eq(query.processKey!));
145
147
  if (query.businessKey !== undefined) source = source.where((i) => i.businessKey.eq(query.businessKey!));
146
148
  if (query.startedById !== undefined) source = source.where((i) => i.startedById.eq(query.startedById!));
147
- const total = await source.count();
148
- const rows = await source.orderByDescending((i) => i.id).skip(offset).take(limit).toList();
149
- 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 };
150
151
  }
151
152
 
152
153
  async instance(instanceId: string): Promise<InstanceDetails> {
@@ -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,9 +1,10 @@
1
1
  import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
2
2
  import { can } from "../../../access";
3
3
  import { Authorize, Controller, Get, Post, type HttpContext } from "../../../http";
4
- import type { WorkflowActor } from "../../contracts/types";
4
+ import type { WorkflowActor, WorkflowRequest } from "../../contracts/types";
5
5
  import { WorkflowError } from "../../contracts/WorkflowError";
6
- import { body, callerOf, optionalString, pagingQuery, param } from "../../internal/http";
6
+ import { buildListDocument } from "../../../../library/jsonapi";
7
+ import { body, callerOf, filterOf, listQuery, pagingOfList, param } from "../../internal/http";
7
8
  import type { TaskStatus } from "../model/WfTask.model";
8
9
  import { IWorkflowTasks, type TaskDetails } from "../services/IWorkflowTasks.service";
9
10
 
@@ -19,21 +20,22 @@ export class TasksController {
19
20
  @Get()
20
21
  @Authorize(can("workflow.tasks.work"))
21
22
  async list(ctx: HttpContext) {
22
- const partition = optionalString(ctx.query("partition"), "partition");
23
23
  const { access, actor } = await callerOf(ctx);
24
- const status = ctx.query("status");
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");
25
27
  const statuses = status === undefined ? {} : { status: status.split(",") as TaskStatus[] };
26
28
  const page = await this.tasks.list({
27
- ...pagingQuery(ctx),
29
+ ...pagingOfList(list),
28
30
  ...statuses,
29
31
  // Addressees are per partition and counted now, not when the task was created.
30
32
  addressees: await access.principals(partition === undefined ? await this.tasks.partitions(statuses) : [partition]),
31
33
  actor,
32
34
  access: await access.filter("workflow.tasks.work"),
33
35
  partition,
34
- processKey: optionalString(ctx.query("processKey"), "processKey"),
36
+ processKey: filterOf(list, "processKey"),
35
37
  });
36
- return { data: page.items, meta: { total: page.total } };
38
+ return buildListDocument(page.items, list, page.total, { basePath: ctx.path });
37
39
  }
38
40
 
39
41
  @Get(":id")
@@ -59,18 +61,18 @@ export class TasksController {
59
61
  @Post(":id/complete")
60
62
  @Authorize(can("workflow.tasks.work"))
61
63
  async complete(ctx: HttpContext) {
62
- const { task, actor } = await this.visible(ctx);
64
+ const { task, actor, request: from } = await this.visible(ctx);
63
65
  const request = await body(ctx);
64
- return await this.tasks.complete(task.id, request.output ?? {}, actor);
66
+ return await this.tasks.complete(task.id, request.output ?? {}, actor, { request: from });
65
67
  }
66
68
 
67
69
  /** The task if the caller may see it: addressed to one of their principals or claimed by them. */
68
- private async visible(ctx: HttpContext): Promise<{ task: TaskDetails; actor: WorkflowActor }> {
70
+ private async visible(ctx: HttpContext): Promise<{ task: TaskDetails; actor: WorkflowActor; request: WorkflowRequest }> {
69
71
  const task = await this.tasks.get(param(ctx, "id"));
70
- const { access, actor } = await callerOf(ctx, "workflow.tasks.work", { scope: task.partition, process: task.processKey });
72
+ const { access, actor, request } = await callerOf(ctx, "workflow.tasks.work", { scope: task.partition, process: task.processKey });
71
73
  const addressees = await access.principals([task.partition]);
72
74
  if (!addressees.some(({ principals }) => principals.includes(task.assignee)) && task.claimedBy !== actor.id) throw new WorkflowError("not_found", `Task ${task.id} does not exist`);
73
- return { task, actor };
75
+ return { task, actor, request };
74
76
  }
75
77
  }
76
78
  contextOnlyController(TasksController);
@@ -1,6 +1,6 @@
1
1
  import type { FormSpec } from "../../../../library/workflow";
2
2
  import type { AccessFilter, ScopePrincipals } from "../../../access";
3
- import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
3
+ import type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging } from "../../contracts/types";
4
4
  import type { TaskStatus } from "../model/WfTask.model";
5
5
 
6
6
  export interface TaskDetails {
@@ -53,5 +53,5 @@ export abstract class IWorkflowTasks {
53
53
  /** Gives a claimed task back. */
54
54
  abstract release(taskId: string, actor: WorkflowActor): Promise<TaskDetails>;
55
55
  /** Fills the form: the process moves on. */
56
- abstract complete(taskId: string, output: unknown, actor: WorkflowActor): Promise<TaskDetails>;
56
+ abstract complete(taskId: string, output: unknown, actor: WorkflowActor, options?: WorkflowAuditOptions): Promise<TaskDetails>;
57
57
  }
@@ -1,8 +1,11 @@
1
1
  import { grantsToWhere, type ScopePrincipals } from "../../../access";
2
- import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
2
+ import { paginate } from "../../../orm";
3
+ import { isUuid, pageQueryOf, type WorkflowActor, type WorkflowAuditOptions, type WorkflowPage } from "../../contracts/types";
3
4
  import type { WorkflowClock } from "../../contracts/WorkflowClock";
4
5
  import { WorkflowError } from "../../contracts/WorkflowError";
6
+ import type { WorkflowEvents } from "../../internal/WorkflowEvents";
5
7
  import type { IWorkflowRuntime } from "../../runtime_module/services/IWorkflowRuntime.service";
8
+ import { WORKFLOW_TASK_COMPLETED } from "../events/Tasks.events";
6
9
  import type { TasksDbContext } from "../model/Tasks.dbContext";
7
10
  import { WfTask, type TaskStatus } from "../model/WfTask.model";
8
11
  import { IWorkflowTasks, type TaskDetails, type TaskListQuery } from "./IWorkflowTasks.service";
@@ -14,12 +17,12 @@ export class WorkflowTasks extends IWorkflowTasks {
14
17
  private readonly db: TasksDbContext,
15
18
  private readonly runtime: IWorkflowRuntime,
16
19
  private readonly clock: WorkflowClock,
20
+ private readonly events: WorkflowEvents,
17
21
  ) {
18
22
  super();
19
23
  }
20
24
 
21
25
  async list(query: TaskListQuery): Promise<WorkflowPage<TaskDetails>> {
22
- const { limit, offset } = pagingOf(query);
23
26
  const statuses = statusesOf(query.status);
24
27
  const groups = groupByPrincipals(query.addressees);
25
28
  const mine = query.actor?.id;
@@ -38,9 +41,8 @@ export class WorkflowTasks extends IWorkflowTasks {
38
41
  });
39
42
  if (query.partition !== undefined) source = source.where((t) => t.partition.eq(query.partition!));
40
43
  if (query.processKey !== undefined) source = source.where((t) => t.processKey.eq(query.processKey!));
41
- const total = await source.count();
42
- const rows = await source.orderBy((t) => t.id).skip(offset).take(limit).toList();
43
- 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 };
44
46
  }
45
47
 
46
48
  async partitions(query: { readonly status?: readonly TaskStatus[] } = {}): Promise<readonly (string | null)[]> {
@@ -79,7 +81,7 @@ export class WorkflowTasks extends IWorkflowTasks {
79
81
  });
80
82
  }
81
83
 
82
- async complete(taskId: string, output: unknown, actor: WorkflowActor): Promise<TaskDetails> {
84
+ async complete(taskId: string, output: unknown, actor: WorkflowActor, options: WorkflowAuditOptions = {}): Promise<TaskDetails> {
83
85
  const task = await this.task(taskId, false);
84
86
  if (task.status === "claimed" && task.claimedById !== actor.id) throw new WorkflowError("conflict", "The task is taken by someone else");
85
87
  if (task.status !== "open" && task.status !== "claimed") throw new WorkflowError("wrong_status", `The task is ${task.status}`);
@@ -98,6 +100,7 @@ export class WorkflowTasks extends IWorkflowTasks {
98
100
  completedAt: this.clock.now(),
99
101
  output: typeof saved === "object" && saved !== null && !Array.isArray(saved) ? saved as Record<string, unknown> : null,
100
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 });
101
104
  return await this.get(task.id);
102
105
  }
103
106
 
@@ -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
  }