bazis 0.124.0 → 0.125.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 +1 -1
- package/core/agent/errors.ts +14 -3
- package/core/app/ui/uiSurfaceHttp.ts +2 -0
- package/core/http/Routing/RouterBuilder.ts +2 -1
- package/core/http/Routing/contextOnlyControllers.ts +16 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/definitions_module/http/Processes.controller.ts +2 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +2 -0
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +30 -3
- package/core/workflow/runtime_module/events/Runtime.events.ts +2 -1
- package/core/workflow/runtime_module/http/Instances.controller.ts +2 -0
- package/core/workflow/runtime_module/internal/GraphCache.ts +8 -2
- package/core/workflow/runtime_module/internal/TimerStep.ts +21 -0
- package/core/workflow/runtime_module/internal/ToolStep.ts +40 -4
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +118 -4
- package/package.json +1 -1
|
@@ -2146,7 +2146,7 @@ export class AgentToolExecutor {
|
|
|
2146
2146
|
details?: JsonObject,
|
|
2147
2147
|
): AgentToolResult {
|
|
2148
2148
|
if (error instanceof ToolError) {
|
|
2149
|
-
return this.errorResult(call, code, error.message, metadata, startedAt, details);
|
|
2149
|
+
return this.errorResult(call, code, error.message, metadata, startedAt, error.reason === undefined ? details : { ...details, reason: error.reason });
|
|
2150
2150
|
}
|
|
2151
2151
|
this.logger?.error("Agent tool failed", { agent: agentName ?? null, tool: call.name, callId: call.id, code, error: redactSensitive(error) });
|
|
2152
2152
|
return this.errorResult(call, code, fallback, metadata, startedAt, details);
|
package/core/agent/errors.ts
CHANGED
|
@@ -29,9 +29,20 @@ export class AgentToolExecutionError extends Error {
|
|
|
29
29
|
* goes to the application log with its stack.
|
|
30
30
|
*/
|
|
31
31
|
export class ToolError extends Error {
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* A business reason the caller can act on, such as `card_declined`: it comes
|
|
34
|
+
* back as `error.details.reason`. A process routes it to an error boundary
|
|
35
|
+
* and does not retry it (WORKFLOW-001).
|
|
36
|
+
*/
|
|
37
|
+
readonly reason?: string;
|
|
38
|
+
|
|
39
|
+
constructor(message: string, options: { readonly reason?: string } = {}) {
|
|
33
40
|
super(message);
|
|
34
41
|
this.name = "ToolError";
|
|
42
|
+
if (options.reason !== undefined) {
|
|
43
|
+
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(options.reason)) throw new TypeError("ToolError reason must match ^[A-Za-z][A-Za-z0-9_.-]{0,63}$");
|
|
44
|
+
this.reason = options.reason;
|
|
45
|
+
}
|
|
35
46
|
}
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -41,8 +52,8 @@ export class ToolError extends Error {
|
|
|
41
52
|
* for an idempotency-gated retry. Its message reaches the model like a ToolError.
|
|
42
53
|
*/
|
|
43
54
|
export class AgentToolPreCommitError extends ToolError {
|
|
44
|
-
constructor(message: string) {
|
|
45
|
-
super(message);
|
|
55
|
+
constructor(message: string, options: { readonly reason?: string } = {}) {
|
|
56
|
+
super(message, options);
|
|
46
57
|
this.name = "AgentToolPreCommitError";
|
|
47
58
|
}
|
|
48
59
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { contextOnlyController } from "../../http/Routing/contextOnlyControllers";
|
|
1
2
|
import {
|
|
2
3
|
Controller,
|
|
3
4
|
ForbiddenError,
|
|
@@ -183,6 +184,7 @@ export class UiSurfaceHttpController {
|
|
|
183
184
|
return versionedJson(ctx, document, document.metadata.revision);
|
|
184
185
|
}
|
|
185
186
|
}
|
|
187
|
+
contextOnlyController(UiSurfaceHttpController);
|
|
186
188
|
|
|
187
189
|
function validateSurfaceRegistration(
|
|
188
190
|
options: RunAppUiSurfaceOptions,
|
|
@@ -2,6 +2,7 @@ import type { Class, Token } from "../../di";
|
|
|
2
2
|
import { assertConsumes, bindArguments } from "../Binding/ParameterBinder";
|
|
3
3
|
import { resolveGeneratedBindings } from "../Binding/autoBindings";
|
|
4
4
|
import { generatedSourcesAreStale } from "../../generatedFingerprint";
|
|
5
|
+
import { isContextOnlyController } from "./contextOnlyControllers";
|
|
5
6
|
import { controllerMetaOf, type ActionMeta, type ControllerMeta } from "../Decorators/metadata";
|
|
6
7
|
import { HttpSetupError } from "../Errors/HttpError";
|
|
7
8
|
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
@@ -237,7 +238,7 @@ function assertBindingsFresh(
|
|
|
237
238
|
methodName: string | symbol,
|
|
238
239
|
bindings: readonly unknown[] | undefined,
|
|
239
240
|
): void {
|
|
240
|
-
if (bindings !== undefined || !generatedSourcesAreStale()) {
|
|
241
|
+
if (bindings !== undefined || isContextOnlyController(controllerClass) || !generatedSourcesAreStale()) {
|
|
241
242
|
return;
|
|
242
243
|
}
|
|
243
244
|
const method = (controllerClass.prototype as Record<string | symbol, unknown>)[methodName];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const contextOnly = new WeakSet<object>();
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @internal Marks a framework controller whose actions read the `HttpContext`
|
|
5
|
+
* themselves. The application's codegen never covers framework classes, so a
|
|
6
|
+
* stale application codegen says nothing about them and must not stop the start.
|
|
7
|
+
*/
|
|
8
|
+
export function contextOnlyController<T extends object>(controller: T): T {
|
|
9
|
+
contextOnly.add(controller);
|
|
10
|
+
return controller;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @internal */
|
|
14
|
+
export function isContextOnlyController(controller: object): boolean {
|
|
15
|
+
return contextOnly.has(controller);
|
|
16
|
+
}
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.125.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
1
2
|
import { Controller, Created, Get, Patch, Post, Put, type HttpContext } from "../../../http";
|
|
2
3
|
import { authorize, body, optionalInteger, optionalString, pagingQuery, param, requiredString } from "../../internal/http";
|
|
3
4
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
@@ -90,3 +91,4 @@ export class ProcessesController {
|
|
|
90
91
|
return { data: this.definitions.tools() };
|
|
91
92
|
}
|
|
92
93
|
}
|
|
94
|
+
contextOnlyController(ProcessesController);
|
|
@@ -9,6 +9,7 @@ import { WorkflowWorker } from "./background/WorkflowWorker";
|
|
|
9
9
|
import { workflowRuntimeConfig } from "./config/Runtime.config";
|
|
10
10
|
import { InstancesController } from "./http/Instances.controller";
|
|
11
11
|
import { GraphCache } from "./internal/GraphCache";
|
|
12
|
+
import { TimerStep } from "./internal/TimerStep";
|
|
12
13
|
import { ToolStep } from "./internal/ToolStep";
|
|
13
14
|
import { WorkflowEngine } from "./internal/WorkflowEngine";
|
|
14
15
|
import { RUNTIME_ENTITIES, RuntimeDbContext, runtimeStore } from "./model/Runtime.dbContext";
|
|
@@ -35,6 +36,7 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
|
|
|
35
36
|
resolver.tryResolve(LOGGER),
|
|
36
37
|
))),
|
|
37
38
|
scoped(ToolStep, ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools] as const),
|
|
39
|
+
scoped(TimerStep, TimerStep, [RuntimeDbContext, WorkflowEngine] as const),
|
|
38
40
|
// The application's input validator, if it registered one anywhere; the built-in one otherwise.
|
|
39
41
|
DI.scoped(DI.factoryProviderWithResolver(IWorkflowRuntime, [], (resolver) => new WorkflowRuntime(
|
|
40
42
|
resolver.resolve(RuntimeDbContext),
|
|
@@ -2,12 +2,16 @@ import { BackgroundService, delay } from "../../../background";
|
|
|
2
2
|
import type { ServiceProvider } from "../../../di";
|
|
3
3
|
import type { ConfigView } from "../../../kernel";
|
|
4
4
|
import type { workflowRuntimeConfig } from "../config/Runtime.config";
|
|
5
|
+
import { TimerStep } from "../internal/TimerStep";
|
|
5
6
|
import { ToolStep } from "../internal/ToolStep";
|
|
7
|
+
import { WorkflowEngine } from "../internal/WorkflowEngine";
|
|
6
8
|
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
7
9
|
import type { WfJob } from "../model/WfJob.model";
|
|
8
10
|
|
|
9
11
|
/** How long a claimed job belongs to this worker; if it dies, another one takes the job after that. */
|
|
10
12
|
const LEASE_MS = 5 * 60_000;
|
|
13
|
+
/** A job that keeps failing outside its Tool (the database, a lost version) fails its step after this many tries. */
|
|
14
|
+
const MAX_JOB_ATTEMPTS = 5;
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
/**
|
|
@@ -48,9 +52,32 @@ export class WorkflowWorker extends BackgroundService {
|
|
|
48
52
|
private async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
49
53
|
const scope = this.services.createScope();
|
|
50
54
|
try {
|
|
51
|
-
await scope.resolve(
|
|
55
|
+
if (job.kind === "timer") await scope.resolve(TimerStep).run(job);
|
|
56
|
+
else await scope.resolve(ToolStep).run(job, signal);
|
|
52
57
|
} catch (error) {
|
|
53
|
-
this.reportFailure("job failed", error, { job: job.id, instance: job.instanceId });
|
|
58
|
+
this.reportFailure("job failed", error, { job: job.id, instance: job.instanceId, attempts: job.attempts });
|
|
59
|
+
await this.giveUpOrDelay(job, error).catch((failure: unknown) => this.reportFailure("job failure handling failed", failure, { job: job.id }));
|
|
60
|
+
} finally {
|
|
61
|
+
await scope.dispose();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A job that failed outside its Tool waits a minute per try, then fails its step for a person to retry. */
|
|
66
|
+
private async giveUpOrDelay(job: WfJob, error: unknown): Promise<void> {
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
68
|
+
const scope = this.services.createScope();
|
|
69
|
+
try {
|
|
70
|
+
const db = scope.resolve(RuntimeDbContext);
|
|
71
|
+
if (job.attempts < MAX_JOB_ATTEMPTS || job.instanceId === null || job.tokenId === null) {
|
|
72
|
+
await db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ dueAt: new Date(Date.now() + job.attempts * 60_000), lockedUntil: null, lastError: message.slice(0, 1000) });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const engine = scope.resolve(WorkflowEngine);
|
|
76
|
+
await engine.inStep(job.instanceId, async (step) => {
|
|
77
|
+
await db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
78
|
+
const token = step.tokens.find((t) => t.id === job.tokenId);
|
|
79
|
+
if (token && token.status === "waiting" && step.instance.status === "running") engine.fail(step, token, "job_failed", message.slice(0, 1000));
|
|
80
|
+
});
|
|
54
81
|
} finally {
|
|
55
82
|
await scope.dispose();
|
|
56
83
|
}
|
|
@@ -63,7 +90,7 @@ export class WorkflowWorker extends BackgroundService {
|
|
|
63
90
|
return await db.transactionScope(async () => {
|
|
64
91
|
const now = new Date();
|
|
65
92
|
const jobs = await db.jobs
|
|
66
|
-
.where((j) => j.kind.
|
|
93
|
+
.where((j) => j.kind.in(["tool", "timer"]).and(j.dueAt.lte(now)).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
|
|
67
94
|
.orderBy((j) => j.id)
|
|
68
95
|
.take(batchSize)
|
|
69
96
|
.forUpdate({ skipLocked: true })
|
|
@@ -20,7 +20,8 @@ export interface WorkflowTaskRequested {
|
|
|
20
20
|
export interface WorkflowTokenClosed {
|
|
21
21
|
readonly instanceId: string;
|
|
22
22
|
readonly tokenId: string;
|
|
23
|
-
|
|
23
|
+
/** `cancelled` — the instance was cancelled; `interrupted` — a boundary timer took over the step. */
|
|
24
|
+
readonly reason: "cancelled" | "interrupted";
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** An instance finished: completed or cancelled. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
1
2
|
import { Controller, Created, Get, Post, type HttpContext } from "../../../http";
|
|
2
3
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
3
4
|
import { authorize, body, optionalString, pagingQuery, param, requiredString } from "../../internal/http";
|
|
@@ -69,4 +70,5 @@ export class InstancesController {
|
|
|
69
70
|
return await this.runtime.retry(instance.id, { actor });
|
|
70
71
|
}
|
|
71
72
|
}
|
|
73
|
+
contextOnlyController(InstancesController);
|
|
72
74
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ProcessDocument, ProcessFlow } from "../../../../library/workflow";
|
|
1
|
+
import type { ProcessDocument, ProcessFlow, ProcessNode } from "../../../../library/workflow";
|
|
2
2
|
|
|
3
3
|
/** A version's graph with flows indexed by node. Built once per version. */
|
|
4
4
|
export interface ProcessGraph {
|
|
@@ -8,6 +8,8 @@ export interface ProcessGraph {
|
|
|
8
8
|
readonly document: ProcessDocument;
|
|
9
9
|
readonly outgoing: ReadonlyMap<string, readonly (readonly [string, ProcessFlow])[]>;
|
|
10
10
|
readonly incoming: ReadonlyMap<string, readonly (readonly [string, ProcessFlow])[]>;
|
|
11
|
+
/** Boundary nodes by the step they are attached to. */
|
|
12
|
+
readonly boundaries: ReadonlyMap<string, readonly (readonly [string, ProcessNode])[]>;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function buildGraph(versionId: string, processKey: string, version: number, document: ProcessDocument): ProcessGraph {
|
|
@@ -17,7 +19,11 @@ export function buildGraph(versionId: string, processKey: string, version: numbe
|
|
|
17
19
|
outgoing.set(flow.source, [...outgoing.get(flow.source) ?? [], [id, flow]]);
|
|
18
20
|
incoming.set(flow.target, [...incoming.get(flow.target) ?? [], [id, flow]]);
|
|
19
21
|
}
|
|
20
|
-
|
|
22
|
+
const boundaries = new Map<string, (readonly [string, ProcessNode])[]>();
|
|
23
|
+
for (const [id, node] of Object.entries(document.nodes)) {
|
|
24
|
+
if (node.attachedTo !== undefined) boundaries.set(node.attachedTo, [...boundaries.get(node.attachedTo) ?? [], [id, node]]);
|
|
25
|
+
}
|
|
26
|
+
return { versionId, processKey, version, document, outgoing, incoming, boundaries };
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/** Graphs of versions in memory. Versions are immutable, so entries never go stale; the oldest go first past the limit. */
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
2
|
+
import type { WfJob } from "../model/WfJob.model";
|
|
3
|
+
import type { WorkflowEngine } from "./WorkflowEngine";
|
|
4
|
+
|
|
5
|
+
/** One `timer` job: a `timerCatch` step or a boundary timer is due. A closed token drops it. */
|
|
6
|
+
export class TimerStep {
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly db: RuntimeDbContext,
|
|
9
|
+
private readonly engine: WorkflowEngine,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
async run(job: WfJob): Promise<void> {
|
|
13
|
+
const fired = job.instanceId === null || job.tokenId === null
|
|
14
|
+
? null
|
|
15
|
+
: await this.engine.inStep(job.instanceId, async (step) => {
|
|
16
|
+
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
17
|
+
return await this.engine.timerFired(step, job.tokenId!);
|
|
18
|
+
});
|
|
19
|
+
if (fired === null) await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentToolResult } from "../../../agent";
|
|
2
|
-
import { resolveInput } from "../../../../library/workflow";
|
|
2
|
+
import { durationMs, resolveInput } from "../../../../library/workflow";
|
|
3
3
|
import type { WorkflowTools } from "../../internal/WorkflowTools";
|
|
4
4
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
5
5
|
import type { WfJob } from "../model/WfJob.model";
|
|
@@ -40,21 +40,57 @@ export class ToolStep {
|
|
|
40
40
|
signal,
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
const reason = reasonOf(result);
|
|
43
44
|
await this.engine.inStep(instance.id, async (step) => {
|
|
44
|
-
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
45
45
|
const current = step.tokens.find((t) => t.id === token.id);
|
|
46
|
+
const retryAt = current?.status === "waiting" && step.instance.status === "running" && result.status === "error" && reason === undefined
|
|
47
|
+
? nextAttempt(node?.config?.retry, job.attempts)
|
|
48
|
+
: undefined;
|
|
49
|
+
if (retryAt !== undefined) {
|
|
50
|
+
// The same job runs again later: its attempts keep counting.
|
|
51
|
+
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ dueAt: retryAt, lockedUntil: null, lastError: result.error?.message ?? null });
|
|
52
|
+
} else {
|
|
53
|
+
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
54
|
+
}
|
|
46
55
|
if (!current || current.status !== "waiting" || step.instance.status !== "running") return;
|
|
47
56
|
this.engine.history(step, current, current.nodeId, "tool", {
|
|
48
57
|
tool: name,
|
|
49
58
|
status: result.status,
|
|
59
|
+
attempt: job.attempts,
|
|
50
60
|
durationMs: result.durationMs ?? null,
|
|
51
|
-
...(result.error ? { error: { code: result.error.code ?? null, message: result.error.message } } : {}),
|
|
61
|
+
...(result.error ? { error: { code: result.error.code ?? null, message: result.error.message, ...(reason === undefined ? {} : { reason }) } } : {}),
|
|
62
|
+
...(retryAt === undefined ? {} : { retryAt: retryAt.toISOString() }),
|
|
52
63
|
});
|
|
64
|
+
if (retryAt !== undefined) return;
|
|
53
65
|
if (result.status === "success") {
|
|
54
66
|
await this.engine.complete(step, current.id, result.output ?? null);
|
|
55
67
|
} else {
|
|
56
|
-
this.engine.
|
|
68
|
+
await this.engine.failStep(step, current, {
|
|
69
|
+
code: result.error?.code ?? "TOOL_FAILED",
|
|
70
|
+
message: result.error?.message ?? `Tool ${name} failed`,
|
|
71
|
+
...(reason === undefined ? {} : { reason }),
|
|
72
|
+
});
|
|
57
73
|
}
|
|
58
74
|
});
|
|
59
75
|
}
|
|
60
76
|
}
|
|
77
|
+
|
|
78
|
+
/** The business reason of a `ToolError`, if the Tool gave one. */
|
|
79
|
+
function reasonOf(result: AgentToolResult): string | undefined {
|
|
80
|
+
const details = result.error?.details;
|
|
81
|
+
const reason = typeof details === "object" && details !== null && !Array.isArray(details) ? (details as Record<string, unknown>).reason : undefined;
|
|
82
|
+
return typeof reason === "string" ? reason : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* When to try a failed call again under the node's `retry` policy, or
|
|
87
|
+
* `undefined` when the attempts are used up. The pause doubles from `backoff`
|
|
88
|
+
* (10 seconds by default) and stops growing at a day.
|
|
89
|
+
*/
|
|
90
|
+
export function nextAttempt(policy: unknown, attempts: number): Date | undefined {
|
|
91
|
+
if (typeof policy !== "object" || policy === null) return undefined;
|
|
92
|
+
const { maxAttempts, backoff } = policy as { maxAttempts?: unknown; backoff?: unknown };
|
|
93
|
+
if (typeof maxAttempts !== "number" || attempts >= maxAttempts) return undefined;
|
|
94
|
+
const base = typeof backoff === "string" ? durationMs(backoff) ?? 10_000 : 10_000;
|
|
95
|
+
return new Date(Date.now() + Math.min(base * 2 ** Math.max(attempts - 1, 0), 86_400_000));
|
|
96
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ServiceResolver } from "../../../di";
|
|
2
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";
|
|
3
|
+
import { durationMs, evaluateCondition, nodeKindOf, readPath, type FormSpec, type InstanceData, type ProcessFlow, type ProcessNode } from "../../../../library/workflow";
|
|
4
4
|
import type { WorkflowActor } from "../../contracts/types";
|
|
5
5
|
import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
6
6
|
import { WORKFLOW_INSTANCE_FINISHED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED } from "../events/Runtime.events";
|
|
@@ -37,7 +37,7 @@ export interface BeginOptions {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** Node kinds that come with later stages: an instance reaching one fails with a clear reason. */
|
|
40
|
-
const NOT_YET = new Set(["
|
|
40
|
+
const NOT_YET = new Set(["eventWait"]);
|
|
41
41
|
const LIVE = new Set(["active", "waiting", "failed"]);
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -132,12 +132,68 @@ export class WorkflowEngine {
|
|
|
132
132
|
const saveTo = node.config?.saveTo;
|
|
133
133
|
if (typeof saveTo === "string") step.instance.vars = { ...step.instance.vars, [saveTo]: output ?? null };
|
|
134
134
|
if (actor !== undefined) this.history(step, token, token.nodeId, "completed", null, actor);
|
|
135
|
+
this.disarm(step, token.nodeId);
|
|
136
|
+
await this.advance(step, this.leave(step, token, this.single(step, token.nodeId)));
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A step's Tool failed for good. An error boundary attached to the step takes
|
|
142
|
+
* the token (first one whose `errorCode` equals the Tool's `reason` or error
|
|
143
|
+
* code, else one without `errorCode`); otherwise the instance fails.
|
|
144
|
+
*/
|
|
145
|
+
async failStep(step: Step, token: WfToken, error: { readonly code: string; readonly message: string; readonly reason?: string }): Promise<void> {
|
|
146
|
+
const boundaries = (step.graph.boundaries.get(token.nodeId) ?? []).filter(([, node]) => node.kind === "boundaryError");
|
|
147
|
+
const matching = boundaries.find(([, node]) => node.config?.errorCode !== undefined && (node.config.errorCode === error.reason || node.config.errorCode === error.code))
|
|
148
|
+
?? boundaries.find(([, node]) => node.config?.errorCode === undefined);
|
|
149
|
+
if (!matching) {
|
|
150
|
+
this.fail(step, token, error.code, error.message);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const [boundaryId] = matching;
|
|
154
|
+
const caught = { code: error.code, message: error.message, ...(error.reason === undefined ? {} : { reason: error.reason }) };
|
|
155
|
+
token.output = { error: caught };
|
|
156
|
+
this.close(token, "done");
|
|
157
|
+
this.disarm(step, token.nodeId);
|
|
158
|
+
this.history(step, token, token.nodeId, "error", { ...caught, boundary: boundaryId });
|
|
159
|
+
step.instance.nodes = { ...step.instance.nodes, [boundaryId]: caught };
|
|
160
|
+
await this.advance(step, [this.spawn(step, boundaryId, null)]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A timer job is due. A `timerCatch` step moves on. A `boundaryTimer` takes
|
|
165
|
+
* its own flow; an interrupting one (the default) first closes the step it
|
|
166
|
+
* watches. `false` when the token no longer waits.
|
|
167
|
+
*/
|
|
168
|
+
async timerFired(step: Step, tokenId: string): Promise<boolean> {
|
|
169
|
+
const token = step.tokens.find((t) => t.id === tokenId);
|
|
170
|
+
if (!token || token.status !== "waiting" || step.instance.status !== "running") return false;
|
|
171
|
+
const node = step.graph.document.nodes[token.nodeId];
|
|
172
|
+
const firedAt = new Date().toISOString();
|
|
173
|
+
if (node?.kind === "timerCatch") {
|
|
174
|
+
this.history(step, token, token.nodeId, "timer", { firedAt });
|
|
175
|
+
return await this.complete(step, tokenId, { firedAt });
|
|
176
|
+
}
|
|
177
|
+
if (node?.kind !== "boundaryTimer" || node.attachedTo === undefined) return false;
|
|
178
|
+
const interrupting = node.config?.interrupting !== false;
|
|
179
|
+
this.history(step, token, token.nodeId, "timer", { firedAt, interrupting });
|
|
180
|
+
if (interrupting) {
|
|
181
|
+
for (const host of step.tokens.filter((t) => t.nodeId === node.attachedTo && LIVE.has(t.status))) {
|
|
182
|
+
this.close(host, "cancelled");
|
|
183
|
+
if (step.graph.document.nodes[host.nodeId]?.kind === "userTask") {
|
|
184
|
+
step.events.push({ event: WORKFLOW_TOKEN_CLOSED as EventToken<unknown>, payload: { instanceId: step.instance.id, tokenId: host.id, reason: "interrupted" } });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
this.disarm(step, node.attachedTo, token.id);
|
|
188
|
+
}
|
|
189
|
+
step.instance.nodes = { ...step.instance.nodes, [token.nodeId]: { firedAt } };
|
|
135
190
|
await this.advance(step, this.leave(step, token, this.single(step, token.nodeId)));
|
|
136
191
|
return true;
|
|
137
192
|
}
|
|
138
193
|
|
|
139
194
|
/** A step failed: the token waits for a retry, the instance is `failed` with the reason. */
|
|
140
195
|
fail(step: Step, token: WfToken, code: string, message: string): void {
|
|
196
|
+
this.disarm(step, token.nodeId);
|
|
141
197
|
token.status = "failed";
|
|
142
198
|
step.instance.status = "failed";
|
|
143
199
|
step.instance.error = { node: token.nodeId, code, message };
|
|
@@ -219,15 +275,19 @@ export class WorkflowEngine {
|
|
|
219
275
|
this.close(token, "done");
|
|
220
276
|
return [];
|
|
221
277
|
}
|
|
278
|
+
// An error boundary that caught a step's failure: it only leads on.
|
|
279
|
+
if (role === "boundary") return this.leave(step, token, this.single(step, token.nodeId));
|
|
222
280
|
if (NOT_YET.has(node.kind)) {
|
|
223
281
|
this.fail(step, token, "not_supported", `${node.kind} steps are not supported yet`);
|
|
224
282
|
return [];
|
|
225
283
|
}
|
|
226
284
|
switch (node.kind) {
|
|
227
285
|
case "serviceTask":
|
|
228
|
-
return this.waitForTool(step, token);
|
|
286
|
+
return this.arm(step, token, this.waitForTool(step, token));
|
|
229
287
|
case "userTask":
|
|
230
|
-
return this.waitForTask(step, token, node);
|
|
288
|
+
return this.arm(step, token, this.waitForTask(step, token, node));
|
|
289
|
+
case "timerCatch":
|
|
290
|
+
return this.arm(step, token, this.waitForTimer(step, token, node));
|
|
231
291
|
case "exclusiveGateway":
|
|
232
292
|
return this.choose(step, token);
|
|
233
293
|
case "parallelGateway":
|
|
@@ -238,6 +298,46 @@ export class WorkflowEngine {
|
|
|
238
298
|
}
|
|
239
299
|
}
|
|
240
300
|
|
|
301
|
+
private waitForTimer(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
|
|
302
|
+
const dueAt = timerDue(node.config ?? {}, dataOf(step.instance));
|
|
303
|
+
if (dueAt === undefined) {
|
|
304
|
+
this.fail(step, token, "bad_timer", `the timer of ${token.nodeId} has no valid date`);
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
token.status = "waiting";
|
|
308
|
+
this.db.jobs.add(Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "timer" as const, instanceId: step.instance.id, tokenId: token.id, dueAt }));
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A step started waiting: its boundary timers start too, each as a waiting
|
|
314
|
+
* token on the boundary node with a timer job.
|
|
315
|
+
*/
|
|
316
|
+
private arm(step: Step, host: WfToken, next: WfToken[]): WfToken[] {
|
|
317
|
+
if (host.status !== "waiting") return next;
|
|
318
|
+
for (const [id, node] of step.graph.boundaries.get(host.nodeId) ?? []) {
|
|
319
|
+
if (node.kind !== "boundaryTimer") continue;
|
|
320
|
+
const dueAt = timerDue(node.config ?? {}, dataOf(step.instance));
|
|
321
|
+
if (dueAt === undefined) {
|
|
322
|
+
this.fail(step, host, "bad_timer", `the timer of ${id} has no valid date`);
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
const watcher = this.spawn(step, id, null);
|
|
326
|
+
watcher.status = "waiting";
|
|
327
|
+
this.db.jobs.add(Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "timer" as const, instanceId: step.instance.id, tokenId: watcher.id, dueAt }));
|
|
328
|
+
}
|
|
329
|
+
return next;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The step left or failed: its boundary tokens close; their jobs find the tokens closed and drop. */
|
|
333
|
+
private disarm(step: Step, hostNodeId: string, except?: string): void {
|
|
334
|
+
const boundaries = new Set((step.graph.boundaries.get(hostNodeId) ?? []).map(([id]) => id));
|
|
335
|
+
if (boundaries.size === 0) return;
|
|
336
|
+
for (const token of step.tokens) {
|
|
337
|
+
if (token.id !== except && boundaries.has(token.nodeId) && LIVE.has(token.status)) this.close(token, "cancelled");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
241
341
|
private waitForTool(step: Step, token: WfToken): WfToken[] {
|
|
242
342
|
token.status = "waiting";
|
|
243
343
|
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() }));
|
|
@@ -351,6 +451,20 @@ export class WorkflowEngine {
|
|
|
351
451
|
}
|
|
352
452
|
}
|
|
353
453
|
|
|
454
|
+
/** When a timer fires: `duration` from now, or the date at the `at` path; `undefined` if neither is valid. */
|
|
455
|
+
export function timerDue(config: Readonly<Record<string, unknown>>, data: InstanceData): Date | undefined {
|
|
456
|
+
if (typeof config.duration === "string") {
|
|
457
|
+
const ms = durationMs(config.duration);
|
|
458
|
+
return ms === undefined ? undefined : new Date(Date.now() + ms);
|
|
459
|
+
}
|
|
460
|
+
if (typeof config.at === "string") {
|
|
461
|
+
const value = readPath(data, config.at);
|
|
462
|
+
const date = value instanceof Date ? value : typeof value === "string" || typeof value === "number" ? new Date(value) : undefined;
|
|
463
|
+
return date === undefined || Number.isNaN(date.getTime()) ? undefined : date;
|
|
464
|
+
}
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
354
468
|
export function dataOf(instance: WfInstance): InstanceData {
|
|
355
469
|
return { input: instance.input, vars: instance.vars, nodes: instance.nodes };
|
|
356
470
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.125.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",
|