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.
- 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 +10 -1
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +23 -0
- package/core/workflow/definitions_module/access/Process.references.ts +27 -0
- package/core/workflow/definitions_module/events/Definitions.events.ts +16 -0
- package/core/workflow/definitions_module/http/Processes.controller.ts +34 -20
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +21 -6
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +48 -21
- package/core/workflow/generated/deps.ts +7 -3
- package/core/workflow/index.ts +13 -2
- package/core/workflow/internal/WorkflowEvents.ts +23 -0
- package/core/workflow/internal/http.ts +51 -21
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -1
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +28 -0
- package/core/workflow/runtime_module/events/Runtime.events.ts +28 -1
- package/core/workflow/runtime_module/http/Events.controller.ts +6 -3
- package/core/workflow/runtime_module/http/Instances.controller.ts +38 -22
- 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 +17 -5
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +16 -11
- package/core/workflow/tasks_module/WorkflowTasks.module.ts +4 -1
- package/core/workflow/tasks_module/access/Tasks.permissions.ts +13 -0
- package/core/workflow/tasks_module/events/Tasks.events.ts +15 -0
- package/core/workflow/tasks_module/http/Tasks.controller.ts +33 -22
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +10 -5
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +50 -15
- 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
- package/core/workflow/access/WorkflowAccess.ts +0 -52
|
@@ -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,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:
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
|
144
|
-
|
|
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 {
|
|
3
|
-
import type
|
|
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 {
|
|
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-
|
|
11
|
-
* `
|
|
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
|
|
21
|
-
const
|
|
22
|
-
const
|
|
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
|
-
...
|
|
25
|
-
|
|
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
|
-
|
|
34
|
+
access: await access.filter("workflow.tasks.work"),
|
|
28
35
|
partition,
|
|
29
|
-
processKey:
|
|
36
|
+
processKey: filterOf(list, "processKey"),
|
|
30
37
|
});
|
|
31
|
-
return
|
|
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
|
|
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
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
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 {
|
|
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
|
|
27
|
-
readonly
|
|
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
|
|
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 {
|
|
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
|
|
22
|
-
const
|
|
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
|
-
|
|
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 =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|