bazis 0.130.0 → 0.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/agent/AgentToolExecutor.ts +3 -0
- package/core/agent/Tool.contract.ts +1 -1
- package/core/agent/index.ts +1 -0
- package/core/agent/metadata.ts +9 -0
- package/core/agent/semantic.ts +8 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/contracts/types.ts +37 -1
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -1
- package/core/workflow/definitions_module/events/Definitions.events.ts +16 -0
- package/core/workflow/definitions_module/http/Processes.controller.ts +17 -12
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +18 -6
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +44 -21
- package/core/workflow/generated/deps.ts +5 -3
- package/core/workflow/index.ts +13 -1
- package/core/workflow/internal/WorkflowEvents.ts +23 -0
- package/core/workflow/internal/http.ts +34 -5
- package/core/workflow/runtime_module/events/Runtime.events.ts +28 -1
- package/core/workflow/runtime_module/http/Events.controller.ts +2 -1
- package/core/workflow/runtime_module/http/Instances.controller.ts +19 -13
- package/core/workflow/runtime_module/internal/ToolStep.ts +2 -1
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +23 -21
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +14 -5
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +12 -11
- package/core/workflow/tasks_module/events/Tasks.events.ts +15 -0
- package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -12
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +2 -2
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +9 -6
- package/library/openapi/codegen.ts +9 -0
- package/library/validation/types/ValidatorOptions.ts +10 -0
- package/library/workflow/formFields.ts +2 -1
- package/package.json +1 -1
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { IAccess, type AccessObject, type SubjectAccess } from "../../access";
|
|
2
2
|
import { ForbiddenError, type HttpContext } from "../../http";
|
|
3
|
-
import
|
|
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
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
...
|
|
40
|
+
...pagingOfList(list),
|
|
35
41
|
access: await access.filter("workflow.instances.read"),
|
|
36
|
-
partition:
|
|
37
|
-
processKey:
|
|
38
|
-
status:
|
|
39
|
-
businessKey:
|
|
40
|
-
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
|
|
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,
|
|
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 {
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
|
148
|
-
|
|
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 {
|
|
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
|
|
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
|
-
...
|
|
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:
|
|
36
|
+
processKey: filterOf(list, "processKey"),
|
|
35
37
|
});
|
|
36
|
-
return
|
|
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 {
|
|
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
|
|
42
|
-
|
|
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
|
}
|