bazis 0.121.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/cli/templates/module.ts +5 -4
- package/cli/templates/passport.ts +1 -1
- package/core/agent/AgentRuntime.ts +7 -2
- package/core/agent/AgentToolExecutor.ts +34 -11
- package/core/agent/README.md +10 -9
- package/core/agent/ToolRunner.ts +135 -0
- package/core/agent/index.ts +3 -0
- package/core/agent/internal/AgentExecutionDriver.ts +9 -2
- package/core/agent/internal/ToolAudit.projector.ts +16 -0
- package/core/agent/metadata.ts +25 -0
- package/core/agent/moduleContributions-v1.ts +5 -1
- package/core/agent/toolExecutionOptions.ts +15 -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,36 @@
|
|
|
1
|
+
import type { ServiceResolver } from "../../di";
|
|
2
|
+
import { ToolRunner, type AgentToolContract, type BoundToolRunner } from "../../agent";
|
|
3
|
+
|
|
4
|
+
/** The workflow caller id of TOOLS-001: processes run only Tools whose `callers` list it. */
|
|
5
|
+
export const WORKFLOW_CALLER = "workflow";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The application's Tools as processes see them. `ToolRunner` exists only when
|
|
9
|
+
* a module declares `tools`; without it there is nothing to call.
|
|
10
|
+
*/
|
|
11
|
+
export class WorkflowTools {
|
|
12
|
+
private readonly runner: BoundToolRunner | undefined;
|
|
13
|
+
|
|
14
|
+
constructor(services: ServiceResolver) {
|
|
15
|
+
this.runner = services.tryResolve(ToolRunner)?.as(WORKFLOW_CALLER);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether a service task may call `name`. */
|
|
19
|
+
has(name: string): boolean {
|
|
20
|
+
return this.runner?.getTool(name) !== undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Contracts of every Tool processes may call, by name. */
|
|
24
|
+
list(): readonly AgentToolContract[] {
|
|
25
|
+
if (this.runner === undefined) return [];
|
|
26
|
+
return this.runner.listTools()
|
|
27
|
+
.map((tool) => this.runner!.describe(tool.metadata.name))
|
|
28
|
+
.filter((contract): contract is AgentToolContract => contract !== undefined)
|
|
29
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The bound runner; `undefined` when the application has no Tools. */
|
|
33
|
+
get bound(): BoundToolRunner | undefined {
|
|
34
|
+
return this.runner;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ForbiddenError, UnauthorizedError, type HttpContext } from "../../http";
|
|
2
|
+
import { WorkflowAccess, type WorkflowAccessTarget, type WorkflowAction } from "../access/WorkflowAccess";
|
|
3
|
+
import type { WorkflowActor } from "../contracts/types";
|
|
4
|
+
import { WorkflowError } from "../contracts/WorkflowError";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Asks the application's {@link WorkflowAccess} who calls and whether the action
|
|
8
|
+
* is allowed. Without a registered access every route answers 403.
|
|
9
|
+
*/
|
|
10
|
+
export async function authorize(ctx: HttpContext, action: WorkflowAction, target: WorkflowAccessTarget = {}): Promise<WorkflowActor> {
|
|
11
|
+
const access = ctx.services.tryResolve(WorkflowAccess);
|
|
12
|
+
if (access === undefined) throw new ForbiddenError("Workflow HTTP is closed: register a WorkflowAccess implementation");
|
|
13
|
+
const actor = await access.actor(ctx);
|
|
14
|
+
if (actor === undefined) throw new UnauthorizedError();
|
|
15
|
+
if (!await access.can(ctx, action, target)) throw new ForbiddenError();
|
|
16
|
+
return actor;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A route parameter as a string. */
|
|
20
|
+
export function param(ctx: HttpContext, name: string): string {
|
|
21
|
+
return String(ctx.params[name] ?? "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The JSON body as an object; anything else is `invalid_input`. */
|
|
25
|
+
export async function body(ctx: HttpContext): Promise<Record<string, unknown>> {
|
|
26
|
+
const text = await ctx.text();
|
|
27
|
+
if (text.trim() === "") return {};
|
|
28
|
+
let value: unknown;
|
|
29
|
+
try {
|
|
30
|
+
value = JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new WorkflowError("invalid_input", "The body must be JSON");
|
|
33
|
+
}
|
|
34
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new WorkflowError("invalid_input", "The body must be a JSON object");
|
|
35
|
+
return value as Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An optional string field of a body or a query; another type is `invalid_input`. */
|
|
39
|
+
export function optionalString(value: unknown, name: string, max = 200): string | undefined {
|
|
40
|
+
if (value === undefined || value === null) return undefined;
|
|
41
|
+
if (typeof value !== "string" || value.length > max) throw new WorkflowError("invalid_input", `${name} must be a string up to ${max} characters`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A required string field. */
|
|
46
|
+
export function requiredString(value: unknown, name: string, max = 200): string {
|
|
47
|
+
const text = optionalString(value, name, max);
|
|
48
|
+
if (text === undefined || text.trim() === "") throw new WorkflowError("invalid_input", `${name} is required`);
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An optional integer from a query string or a body. */
|
|
53
|
+
export function optionalInteger(value: unknown, name: string): number | undefined {
|
|
54
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
55
|
+
const number = typeof value === "string" ? Number(value) : value;
|
|
56
|
+
if (!Number.isSafeInteger(number)) throw new WorkflowError("invalid_input", `${name} must be an integer`);
|
|
57
|
+
return number as number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `limit` and `offset` of a list query. */
|
|
61
|
+
export function pagingQuery(ctx: HttpContext): { limit?: number; offset?: number } {
|
|
62
|
+
return { limit: optionalInteger(ctx.query("limit"), "limit"), offset: optionalInteger(ctx.query("offset"), "offset") };
|
|
63
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DI, Module, SERVICE_PROVIDER, scoped } from "../../di";
|
|
2
|
+
import { EventBus, LOGGER } from "../../kernel";
|
|
3
|
+
import { DefaultWorkflowInputValidator, WorkflowInputValidator } from "../contracts/WorkflowInputValidator";
|
|
4
|
+
import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
|
|
5
|
+
import { IWorkflowDefinitions } from "../definitions_module/services/IWorkflowDefinitions.service";
|
|
6
|
+
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
7
|
+
import { WorkflowRetention } from "./background/WorkflowRetention";
|
|
8
|
+
import { WorkflowWorker } from "./background/WorkflowWorker";
|
|
9
|
+
import { workflowRuntimeConfig } from "./config/Runtime.config";
|
|
10
|
+
import { InstancesController } from "./http/Instances.controller";
|
|
11
|
+
import { GraphCache } from "./internal/GraphCache";
|
|
12
|
+
import { ToolStep } from "./internal/ToolStep";
|
|
13
|
+
import { WorkflowEngine } from "./internal/WorkflowEngine";
|
|
14
|
+
import { RUNTIME_ENTITIES, RuntimeDbContext, runtimeStore } from "./model/Runtime.dbContext";
|
|
15
|
+
import { IWorkflowRuntime } from "./services/IWorkflowRuntime.service";
|
|
16
|
+
import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs published versions: instances, tokens, jobs, history, the worker and
|
|
20
|
+
* retention ([passport](MODULE.md)). Dependencies are explicit: the framework's
|
|
21
|
+
* classes are not covered by the application's codegen.
|
|
22
|
+
*/
|
|
23
|
+
@Module({
|
|
24
|
+
imports: [WorkflowDefinitionsModule],
|
|
25
|
+
config: [workflowRuntimeConfig],
|
|
26
|
+
ormBazis: { context: RuntimeDbContext, entities: [...RUNTIME_ENTITIES], ownedStore: runtimeStore, registerRepositories: false },
|
|
27
|
+
providers: [
|
|
28
|
+
DI.singleton(DI.factoryProvider(GraphCache, [], () => new GraphCache())),
|
|
29
|
+
DI.scoped(DI.factoryProviderWithResolver(WorkflowEngine, [], (resolver) => new WorkflowEngine(
|
|
30
|
+
resolver.resolve(RuntimeDbContext),
|
|
31
|
+
resolver.resolve(IWorkflowDefinitions),
|
|
32
|
+
resolver.resolve(GraphCache),
|
|
33
|
+
resolver.resolve(EventBus),
|
|
34
|
+
resolver,
|
|
35
|
+
resolver.tryResolve(LOGGER),
|
|
36
|
+
))),
|
|
37
|
+
scoped(ToolStep, ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools] as const),
|
|
38
|
+
// The application's input validator, if it registered one anywhere; the built-in one otherwise.
|
|
39
|
+
DI.scoped(DI.factoryProviderWithResolver(IWorkflowRuntime, [], (resolver) => new WorkflowRuntime(
|
|
40
|
+
resolver.resolve(RuntimeDbContext),
|
|
41
|
+
resolver.resolve(WorkflowEngine),
|
|
42
|
+
resolver.resolve(IWorkflowDefinitions),
|
|
43
|
+
resolver.resolve(SERVICE_PROVIDER).tryResolve(WorkflowInputValidator) ?? new DefaultWorkflowInputValidator(),
|
|
44
|
+
))),
|
|
45
|
+
],
|
|
46
|
+
controllers: [DI.bindDeps(InstancesController, IWorkflowRuntime)],
|
|
47
|
+
background: [
|
|
48
|
+
DI.bindDeps(WorkflowWorker, SERVICE_PROVIDER, workflowRuntimeConfig.token),
|
|
49
|
+
DI.bindDeps(WorkflowRetention, SERVICE_PROVIDER, workflowRuntimeConfig.token),
|
|
50
|
+
],
|
|
51
|
+
exports: [IWorkflowRuntime],
|
|
52
|
+
})
|
|
53
|
+
export class WorkflowRuntimeModule {}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { BackgroundService, delay } from "../../../background";
|
|
2
|
+
import type { ServiceProvider } from "../../../di";
|
|
3
|
+
import type { ConfigView } from "../../../kernel";
|
|
4
|
+
import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
5
|
+
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
6
|
+
import type { WorkflowSettings } from "./WorkflowWorker";
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deletes `completed` and `cancelled` instances older than their process's
|
|
12
|
+
* `retentionDays`, with tokens, jobs, subscriptions and history (by cascade),
|
|
13
|
+
* `retention.batchSize` at a time. `failed` instances wait for a person and stay.
|
|
14
|
+
*/
|
|
15
|
+
export class WorkflowRetention extends BackgroundService {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly services: ServiceProvider,
|
|
18
|
+
private readonly config: ConfigView<WorkflowSettings>,
|
|
19
|
+
) {
|
|
20
|
+
super();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
protected override async execute(signal: AbortSignal): Promise<void> {
|
|
24
|
+
if (!this.config.get("retention.enabled")) return;
|
|
25
|
+
const intervalMs = this.config.get("retention.intervalMs");
|
|
26
|
+
while (!signal.aborted) {
|
|
27
|
+
try {
|
|
28
|
+
await this.sweep(signal);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (signal.aborted) return;
|
|
31
|
+
this.reportFailure("retention sweep failed", error);
|
|
32
|
+
}
|
|
33
|
+
await delay(intervalMs, signal);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One pass over every process with a retention period; returns how many instances went. */
|
|
38
|
+
async sweep(signal?: AbortSignal): Promise<number> {
|
|
39
|
+
const batchSize = this.config.get("retention.batchSize");
|
|
40
|
+
const scope = this.services.createScope();
|
|
41
|
+
let deleted = 0;
|
|
42
|
+
try {
|
|
43
|
+
const definitions = scope.resolve(IWorkflowDefinitions);
|
|
44
|
+
const db = scope.resolve(RuntimeDbContext);
|
|
45
|
+
for (const policy of await definitions.retentionPolicies()) {
|
|
46
|
+
const cutoff = new Date(Date.now() - policy.retentionDays * DAY_MS);
|
|
47
|
+
for (;;) {
|
|
48
|
+
if (signal?.aborted) return deleted;
|
|
49
|
+
const ids = (await db.instances.asNoTracking()
|
|
50
|
+
.where((i) => i.processKey.eq(policy.key).and(i.status.in(["completed", "cancelled"])).and(i.endedAt.lt(cutoff)))
|
|
51
|
+
.orderBy((i) => i.id)
|
|
52
|
+
.take(batchSize)
|
|
53
|
+
.select((i) => ({ id: i.id }))
|
|
54
|
+
.toList()).map((row) => row.id);
|
|
55
|
+
if (ids.length === 0) break;
|
|
56
|
+
await db.instances.asNoTracking().where((i) => i.id.in(ids)).executeDelete();
|
|
57
|
+
deleted += ids.length;
|
|
58
|
+
if (ids.length < batchSize) break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await scope.dispose();
|
|
63
|
+
}
|
|
64
|
+
return deleted;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { BackgroundService, delay } from "../../../background";
|
|
2
|
+
import type { ServiceProvider } from "../../../di";
|
|
3
|
+
import type { ConfigView } from "../../../kernel";
|
|
4
|
+
import type { workflowRuntimeConfig } from "../config/Runtime.config";
|
|
5
|
+
import { ToolStep } from "../internal/ToolStep";
|
|
6
|
+
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
7
|
+
import type { WfJob } from "../model/WfJob.model";
|
|
8
|
+
|
|
9
|
+
/** How long a claimed job belongs to this worker; if it dies, another one takes the job after that. */
|
|
10
|
+
const LEASE_MS = 5 * 60_000;
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Takes due jobs of `bazis_wf_run_jobs` every `worker.intervalMs`, up to
|
|
15
|
+
* `worker.batchSize` at a time, with `FOR UPDATE SKIP LOCKED` and a lease, so
|
|
16
|
+
* application instances never take one job twice. A full batch is followed by
|
|
17
|
+
* the next one at once. Every job runs in its own DI scope.
|
|
18
|
+
*/
|
|
19
|
+
export class WorkflowWorker extends BackgroundService {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly services: ServiceProvider,
|
|
22
|
+
private readonly config: ConfigView<WorkflowSettings>,
|
|
23
|
+
) {
|
|
24
|
+
super();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected override async execute(signal: AbortSignal): Promise<void> {
|
|
28
|
+
if (!this.config.get("worker.enabled")) return;
|
|
29
|
+
const intervalMs = this.config.get("worker.intervalMs");
|
|
30
|
+
const batchSize = this.config.get("worker.batchSize");
|
|
31
|
+
while (!signal.aborted) {
|
|
32
|
+
let claimed = 0;
|
|
33
|
+
try {
|
|
34
|
+
const jobs = await this.claim(batchSize);
|
|
35
|
+
claimed = jobs.length;
|
|
36
|
+
for (const job of jobs) {
|
|
37
|
+
if (signal.aborted) return;
|
|
38
|
+
await this.run(job, signal);
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (signal.aborted) return;
|
|
42
|
+
this.reportFailure("claiming jobs failed", error);
|
|
43
|
+
}
|
|
44
|
+
if (claimed < batchSize) await delay(intervalMs, signal);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
49
|
+
const scope = this.services.createScope();
|
|
50
|
+
try {
|
|
51
|
+
await scope.resolve(ToolStep).run(job, signal);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
this.reportFailure("job failed", error, { job: job.id, instance: job.instanceId });
|
|
54
|
+
} finally {
|
|
55
|
+
await scope.dispose();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async claim(batchSize: number): Promise<WfJob[]> {
|
|
60
|
+
const scope = this.services.createScope();
|
|
61
|
+
try {
|
|
62
|
+
const db = scope.resolve(RuntimeDbContext);
|
|
63
|
+
return await db.transactionScope(async () => {
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const jobs = await db.jobs
|
|
66
|
+
.where((j) => j.kind.eq("tool").and(j.dueAt.lte(now)).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
|
|
67
|
+
.orderBy((j) => j.id)
|
|
68
|
+
.take(batchSize)
|
|
69
|
+
.forUpdate({ skipLocked: true })
|
|
70
|
+
.toList();
|
|
71
|
+
for (const job of jobs) {
|
|
72
|
+
job.lockedUntil = new Date(now.getTime() + LEASE_MS);
|
|
73
|
+
job.attempts += 1;
|
|
74
|
+
}
|
|
75
|
+
await db.saveChanges();
|
|
76
|
+
return jobs;
|
|
77
|
+
});
|
|
78
|
+
} finally {
|
|
79
|
+
await scope.dispose();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The resolved settings of {@link workflowRuntimeConfig}. */
|
|
85
|
+
export interface WorkflowSettings {
|
|
86
|
+
readonly "worker.enabled": boolean;
|
|
87
|
+
readonly "worker.intervalMs": number;
|
|
88
|
+
readonly "worker.batchSize": number;
|
|
89
|
+
readonly "retention.enabled": boolean;
|
|
90
|
+
readonly "retention.intervalMs": number;
|
|
91
|
+
readonly "retention.batchSize": number;
|
|
92
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig } from "../../../kernel";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settings of the workflow background services (WORKFLOW-001 §4), from the
|
|
5
|
+
* application configuration: `BAZIS_WORKFLOW__WORKER__ENABLED=false` keeps an
|
|
6
|
+
* instance from running jobs, for example a web-only one.
|
|
7
|
+
*/
|
|
8
|
+
export const workflowRuntimeConfig = defineConfig("workflow", {
|
|
9
|
+
default: {
|
|
10
|
+
"worker.enabled": true,
|
|
11
|
+
"worker.intervalMs": 1000,
|
|
12
|
+
"worker.batchSize": 10,
|
|
13
|
+
"retention.enabled": true,
|
|
14
|
+
"retention.intervalMs": 3_600_000,
|
|
15
|
+
"retention.batchSize": 500,
|
|
16
|
+
},
|
|
17
|
+
validate: {
|
|
18
|
+
"worker.intervalMs": (value) => positive(value, 3_600_000),
|
|
19
|
+
"worker.batchSize": (value) => positive(value, 1000),
|
|
20
|
+
"retention.intervalMs": (value) => positive(value, 86_400_000),
|
|
21
|
+
"retention.batchSize": (value) => positive(value, 10_000),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function positive(value: unknown, max: number): string | undefined {
|
|
26
|
+
return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= max ? undefined : `must be an integer 1–${max}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createEventToken } from "../../../kernel";
|
|
2
|
+
import type { FormSpec } from "../../../../library/workflow";
|
|
3
|
+
import type { InstanceStatus } from "../model/WfInstance.model";
|
|
4
|
+
|
|
5
|
+
/** A token reached a user task. Published after the step commits. */
|
|
6
|
+
export interface WorkflowTaskRequested {
|
|
7
|
+
readonly instanceId: string;
|
|
8
|
+
readonly tokenId: string;
|
|
9
|
+
readonly processKey: string;
|
|
10
|
+
readonly nodeId: string;
|
|
11
|
+
readonly partition: string | null;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly assignee: { readonly role: string } | { readonly user: string };
|
|
14
|
+
readonly form: FormSpec | null;
|
|
15
|
+
/** When the task is due, from the node's `dueIn`. */
|
|
16
|
+
readonly dueAt: Date | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A waiting token was closed otherwise than by its task: the task is dropped. */
|
|
20
|
+
export interface WorkflowTokenClosed {
|
|
21
|
+
readonly instanceId: string;
|
|
22
|
+
readonly tokenId: string;
|
|
23
|
+
readonly reason: "cancelled";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An instance finished: completed or cancelled. */
|
|
27
|
+
export interface WorkflowInstanceFinished {
|
|
28
|
+
readonly instanceId: string;
|
|
29
|
+
readonly processKey: string;
|
|
30
|
+
readonly partition: string | null;
|
|
31
|
+
readonly status: Extract<InstanceStatus, "completed" | "cancelled">;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const WORKFLOW_TASK_REQUESTED = createEventToken<WorkflowTaskRequested>("workflow.task-requested");
|
|
35
|
+
export const WORKFLOW_TOKEN_CLOSED = createEventToken<WorkflowTokenClosed>("workflow.token-closed");
|
|
36
|
+
export const WORKFLOW_INSTANCE_FINISHED = createEventToken<WorkflowInstanceFinished>("workflow.instance-finished");
|
|
@@ -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
|
+
}
|