bazis 0.123.0 → 0.124.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/index.ts +1 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/Workflow.module.ts +15 -0
- package/core/workflow/access/WorkflowAccess.ts +52 -0
- package/core/workflow/contracts/WorkflowError.ts +28 -0
- package/core/workflow/contracts/WorkflowInputValidator.ts +24 -0
- package/core/workflow/contracts/types.ts +30 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +24 -0
- package/core/workflow/definitions_module/http/Processes.controller.ts +92 -0
- package/core/workflow/definitions_module/model/Definitions.dbContext.ts +16 -0
- package/core/workflow/definitions_module/model/WfProcess.model.ts +52 -0
- package/core/workflow/definitions_module/model/WfVersion.model.ts +33 -0
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +79 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +234 -0
- package/core/workflow/index.ts +43 -0
- package/core/workflow/internal/WorkflowTools.ts +36 -0
- package/core/workflow/internal/http.ts +63 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +53 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +66 -0
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +92 -0
- package/core/workflow/runtime_module/config/Runtime.config.ts +27 -0
- package/core/workflow/runtime_module/events/Runtime.events.ts +36 -0
- package/core/workflow/runtime_module/http/Instances.controller.ts +72 -0
- package/core/workflow/runtime_module/internal/GraphCache.ts +37 -0
- package/core/workflow/runtime_module/internal/ToolStep.ts +60 -0
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +356 -0
- package/core/workflow/runtime_module/model/Runtime.dbContext.ts +25 -0
- package/core/workflow/runtime_module/model/WfHistory.model.ts +38 -0
- package/core/workflow/runtime_module/model/WfInstance.model.ts +70 -0
- package/core/workflow/runtime_module/model/WfJob.model.ts +49 -0
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +30 -0
- package/core/workflow/runtime_module/model/WfToken.model.ts +39 -0
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +82 -0
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +167 -0
- package/library/workflow/ProcessDocument.ts +94 -0
- package/library/workflow/conditions.ts +44 -0
- package/library/workflow/duration.ts +10 -0
- package/library/workflow/formFields.ts +112 -0
- package/library/workflow/index.ts +24 -0
- package/library/workflow/nodeKinds.ts +111 -0
- package/library/workflow/paths.ts +39 -0
- package/library/workflow/validateProcess.ts +310 -0
- package/package.json +4 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Controller, Created, Get, Post, type HttpContext } from "../../../http";
|
|
2
|
+
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
3
|
+
import { authorize, body, optionalString, pagingQuery, param, requiredString } from "../../internal/http";
|
|
4
|
+
import type { InstanceStatus } from "../model/WfInstance.model";
|
|
5
|
+
import { IWorkflowRuntime } from "../services/IWorkflowRuntime.service";
|
|
6
|
+
|
|
7
|
+
/** Instances over HTTP (WORKFLOW-001 §4); rights come from `WorkflowAccess` with the instance's partition. */
|
|
8
|
+
@Controller("workflow/instances")
|
|
9
|
+
export class InstancesController {
|
|
10
|
+
constructor(private readonly runtime: IWorkflowRuntime) {}
|
|
11
|
+
|
|
12
|
+
@Post()
|
|
13
|
+
async start(ctx: HttpContext) {
|
|
14
|
+
const request = await body(ctx);
|
|
15
|
+
const processKey = requiredString(request.processKey, "processKey", 64);
|
|
16
|
+
const partition = optionalString(request.partition, "partition") ?? null;
|
|
17
|
+
const actor = await authorize(ctx, "instances.start", { processKey, partition });
|
|
18
|
+
const instance = await this.runtime.start(processKey, {
|
|
19
|
+
input: request.input,
|
|
20
|
+
partition,
|
|
21
|
+
businessKey: optionalString(request.businessKey, "businessKey") ?? null,
|
|
22
|
+
actor,
|
|
23
|
+
});
|
|
24
|
+
return Created(`${ctx.path}/${instance.id}`, instance);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Get()
|
|
28
|
+
async list(ctx: HttpContext) {
|
|
29
|
+
const partition = optionalString(ctx.query("partition"), "partition");
|
|
30
|
+
const processKey = optionalString(ctx.query("processKey"), "processKey");
|
|
31
|
+
await authorize(ctx, "instances.read", { partition: partition ?? null, ...(processKey === undefined ? {} : { processKey }) });
|
|
32
|
+
const page = await this.runtime.instances({
|
|
33
|
+
...pagingQuery(ctx),
|
|
34
|
+
partition,
|
|
35
|
+
processKey,
|
|
36
|
+
status: optionalString(ctx.query("status"), "status") as InstanceStatus | undefined,
|
|
37
|
+
businessKey: optionalString(ctx.query("businessKey"), "businessKey"),
|
|
38
|
+
startedById: optionalString(ctx.query("startedById"), "startedById"),
|
|
39
|
+
});
|
|
40
|
+
return { data: page.items, meta: { total: page.total } };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Get(":id")
|
|
44
|
+
async get(ctx: HttpContext) {
|
|
45
|
+
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
46
|
+
await authorize(ctx, "instances.read", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
|
|
47
|
+
return instance;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Get(":id/history")
|
|
51
|
+
async history(ctx: HttpContext) {
|
|
52
|
+
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
53
|
+
await authorize(ctx, "instances.read", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
|
|
54
|
+
return { data: await this.runtime.history(instance.id) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Post(":id/cancel")
|
|
58
|
+
async cancel(ctx: HttpContext) {
|
|
59
|
+
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
60
|
+
const request = await body(ctx);
|
|
61
|
+
const actor = await authorize(ctx, "instances.manage", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
|
|
62
|
+
return await this.runtime.cancel(instance.id, { reason: optionalString(request.reason, "reason", 1000) ?? null, actor });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Post(":id/retry")
|
|
66
|
+
async retry(ctx: HttpContext) {
|
|
67
|
+
const instance = await this.runtime.instance(param(ctx, "id"));
|
|
68
|
+
const actor = await authorize(ctx, "instances.manage", { processKey: instance.processKey, partition: instance.partition, instanceId: instance.id });
|
|
69
|
+
return await this.runtime.retry(instance.id, { actor });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ProcessDocument, ProcessFlow } from "../../../../library/workflow";
|
|
2
|
+
|
|
3
|
+
/** A version's graph with flows indexed by node. Built once per version. */
|
|
4
|
+
export interface ProcessGraph {
|
|
5
|
+
readonly versionId: string;
|
|
6
|
+
readonly processKey: string;
|
|
7
|
+
readonly version: number;
|
|
8
|
+
readonly document: ProcessDocument;
|
|
9
|
+
readonly outgoing: ReadonlyMap<string, readonly (readonly [string, ProcessFlow])[]>;
|
|
10
|
+
readonly incoming: ReadonlyMap<string, readonly (readonly [string, ProcessFlow])[]>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function buildGraph(versionId: string, processKey: string, version: number, document: ProcessDocument): ProcessGraph {
|
|
14
|
+
const outgoing = new Map<string, (readonly [string, ProcessFlow])[]>();
|
|
15
|
+
const incoming = new Map<string, (readonly [string, ProcessFlow])[]>();
|
|
16
|
+
for (const [id, flow] of Object.entries(document.flows)) {
|
|
17
|
+
outgoing.set(flow.source, [...outgoing.get(flow.source) ?? [], [id, flow]]);
|
|
18
|
+
incoming.set(flow.target, [...incoming.get(flow.target) ?? [], [id, flow]]);
|
|
19
|
+
}
|
|
20
|
+
return { versionId, processKey, version, document, outgoing, incoming };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Graphs of versions in memory. Versions are immutable, so entries never go stale; the oldest go first past the limit. */
|
|
24
|
+
export class GraphCache {
|
|
25
|
+
private readonly graphs = new Map<string, ProcessGraph>();
|
|
26
|
+
|
|
27
|
+
constructor(private readonly limit = 500) {}
|
|
28
|
+
|
|
29
|
+
get(versionId: string): ProcessGraph | undefined {
|
|
30
|
+
return this.graphs.get(versionId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(graph: ProcessGraph): void {
|
|
34
|
+
this.graphs.set(graph.versionId, graph);
|
|
35
|
+
if (this.graphs.size > this.limit) this.graphs.delete(this.graphs.keys().next().value!);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AgentToolResult } from "../../../agent";
|
|
2
|
+
import { resolveInput } from "../../../../library/workflow";
|
|
3
|
+
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
4
|
+
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
5
|
+
import type { WfJob } from "../model/WfJob.model";
|
|
6
|
+
import { dataOf, type WorkflowEngine } from "./WorkflowEngine";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One `tool` job: call the service task's Tool and apply the result. The Tool
|
|
10
|
+
* runs outside a transaction, the result is applied in a step transaction; a
|
|
11
|
+
* token closed meanwhile (a cancel) drops it. The Tool runs on behalf of who
|
|
12
|
+
* started the instance, with the token id as its idempotency key: after a crash
|
|
13
|
+
* between the call and the step the call may repeat (TOOLS-001 §7).
|
|
14
|
+
*/
|
|
15
|
+
export class ToolStep {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly db: RuntimeDbContext,
|
|
18
|
+
private readonly engine: WorkflowEngine,
|
|
19
|
+
private readonly tools: WorkflowTools,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
23
|
+
const instance = job.instanceId === null ? undefined : await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(job.instanceId!));
|
|
24
|
+
const token = job.tokenId === null ? undefined : await this.db.tokens.asNoTracking().firstOrDefault((t) => t.id.eq(job.tokenId!));
|
|
25
|
+
if (!instance || !token || instance.status !== "running" || token.status !== "waiting") {
|
|
26
|
+
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const graph = await this.engine.graphOf(instance.versionId);
|
|
30
|
+
const node = graph.document.nodes[token.nodeId];
|
|
31
|
+
const name = String(node?.config?.tool ?? "");
|
|
32
|
+
const input = resolveInput(node?.config?.input ?? {}, dataOf(instance));
|
|
33
|
+
const runner = this.tools.bound;
|
|
34
|
+
const result: AgentToolResult = runner === undefined
|
|
35
|
+
? { callId: token.id, name, status: "error", error: { code: "TOOL_NOT_FOUND", message: `Tool ${name} is not available to processes` }, metadata: {} } as AgentToolResult
|
|
36
|
+
: await runner.run(name, input, {
|
|
37
|
+
idempotencyKey: token.id,
|
|
38
|
+
actor: { kind: instance.startedByKind, id: instance.startedById },
|
|
39
|
+
metadata: { instanceId: instance.id, partition: instance.partition, processKey: instance.processKey, nodeId: token.nodeId },
|
|
40
|
+
signal,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await this.engine.inStep(instance.id, async (step) => {
|
|
44
|
+
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
45
|
+
const current = step.tokens.find((t) => t.id === token.id);
|
|
46
|
+
if (!current || current.status !== "waiting" || step.instance.status !== "running") return;
|
|
47
|
+
this.engine.history(step, current, current.nodeId, "tool", {
|
|
48
|
+
tool: name,
|
|
49
|
+
status: result.status,
|
|
50
|
+
durationMs: result.durationMs ?? null,
|
|
51
|
+
...(result.error ? { error: { code: result.error.code ?? null, message: result.error.message } } : {}),
|
|
52
|
+
});
|
|
53
|
+
if (result.status === "success") {
|
|
54
|
+
await this.engine.complete(step, current.id, result.output ?? null);
|
|
55
|
+
} else {
|
|
56
|
+
this.engine.fail(step, current, result.error?.code ?? "TOOL_FAILED", result.error?.message ?? `Tool ${name} failed`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import type { ServiceResolver } from "../../../di";
|
|
2
|
+
import { EventBus, type EventToken, type Logger } from "../../../kernel";
|
|
3
|
+
import { durationMs, evaluateCondition, nodeKindOf, type FormSpec, type InstanceData, type ProcessFlow, type ProcessNode } from "../../../../library/workflow";
|
|
4
|
+
import type { WorkflowActor } from "../../contracts/types";
|
|
5
|
+
import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
6
|
+
import { WORKFLOW_INSTANCE_FINISHED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED } from "../events/Runtime.events";
|
|
7
|
+
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
8
|
+
import { WfHistory, type HistoryType } from "../model/WfHistory.model";
|
|
9
|
+
import { WfInstance, type StartedVia } from "../model/WfInstance.model";
|
|
10
|
+
import { WfJob } from "../model/WfJob.model";
|
|
11
|
+
import { WfToken } from "../model/WfToken.model";
|
|
12
|
+
import { buildGraph, type GraphCache, type ProcessGraph } from "./GraphCache";
|
|
13
|
+
|
|
14
|
+
/** An event to publish once the step has committed. */
|
|
15
|
+
interface PendingEvent {
|
|
16
|
+
readonly event: EventToken<unknown>;
|
|
17
|
+
readonly payload: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The state of an instance for one step: its row under `FOR UPDATE`, its live
|
|
22
|
+
* tokens and the version graph, plus the events to publish after the commit.
|
|
23
|
+
*/
|
|
24
|
+
export interface Step {
|
|
25
|
+
readonly instance: WfInstance;
|
|
26
|
+
readonly tokens: WfToken[];
|
|
27
|
+
readonly graph: ProcessGraph;
|
|
28
|
+
readonly events: PendingEvent[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BeginOptions {
|
|
32
|
+
readonly input: Record<string, unknown>;
|
|
33
|
+
readonly partition: string | null;
|
|
34
|
+
readonly businessKey: string | null;
|
|
35
|
+
readonly startedVia: StartedVia;
|
|
36
|
+
readonly actor: WorkflowActor;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Node kinds that come with later stages: an instance reaching one fails with a clear reason. */
|
|
40
|
+
const NOT_YET = new Set(["timerCatch", "eventWait"]);
|
|
41
|
+
const LIVE = new Set(["active", "waiting", "failed"]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Moves tokens along the graph up to the steps that wait (a Tool, a task, a
|
|
45
|
+
* join) and finishes the instance when no live token is left. No state lives in
|
|
46
|
+
* memory: every change is one transaction holding the instance row
|
|
47
|
+
* ({@link inStep}), so a restart or a second application instance loses nothing.
|
|
48
|
+
*/
|
|
49
|
+
export class WorkflowEngine {
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly db: RuntimeDbContext,
|
|
52
|
+
private readonly definitions: IWorkflowDefinitions,
|
|
53
|
+
private readonly graphs: GraphCache,
|
|
54
|
+
private readonly events: EventBus,
|
|
55
|
+
private readonly scope: ServiceResolver,
|
|
56
|
+
private readonly logger: Logger | undefined,
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
/** The graph of a version. Versions are immutable, so graphs are cached. */
|
|
60
|
+
async graphOf(versionId: string): Promise<ProcessGraph> {
|
|
61
|
+
const cached = this.graphs.get(versionId);
|
|
62
|
+
if (cached) return cached;
|
|
63
|
+
const version = await this.definitions.versionById(versionId);
|
|
64
|
+
if (!version) throw new Error(`workflow version ${versionId} does not exist`);
|
|
65
|
+
return this.cache(version);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cache(version: VersionDetails): ProcessGraph {
|
|
69
|
+
const graph = buildGraph(version.id, version.key, version.version, version.document);
|
|
70
|
+
this.graphs.set(graph);
|
|
71
|
+
return graph;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Creates an instance of `graph` and moves its token from `startNodeId`. */
|
|
75
|
+
async begin(graph: ProcessGraph, startNodeId: string, options: BeginOptions): Promise<string> {
|
|
76
|
+
const instance = Object.assign(new WfInstance(), {
|
|
77
|
+
id: Bun.randomUUIDv7(),
|
|
78
|
+
versionId: graph.versionId,
|
|
79
|
+
processKey: graph.processKey,
|
|
80
|
+
version: graph.version,
|
|
81
|
+
partition: options.partition,
|
|
82
|
+
input: options.input,
|
|
83
|
+
businessKey: options.businessKey,
|
|
84
|
+
startedVia: options.startedVia,
|
|
85
|
+
startedByKind: options.actor.kind,
|
|
86
|
+
startedById: options.actor.id,
|
|
87
|
+
});
|
|
88
|
+
await this.db.transactionScope(async (transaction) => {
|
|
89
|
+
this.db.instances.add(instance);
|
|
90
|
+
await this.db.saveChanges();
|
|
91
|
+
const step: Step = { instance, tokens: [], graph, events: [] };
|
|
92
|
+
this.history(step, null, null, "started", { via: options.startedVia }, options.actor);
|
|
93
|
+
await this.advance(step, [this.spawn(step, startNodeId, null)]);
|
|
94
|
+
await this.db.saveChanges();
|
|
95
|
+
this.publishAfter(transaction, step);
|
|
96
|
+
});
|
|
97
|
+
return instance.id;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Runs `work` in a transaction holding the instance row and saves every
|
|
102
|
+
* change. `null` when the instance does not exist.
|
|
103
|
+
*/
|
|
104
|
+
async inStep<T>(instanceId: string, work: (step: Step) => Promise<T>): Promise<T | null> {
|
|
105
|
+
// The graph is read before the transaction: versions are immutable and live in the Definitions store.
|
|
106
|
+
const head = await this.db.instances.asNoTracking().firstOrDefault((i) => i.id.eq(instanceId));
|
|
107
|
+
if (!head) return null;
|
|
108
|
+
const graph = await this.graphOf(head.versionId);
|
|
109
|
+
return await this.db.transactionScope(async (transaction) => {
|
|
110
|
+
const instance = await this.db.instances.where((i) => i.id.eq(instanceId)).forUpdate().firstOrDefault();
|
|
111
|
+
if (!instance) return null;
|
|
112
|
+
const tokens = await this.db.tokens.where((t) => t.instanceId.eq(instanceId).and(t.status.in(["active", "waiting", "failed"]))).toList();
|
|
113
|
+
const step: Step = { instance, tokens, graph, events: [] };
|
|
114
|
+
const result = await work(step);
|
|
115
|
+
await this.db.saveChanges();
|
|
116
|
+
this.publishAfter(transaction, step);
|
|
117
|
+
return result;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A waiting step (a Tool, a task) is done: its result goes to `$nodes.<node>`
|
|
123
|
+
* and to the `saveTo` variable, the token moves on. `false` when the token no
|
|
124
|
+
* longer waits (cancelled, the instance failed or finished).
|
|
125
|
+
*/
|
|
126
|
+
async complete(step: Step, tokenId: string, output: unknown, actor?: WorkflowActor): Promise<boolean> {
|
|
127
|
+
const token = step.tokens.find((t) => t.id === tokenId);
|
|
128
|
+
if (!token || token.status !== "waiting" || step.instance.status !== "running") return false;
|
|
129
|
+
const node = step.graph.document.nodes[token.nodeId]!;
|
|
130
|
+
token.output = output ?? null;
|
|
131
|
+
step.instance.nodes = { ...step.instance.nodes, [token.nodeId]: output ?? null };
|
|
132
|
+
const saveTo = node.config?.saveTo;
|
|
133
|
+
if (typeof saveTo === "string") step.instance.vars = { ...step.instance.vars, [saveTo]: output ?? null };
|
|
134
|
+
if (actor !== undefined) this.history(step, token, token.nodeId, "completed", null, actor);
|
|
135
|
+
await this.advance(step, this.leave(step, token, this.single(step, token.nodeId)));
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A step failed: the token waits for a retry, the instance is `failed` with the reason. */
|
|
140
|
+
fail(step: Step, token: WfToken, code: string, message: string): void {
|
|
141
|
+
token.status = "failed";
|
|
142
|
+
step.instance.status = "failed";
|
|
143
|
+
step.instance.error = { node: token.nodeId, code, message };
|
|
144
|
+
this.history(step, token, token.nodeId, "error", { code, message });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Retries the failed steps: their tokens are back on their nodes and the instance runs again. */
|
|
148
|
+
async retry(step: Step, actor: WorkflowActor): Promise<void> {
|
|
149
|
+
const failed = step.tokens.filter((t) => t.status === "failed");
|
|
150
|
+
step.instance.status = "running";
|
|
151
|
+
step.instance.error = null;
|
|
152
|
+
for (const token of failed) {
|
|
153
|
+
token.status = "active";
|
|
154
|
+
this.history(step, token, token.nodeId, "retried", null, actor);
|
|
155
|
+
}
|
|
156
|
+
await this.advance(step, failed);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Closes every live token, job and subscription; the application drops the tasks on `WORKFLOW_TOKEN_CLOSED`. */
|
|
160
|
+
async cancel(step: Step, reason: string | null, actor: WorkflowActor): Promise<void> {
|
|
161
|
+
const open = step.tokens.filter((t) => LIVE.has(t.status));
|
|
162
|
+
for (const token of open) {
|
|
163
|
+
token.status = "cancelled";
|
|
164
|
+
token.endedAt = new Date();
|
|
165
|
+
}
|
|
166
|
+
await this.db.jobs.asNoTracking().where((j) => j.instanceId.eq(step.instance.id)).executeDelete();
|
|
167
|
+
await this.db.subscriptions.asNoTracking().where((s) => s.instanceId.eq(step.instance.id)).executeDelete();
|
|
168
|
+
step.instance.status = "cancelled";
|
|
169
|
+
step.instance.endedAt = new Date();
|
|
170
|
+
this.history(step, null, null, "cancelled", { reason }, actor);
|
|
171
|
+
for (const token of open) {
|
|
172
|
+
if (token.nodeId !== "" && step.graph.document.nodes[token.nodeId]?.kind === "userTask") {
|
|
173
|
+
step.events.push({ event: WORKFLOW_TOKEN_CLOSED as EventToken<unknown>, payload: { instanceId: step.instance.id, tokenId: token.id, reason: "cancelled" } });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
this.finished(step, "cancelled");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Adds a history entry to the step. */
|
|
180
|
+
history(step: Step, token: WfToken | null, nodeId: string | null, type: HistoryType, data: Record<string, unknown> | null, actor?: WorkflowActor): void {
|
|
181
|
+
this.db.history.add(Object.assign(new WfHistory(), {
|
|
182
|
+
id: Bun.randomUUIDv7(),
|
|
183
|
+
instanceId: step.instance.id,
|
|
184
|
+
tokenId: token?.id ?? null,
|
|
185
|
+
nodeId,
|
|
186
|
+
type,
|
|
187
|
+
data,
|
|
188
|
+
actorKind: actor?.kind ?? null,
|
|
189
|
+
actorId: actor?.id ?? null,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Moves tokens until each waits or ends. */
|
|
194
|
+
private async advance(step: Step, start: readonly WfToken[]): Promise<void> {
|
|
195
|
+
const queue = [...start];
|
|
196
|
+
while (queue.length > 0 && step.instance.status === "running") {
|
|
197
|
+
const token = queue.shift()!;
|
|
198
|
+
if (token.status !== "active") continue;
|
|
199
|
+
queue.push(...this.enter(step, token));
|
|
200
|
+
}
|
|
201
|
+
if (step.instance.status === "running" && !step.tokens.some((t) => LIVE.has(t.status))) {
|
|
202
|
+
step.instance.status = "completed";
|
|
203
|
+
step.instance.endedAt = new Date();
|
|
204
|
+
this.history(step, null, null, "completed", null);
|
|
205
|
+
this.finished(step, "completed");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The node under a token: returns the new tokens to move on. */
|
|
210
|
+
private enter(step: Step, token: WfToken): WfToken[] {
|
|
211
|
+
const node = step.graph.document.nodes[token.nodeId];
|
|
212
|
+
if (!node) {
|
|
213
|
+
this.fail(step, token, "unknown_node", `node ${token.nodeId} is not in the version`);
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
const role = nodeKindOf(node.kind)?.role;
|
|
217
|
+
if (role === "start") return this.leave(step, token, this.single(step, token.nodeId));
|
|
218
|
+
if (role === "end") {
|
|
219
|
+
this.close(token, "done");
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
if (NOT_YET.has(node.kind)) {
|
|
223
|
+
this.fail(step, token, "not_supported", `${node.kind} steps are not supported yet`);
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
switch (node.kind) {
|
|
227
|
+
case "serviceTask":
|
|
228
|
+
return this.waitForTool(step, token);
|
|
229
|
+
case "userTask":
|
|
230
|
+
return this.waitForTask(step, token, node);
|
|
231
|
+
case "exclusiveGateway":
|
|
232
|
+
return this.choose(step, token);
|
|
233
|
+
case "parallelGateway":
|
|
234
|
+
return this.parallel(step, token);
|
|
235
|
+
default:
|
|
236
|
+
this.fail(step, token, "not_supported", `${node.kind} cannot be entered by a flow`);
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private waitForTool(step: Step, token: WfToken): WfToken[] {
|
|
242
|
+
token.status = "waiting";
|
|
243
|
+
this.db.jobs.add(Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "tool" as const, instanceId: step.instance.id, tokenId: token.id, dueAt: new Date() }));
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private waitForTask(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
|
|
248
|
+
token.status = "waiting";
|
|
249
|
+
const config = node.config ?? {};
|
|
250
|
+
const dueIn = typeof config.dueIn === "string" ? durationMs(config.dueIn) : undefined;
|
|
251
|
+
step.events.push({
|
|
252
|
+
event: WORKFLOW_TASK_REQUESTED as EventToken<unknown>,
|
|
253
|
+
payload: {
|
|
254
|
+
instanceId: step.instance.id,
|
|
255
|
+
tokenId: token.id,
|
|
256
|
+
processKey: step.instance.processKey,
|
|
257
|
+
nodeId: token.nodeId,
|
|
258
|
+
partition: step.instance.partition,
|
|
259
|
+
title: node.title,
|
|
260
|
+
assignee: config.assignee as { role: string } | { user: string },
|
|
261
|
+
form: (config.form as FormSpec | undefined) ?? null,
|
|
262
|
+
dueAt: dueIn === undefined ? null : new Date(Date.now() + dueIn),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** An exclusive gateway: the first true flow in document order, else the default one. */
|
|
269
|
+
private choose(step: Step, token: WfToken): WfToken[] {
|
|
270
|
+
const flows = step.graph.outgoing.get(token.nodeId) ?? [];
|
|
271
|
+
if (flows.length === 1) return this.leave(step, token, flows[0]!);
|
|
272
|
+
const data = dataOf(step.instance);
|
|
273
|
+
const chosen = flows.find(([, flow]) => flow.condition !== undefined && evaluateCondition(flow.condition, data))
|
|
274
|
+
?? flows.find(([, flow]) => flow.isDefault === true);
|
|
275
|
+
if (!chosen) {
|
|
276
|
+
this.fail(step, token, "no_branch", "no condition matched and there is no default flow");
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
return this.leave(step, token, chosen);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A parallel gateway. A join waits for a token from every incoming flow and makes one; a split makes one per outgoing flow. */
|
|
283
|
+
private parallel(step: Step, token: WfToken): WfToken[] {
|
|
284
|
+
const inputs = step.graph.incoming.get(token.nodeId) ?? [];
|
|
285
|
+
let current = token;
|
|
286
|
+
if (inputs.length > 1) {
|
|
287
|
+
token.status = "waiting";
|
|
288
|
+
const arrived = new Map<string, WfToken>();
|
|
289
|
+
for (const other of step.tokens) {
|
|
290
|
+
if (other.nodeId === token.nodeId && other.status === "waiting" && other.viaFlowId !== null && !arrived.has(other.viaFlowId)) arrived.set(other.viaFlowId, other);
|
|
291
|
+
}
|
|
292
|
+
if (!inputs.every(([id]) => arrived.has(id))) return [];
|
|
293
|
+
for (const joined of arrived.values()) this.close(joined, "done");
|
|
294
|
+
current = this.spawn(step, token.nodeId, null);
|
|
295
|
+
}
|
|
296
|
+
const outputs = step.graph.outgoing.get(token.nodeId) ?? [];
|
|
297
|
+
this.close(current, "done");
|
|
298
|
+
this.history(step, current, current.nodeId, "left", { flows: outputs.map(([id]) => id) });
|
|
299
|
+
return outputs.map(([id, flow]) => this.spawn(step, flow.target, id));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The single outgoing flow of a node; validation guarantees it exists. */
|
|
303
|
+
private single(step: Step, nodeId: string): readonly [string, ProcessFlow] {
|
|
304
|
+
const flows = step.graph.outgoing.get(nodeId) ?? [];
|
|
305
|
+
if (flows.length !== 1) throw new Error(`node ${nodeId} must have exactly one outgoing flow`);
|
|
306
|
+
return flows[0]!;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private leave(step: Step, token: WfToken, [flowId, flow]: readonly [string, ProcessFlow]): WfToken[] {
|
|
310
|
+
this.close(token, "done");
|
|
311
|
+
this.history(step, token, token.nodeId, "left", { flow: flowId });
|
|
312
|
+
return [this.spawn(step, flow.target, flowId)];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private close(token: WfToken, status: "done" | "cancelled"): void {
|
|
316
|
+
token.status = status;
|
|
317
|
+
token.endedAt = new Date();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private spawn(step: Step, nodeId: string, viaFlowId: string | null): WfToken {
|
|
321
|
+
const token = Object.assign(new WfToken(), { id: Bun.randomUUIDv7(), instanceId: step.instance.id, nodeId, viaFlowId, status: "active" as const });
|
|
322
|
+
this.db.tokens.add(token);
|
|
323
|
+
step.tokens.push(token);
|
|
324
|
+
this.history(step, token, nodeId, "entered", viaFlowId ? { flow: viaFlowId } : null);
|
|
325
|
+
return token;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private finished(step: Step, status: "completed" | "cancelled"): void {
|
|
329
|
+
step.events.push({
|
|
330
|
+
event: WORKFLOW_INSTANCE_FINISHED as EventToken<unknown>,
|
|
331
|
+
payload: { instanceId: step.instance.id, processKey: step.instance.processKey, partition: step.instance.partition, status },
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Events go out after the commit, so a handler never sees a step that rolled
|
|
337
|
+
* back. A failing handler is logged: the step is already saved.
|
|
338
|
+
*/
|
|
339
|
+
private publishAfter(transaction: { afterCommit(callback: () => void | Promise<void>): void }, step: Step): void {
|
|
340
|
+
if (step.events.length === 0) return;
|
|
341
|
+
const events = [...step.events];
|
|
342
|
+
transaction.afterCommit(async () => {
|
|
343
|
+
for (const { event, payload } of events) {
|
|
344
|
+
try {
|
|
345
|
+
await this.events.publishScoped(this.scope, event, payload);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
this.logger?.error("workflow event handler failed", { event: event.name, instance: step.instance.id, error: error instanceof Error ? error.message : String(error) });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function dataOf(instance: WfInstance): InstanceData {
|
|
355
|
+
return { input: instance.input, vars: instance.vars, nodes: instance.nodes };
|
|
356
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DbContext, defineOrmOwnedStoreV1 } from "../../../orm";
|
|
2
|
+
import { WfHistory } from "./WfHistory.model";
|
|
3
|
+
import { WfInstance } from "./WfInstance.model";
|
|
4
|
+
import { WfJob } from "./WfJob.model";
|
|
5
|
+
import { WfSubscription } from "./WfSubscription.model";
|
|
6
|
+
import { WfToken } from "./WfToken.model";
|
|
7
|
+
|
|
8
|
+
/** The five tables change together: one engine step is one transaction over them. */
|
|
9
|
+
export class RuntimeDbContext extends DbContext {
|
|
10
|
+
readonly instances = this.set(WfInstance);
|
|
11
|
+
readonly tokens = this.set(WfToken);
|
|
12
|
+
readonly jobs = this.set(WfJob);
|
|
13
|
+
readonly subscriptions = this.set(WfSubscription);
|
|
14
|
+
readonly history = this.set(WfHistory);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RUNTIME_ENTITIES = [WfInstance, WfToken, WfJob, WfSubscription, WfHistory] as const;
|
|
18
|
+
|
|
19
|
+
/** The tables of the Runtime part: bazis creates and checks them in the application database. */
|
|
20
|
+
export const runtimeStore = defineOrmOwnedStoreV1({
|
|
21
|
+
contract: "bazis.orm-owned-store/v1",
|
|
22
|
+
storeKey: "bazis.workflow.runtime",
|
|
23
|
+
formatVersion: 1,
|
|
24
|
+
ownedScope: { schema: "public", tablePrefix: "bazis_wf_run_" },
|
|
25
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
|
|
2
|
+
import { WfInstance } from "./WfInstance.model";
|
|
3
|
+
|
|
4
|
+
export type HistoryType = "started" | "entered" | "left" | "tool" | "timer" | "event" | "error" | "retried" | "cancelled" | "completed";
|
|
5
|
+
|
|
6
|
+
/** The journal of an instance, append only. Its v7 ids give the order. */
|
|
7
|
+
@ForeignKey(() => WfInstance, { properties: ["instanceId"], onDelete: "cascade" })
|
|
8
|
+
@Index(["instanceId", "id"])
|
|
9
|
+
@Entity({ table: "bazis_wf_run_history" })
|
|
10
|
+
export class WfHistory {
|
|
11
|
+
@UUID({ version: "v7" })
|
|
12
|
+
id = "";
|
|
13
|
+
|
|
14
|
+
@Column({ type: "uuid" })
|
|
15
|
+
instanceId = "";
|
|
16
|
+
|
|
17
|
+
@Column({ type: "uuid", nullable: true })
|
|
18
|
+
tokenId: string | null = null;
|
|
19
|
+
|
|
20
|
+
@Column({ type: "text", nullable: true })
|
|
21
|
+
nodeId: string | null = null;
|
|
22
|
+
|
|
23
|
+
@Column({ type: "text" })
|
|
24
|
+
type: HistoryType = "entered";
|
|
25
|
+
|
|
26
|
+
@Column({ type: "json", nullable: true })
|
|
27
|
+
data: Record<string, unknown> | null = null;
|
|
28
|
+
|
|
29
|
+
/** Who did it: cancel, retry, task completion; `null` for engine steps. */
|
|
30
|
+
@Column({ type: "text", nullable: true })
|
|
31
|
+
actorKind: string | null = null;
|
|
32
|
+
|
|
33
|
+
@Column({ type: "text", nullable: true })
|
|
34
|
+
actorId: string | null = null;
|
|
35
|
+
|
|
36
|
+
@Column({ type: "createdAt" })
|
|
37
|
+
at = new Date(0);
|
|
38
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Column, Entity, Index, UUID } from "../../../orm";
|
|
2
|
+
|
|
3
|
+
/** `completed` and `cancelled` are finished: retention deletes them. `failed` waits for retry or cancel. */
|
|
4
|
+
export type InstanceStatus = "running" | "completed" | "cancelled" | "failed";
|
|
5
|
+
|
|
6
|
+
export type StartedVia = "manual" | "event" | "schedule";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One run of a process. Bound to the version it started with: a new publication
|
|
10
|
+
* does not touch it. Its data: the input, the variables `saveTo` writes and the
|
|
11
|
+
* result of each step by node id.
|
|
12
|
+
*/
|
|
13
|
+
@Index(["partition", "status"])
|
|
14
|
+
@Index(["processKey", "status"])
|
|
15
|
+
@Index(["status", "endedAt"])
|
|
16
|
+
@Entity({ table: "bazis_wf_run_instances" })
|
|
17
|
+
export class WfInstance {
|
|
18
|
+
@UUID({ version: "v7" })
|
|
19
|
+
id = "";
|
|
20
|
+
|
|
21
|
+
/** The version (a Definitions row: a reference without a foreign key). */
|
|
22
|
+
@Column({ type: "uuid" })
|
|
23
|
+
versionId = "";
|
|
24
|
+
|
|
25
|
+
@Column({ type: "text" })
|
|
26
|
+
processKey = "";
|
|
27
|
+
|
|
28
|
+
@Column({ type: "integer" })
|
|
29
|
+
version = 0;
|
|
30
|
+
|
|
31
|
+
/** The application's scope: a space, an organization. Never interpreted. */
|
|
32
|
+
@Column({ type: "text", nullable: true })
|
|
33
|
+
partition: string | null = null;
|
|
34
|
+
|
|
35
|
+
@Column({ type: "text" })
|
|
36
|
+
status: InstanceStatus = "running";
|
|
37
|
+
|
|
38
|
+
@Column({ type: "json" })
|
|
39
|
+
input: Record<string, unknown> = {};
|
|
40
|
+
|
|
41
|
+
@Column({ type: "json" })
|
|
42
|
+
vars: Record<string, unknown> = {};
|
|
43
|
+
|
|
44
|
+
/** The result of every passed step by node id: `$nodes.<node>.<field>`. */
|
|
45
|
+
@Column({ type: "json" })
|
|
46
|
+
nodes: Record<string, unknown> = {};
|
|
47
|
+
|
|
48
|
+
@Index()
|
|
49
|
+
@Column({ type: "text", nullable: true })
|
|
50
|
+
businessKey: string | null = null;
|
|
51
|
+
|
|
52
|
+
@Column({ type: "text" })
|
|
53
|
+
startedVia: StartedVia = "manual";
|
|
54
|
+
|
|
55
|
+
@Column({ type: "text" })
|
|
56
|
+
startedByKind: "user" | "system" = "user";
|
|
57
|
+
|
|
58
|
+
@Column({ type: "text" })
|
|
59
|
+
startedById = "";
|
|
60
|
+
|
|
61
|
+
/** Why the instance failed: node, code and message. */
|
|
62
|
+
@Column({ type: "json", nullable: true })
|
|
63
|
+
error: { readonly node: string; readonly code: string; readonly message: string } | null = null;
|
|
64
|
+
|
|
65
|
+
@Column({ type: "createdAt" })
|
|
66
|
+
createdAt = new Date(0);
|
|
67
|
+
|
|
68
|
+
@Column({ type: "datetime", nullable: true })
|
|
69
|
+
endedAt: Date | null = null;
|
|
70
|
+
}
|