bazis 0.127.0 → 0.128.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/generateModule.ts +5 -5
- package/cli/generateProcess.ts +56 -0
- package/cli/main.ts +35 -1
- package/cli/moduleRegistration.ts +23 -10
- package/cli/parseCli.ts +45 -3
- package/cli/templates/process.ts +28 -0
- package/cli/workflow.ts +66 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/contracts/WorkflowClock.ts +21 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +14 -6
- package/core/workflow/definitions_module/code/CodeProcessCatalog.ts +19 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +44 -0
- package/core/workflow/definitions_module/code/codeProcesses.ts +85 -0
- package/core/workflow/definitions_module/code/defineProcess.ts +40 -0
- package/core/workflow/definitions_module/code/inspectProcesses.ts +74 -0
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +11 -2
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +106 -45
- package/core/workflow/index.ts +14 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +2 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +4 -2
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +11 -88
- package/core/workflow/runtime_module/internal/Schedules.ts +4 -2
- package/core/workflow/runtime_module/internal/ToolStep.ts +3 -3
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +18 -11
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +100 -0
- package/core/workflow/tasks_module/WorkflowTasks.module.ts +4 -2
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -2
- package/core/workflow/testing/index.ts +162 -0
- package/library/terminal/messages.ts +28 -0
- package/library/terminal/screen.ts +1 -0
- package/library/workflow/index.ts +1 -0
- package/library/workflow/mermaid.ts +87 -0
- package/package.json +2 -1
|
@@ -1,53 +1,46 @@
|
|
|
1
1
|
import { BackgroundService, delay } from "../../../background";
|
|
2
2
|
import type { ServiceProvider } from "../../../di";
|
|
3
|
+
import { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
3
4
|
import type { ConfigView } from "../../../kernel";
|
|
4
5
|
import type { workflowRuntimeConfig } from "../config/Runtime.config";
|
|
5
|
-
import {
|
|
6
|
-
import { TimerStep } from "../internal/TimerStep";
|
|
7
|
-
import { ToolStep } from "../internal/ToolStep";
|
|
8
|
-
import { WorkflowEngine } from "../internal/WorkflowEngine";
|
|
9
|
-
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
10
|
-
import type { WfJob } from "../model/WfJob.model";
|
|
6
|
+
import { WorkflowJobs } from "../internal/WorkflowJobs";
|
|
11
7
|
|
|
12
|
-
/** How long a claimed job belongs to this worker; if it dies, another one takes the job after that. */
|
|
13
|
-
const LEASE_MS = 5 * 60_000;
|
|
14
|
-
/** A job that keeps failing outside its Tool (the database, a lost version) fails its step after this many tries. */
|
|
15
|
-
const MAX_JOB_ATTEMPTS = 5;
|
|
16
8
|
/** How often the scheduled starts are matched against the published versions. */
|
|
17
9
|
const RECONCILE_MS = 60_000;
|
|
18
10
|
|
|
19
|
-
|
|
20
11
|
/**
|
|
21
12
|
* Takes due jobs of `bazis_wf_run_jobs` every `worker.intervalMs`, up to
|
|
22
|
-
* `worker.batchSize` at a time
|
|
23
|
-
*
|
|
24
|
-
* the next one at once. Every job runs in its own DI scope.
|
|
13
|
+
* `worker.batchSize` at a time ({@link WorkflowJobs}). A full batch is
|
|
14
|
+
* followed by the next one at once.
|
|
25
15
|
*/
|
|
26
16
|
export class WorkflowWorker extends BackgroundService {
|
|
17
|
+
private readonly jobs: WorkflowJobs;
|
|
18
|
+
|
|
27
19
|
constructor(
|
|
28
20
|
private readonly services: ServiceProvider,
|
|
29
21
|
private readonly config: ConfigView<WorkflowSettings>,
|
|
30
22
|
) {
|
|
31
23
|
super();
|
|
24
|
+
this.jobs = new WorkflowJobs(services, (message, error, fields) => this.reportFailure(message, error, fields));
|
|
32
25
|
}
|
|
33
26
|
|
|
34
27
|
protected override async execute(signal: AbortSignal): Promise<void> {
|
|
35
|
-
if (!this.config.get("worker.enabled")) return;
|
|
28
|
+
if (!this.config.get("worker.enabled") || this.services.resolve(WorkflowClock).manual === true) return;
|
|
36
29
|
const intervalMs = this.config.get("worker.intervalMs");
|
|
37
30
|
const batchSize = this.config.get("worker.batchSize");
|
|
38
31
|
let reconciledAt = 0;
|
|
39
32
|
while (!signal.aborted) {
|
|
40
33
|
if (Date.now() - reconciledAt >= RECONCILE_MS) {
|
|
41
34
|
reconciledAt = Date.now();
|
|
42
|
-
await this.reconcile();
|
|
35
|
+
await this.jobs.reconcile();
|
|
43
36
|
}
|
|
44
37
|
let claimed = 0;
|
|
45
38
|
try {
|
|
46
|
-
const jobs = await this.claim(batchSize);
|
|
39
|
+
const jobs = await this.jobs.claim(batchSize);
|
|
47
40
|
claimed = jobs.length;
|
|
48
41
|
for (const job of jobs) {
|
|
49
42
|
if (signal.aborted) return;
|
|
50
|
-
await this.run(job, signal);
|
|
43
|
+
await this.jobs.run(job, signal);
|
|
51
44
|
}
|
|
52
45
|
} catch (error) {
|
|
53
46
|
if (signal.aborted) return;
|
|
@@ -56,76 +49,6 @@ export class WorkflowWorker extends BackgroundService {
|
|
|
56
49
|
if (claimed < batchSize) await delay(intervalMs, signal);
|
|
57
50
|
}
|
|
58
51
|
}
|
|
59
|
-
|
|
60
|
-
private async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
61
|
-
const scope = this.services.createScope();
|
|
62
|
-
try {
|
|
63
|
-
if (job.kind === "timer") await scope.resolve(TimerStep).run(job);
|
|
64
|
-
else if (job.kind === "schedule") await scope.resolve(Schedules).fire(job);
|
|
65
|
-
else await scope.resolve(ToolStep).run(job, signal);
|
|
66
|
-
} catch (error) {
|
|
67
|
-
this.reportFailure("job failed", error, { job: job.id, instance: job.instanceId, attempts: job.attempts });
|
|
68
|
-
await this.giveUpOrDelay(job, error).catch((failure: unknown) => this.reportFailure("job failure handling failed", failure, { job: job.id }));
|
|
69
|
-
} finally {
|
|
70
|
-
await scope.dispose();
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
private async reconcile(): Promise<void> {
|
|
75
|
-
const scope = this.services.createScope();
|
|
76
|
-
try {
|
|
77
|
-
await scope.resolve(Schedules).reconcile();
|
|
78
|
-
} catch (error) {
|
|
79
|
-
this.reportFailure("scheduled starts could not be planned", error);
|
|
80
|
-
} finally {
|
|
81
|
-
await scope.dispose();
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** A job that failed outside its Tool waits a minute per try, then fails its step for a person to retry. */
|
|
86
|
-
private async giveUpOrDelay(job: WfJob, error: unknown): Promise<void> {
|
|
87
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
-
const scope = this.services.createScope();
|
|
89
|
-
try {
|
|
90
|
-
const db = scope.resolve(RuntimeDbContext);
|
|
91
|
-
if (job.attempts < MAX_JOB_ATTEMPTS || job.instanceId === null || job.tokenId === null) {
|
|
92
|
-
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) });
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
const engine = scope.resolve(WorkflowEngine);
|
|
96
|
-
await engine.inStep(job.instanceId, async (step) => {
|
|
97
|
-
await db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
98
|
-
const token = step.tokens.find((t) => t.id === job.tokenId);
|
|
99
|
-
if (token && token.status === "waiting" && step.instance.status === "running") engine.fail(step, token, "job_failed", message.slice(0, 1000));
|
|
100
|
-
});
|
|
101
|
-
} finally {
|
|
102
|
-
await scope.dispose();
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
private async claim(batchSize: number): Promise<WfJob[]> {
|
|
107
|
-
const scope = this.services.createScope();
|
|
108
|
-
try {
|
|
109
|
-
const db = scope.resolve(RuntimeDbContext);
|
|
110
|
-
return await db.transactionScope(async () => {
|
|
111
|
-
const now = new Date();
|
|
112
|
-
const jobs = await db.jobs
|
|
113
|
-
.where((j) => j.kind.in(["tool", "timer", "schedule"]).and(j.dueAt.lte(now)).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
|
|
114
|
-
.orderBy((j) => j.id)
|
|
115
|
-
.take(batchSize)
|
|
116
|
-
.forUpdate({ skipLocked: true })
|
|
117
|
-
.toList();
|
|
118
|
-
for (const job of jobs) {
|
|
119
|
-
job.lockedUntil = new Date(now.getTime() + LEASE_MS);
|
|
120
|
-
job.attempts += 1;
|
|
121
|
-
}
|
|
122
|
-
await db.saveChanges();
|
|
123
|
-
return jobs;
|
|
124
|
-
});
|
|
125
|
-
} finally {
|
|
126
|
-
await scope.dispose();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
52
|
}
|
|
130
53
|
|
|
131
54
|
/** The resolved settings of {@link workflowRuntimeConfig}. */
|
|
@@ -22,7 +22,7 @@ export class Schedules {
|
|
|
22
22
|
) {}
|
|
23
23
|
|
|
24
24
|
/** Adds jobs for new scheduled versions and drops those of replaced or archived ones. */
|
|
25
|
-
async reconcile(now =
|
|
25
|
+
async reconcile(now = this.engine.now()): Promise<void> {
|
|
26
26
|
const versions = (await this.definitions.published()).filter((version) => scheduleNodes(version).length > 0);
|
|
27
27
|
const wanted = new Map(versions.map((version) => [version.id, version]));
|
|
28
28
|
const added: WfJob[] = [];
|
|
@@ -58,7 +58,9 @@ export class Schedules {
|
|
|
58
58
|
const due = new Date(Math.floor(job.dueAt.getTime() / 60_000) * 60_000);
|
|
59
59
|
const graph = this.engine.cache(latest);
|
|
60
60
|
await this.db.transactionScope(async () => {
|
|
61
|
-
|
|
61
|
+
// Planned from now, not from the missed firing: a stopped worker does not catch up.
|
|
62
|
+
const now = this.engine.now();
|
|
63
|
+
const next = nextFiring(latest, now > due ? now : due);
|
|
62
64
|
if (next === undefined) await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
63
65
|
else await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ dueAt: next, lockedUntil: null, attempts: 0, lastError: null });
|
|
64
66
|
for (const [nodeId, cron] of scheduleNodes(latest)) {
|
|
@@ -44,7 +44,7 @@ export class ToolStep {
|
|
|
44
44
|
await this.engine.inStep(instance.id, async (step) => {
|
|
45
45
|
const current = step.tokens.find((t) => t.id === token.id);
|
|
46
46
|
const retryAt = current?.status === "waiting" && step.instance.status === "running" && result.status === "error" && reason === undefined
|
|
47
|
-
? nextAttempt(node?.config?.retry, job.attempts)
|
|
47
|
+
? nextAttempt(node?.config?.retry, job.attempts, this.engine.now())
|
|
48
48
|
: undefined;
|
|
49
49
|
if (retryAt !== undefined) {
|
|
50
50
|
// The same job runs again later: its attempts keep counting.
|
|
@@ -87,10 +87,10 @@ function reasonOf(result: AgentToolResult): string | undefined {
|
|
|
87
87
|
* `undefined` when the attempts are used up. The pause doubles from `backoff`
|
|
88
88
|
* (10 seconds by default) and stops growing at a day.
|
|
89
89
|
*/
|
|
90
|
-
export function nextAttempt(policy: unknown, attempts: number): Date | undefined {
|
|
90
|
+
export function nextAttempt(policy: unknown, attempts: number, now: Date): Date | undefined {
|
|
91
91
|
if (typeof policy !== "object" || policy === null) return undefined;
|
|
92
92
|
const { maxAttempts, backoff } = policy as { maxAttempts?: unknown; backoff?: unknown };
|
|
93
93
|
if (typeof maxAttempts !== "number" || attempts >= maxAttempts) return undefined;
|
|
94
94
|
const base = typeof backoff === "string" ? durationMs(backoff) ?? 10_000 : 10_000;
|
|
95
|
-
return new Date(
|
|
95
|
+
return new Date(now.getTime() + Math.min(base * 2 ** Math.max(attempts - 1, 0), 86_400_000));
|
|
96
96
|
}
|
|
@@ -2,6 +2,7 @@ import type { ServiceResolver } from "../../../di";
|
|
|
2
2
|
import { EventBus, type EventToken, type Logger } from "../../../kernel";
|
|
3
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
|
+
import type { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
5
6
|
import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
6
7
|
import { WORKFLOW_INSTANCE_FINISHED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED } from "../events/Runtime.events";
|
|
7
8
|
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
@@ -53,8 +54,14 @@ export class WorkflowEngine {
|
|
|
53
54
|
private readonly events: EventBus,
|
|
54
55
|
private readonly scope: ServiceResolver,
|
|
55
56
|
private readonly logger: Logger | undefined,
|
|
57
|
+
private readonly clock: WorkflowClock,
|
|
56
58
|
) {}
|
|
57
59
|
|
|
60
|
+
/** The engine's time ({@link WorkflowClock}). */
|
|
61
|
+
now(): Date {
|
|
62
|
+
return this.clock.now();
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
/** The graph of a version. Versions are immutable, so graphs are cached. */
|
|
59
66
|
async graphOf(versionId: string): Promise<ProcessGraph> {
|
|
60
67
|
const cached = this.graphs.get(versionId);
|
|
@@ -168,7 +175,7 @@ export class WorkflowEngine {
|
|
|
168
175
|
const token = step.tokens.find((t) => t.id === tokenId);
|
|
169
176
|
if (!token || token.status !== "waiting" || step.instance.status !== "running") return false;
|
|
170
177
|
const node = step.graph.document.nodes[token.nodeId];
|
|
171
|
-
const firedAt =
|
|
178
|
+
const firedAt = this.now().toISOString();
|
|
172
179
|
if (node?.kind === "timerCatch") {
|
|
173
180
|
this.history(step, token, token.nodeId, "timer", { firedAt });
|
|
174
181
|
return await this.complete(step, tokenId, { firedAt });
|
|
@@ -239,12 +246,12 @@ export class WorkflowEngine {
|
|
|
239
246
|
const open = step.tokens.filter((t) => LIVE.has(t.status));
|
|
240
247
|
for (const token of open) {
|
|
241
248
|
token.status = "cancelled";
|
|
242
|
-
token.endedAt =
|
|
249
|
+
token.endedAt = this.now();
|
|
243
250
|
}
|
|
244
251
|
await this.db.jobs.asNoTracking().where((j) => j.instanceId.eq(step.instance.id)).executeDelete();
|
|
245
252
|
await this.db.subscriptions.asNoTracking().where((s) => s.instanceId.eq(step.instance.id)).executeDelete();
|
|
246
253
|
step.instance.status = "cancelled";
|
|
247
|
-
step.instance.endedAt =
|
|
254
|
+
step.instance.endedAt = this.now();
|
|
248
255
|
this.history(step, null, null, "cancelled", { reason }, actor);
|
|
249
256
|
for (const token of open) {
|
|
250
257
|
if (token.nodeId !== "" && step.graph.document.nodes[token.nodeId]?.kind === "userTask") {
|
|
@@ -278,7 +285,7 @@ export class WorkflowEngine {
|
|
|
278
285
|
}
|
|
279
286
|
if (step.instance.status === "running" && !step.tokens.some((t) => LIVE.has(t.status))) {
|
|
280
287
|
step.instance.status = "completed";
|
|
281
|
-
step.instance.endedAt =
|
|
288
|
+
step.instance.endedAt = this.now();
|
|
282
289
|
this.history(step, null, null, "completed", null);
|
|
283
290
|
this.finished(step, "completed");
|
|
284
291
|
}
|
|
@@ -319,7 +326,7 @@ export class WorkflowEngine {
|
|
|
319
326
|
}
|
|
320
327
|
|
|
321
328
|
private waitForTimer(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
|
|
322
|
-
const dueAt = timerDue(node.config ?? {}, dataOf(step.instance));
|
|
329
|
+
const dueAt = timerDue(node.config ?? {}, dataOf(step.instance), this.now());
|
|
323
330
|
if (dueAt === undefined) {
|
|
324
331
|
this.fail(step, token, "bad_timer", `the timer of ${token.nodeId} has no valid date`);
|
|
325
332
|
return [];
|
|
@@ -348,7 +355,7 @@ export class WorkflowEngine {
|
|
|
348
355
|
continue;
|
|
349
356
|
}
|
|
350
357
|
if (node.kind !== "boundaryTimer") continue;
|
|
351
|
-
const dueAt = timerDue(node.config ?? {}, dataOf(step.instance));
|
|
358
|
+
const dueAt = timerDue(node.config ?? {}, dataOf(step.instance), this.now());
|
|
352
359
|
if (dueAt === undefined) {
|
|
353
360
|
this.fail(step, host, "bad_timer", `the timer of ${id} has no valid date`);
|
|
354
361
|
return [];
|
|
@@ -393,7 +400,7 @@ export class WorkflowEngine {
|
|
|
393
400
|
|
|
394
401
|
private waitForTool(step: Step, token: WfToken): WfToken[] {
|
|
395
402
|
token.status = "waiting";
|
|
396
|
-
this.db.jobs.add(Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "tool" as const, instanceId: step.instance.id, tokenId: token.id, dueAt:
|
|
403
|
+
this.db.jobs.add(Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "tool" as const, instanceId: step.instance.id, tokenId: token.id, dueAt: this.now() }));
|
|
397
404
|
return [];
|
|
398
405
|
}
|
|
399
406
|
|
|
@@ -412,7 +419,7 @@ export class WorkflowEngine {
|
|
|
412
419
|
title: node.title,
|
|
413
420
|
assignee: config.assignee as { role: string } | { user: string },
|
|
414
421
|
form: (config.form as FormSpec | undefined) ?? null,
|
|
415
|
-
dueAt: dueIn === undefined ? null : new Date(
|
|
422
|
+
dueAt: dueIn === undefined ? null : new Date(this.now().getTime() + dueIn),
|
|
416
423
|
},
|
|
417
424
|
});
|
|
418
425
|
return [];
|
|
@@ -467,7 +474,7 @@ export class WorkflowEngine {
|
|
|
467
474
|
|
|
468
475
|
private close(token: WfToken, status: "done" | "cancelled"): void {
|
|
469
476
|
token.status = status;
|
|
470
|
-
token.endedAt =
|
|
477
|
+
token.endedAt = this.now();
|
|
471
478
|
}
|
|
472
479
|
|
|
473
480
|
private spawn(step: Step, nodeId: string, viaFlowId: string | null): WfToken {
|
|
@@ -505,10 +512,10 @@ export class WorkflowEngine {
|
|
|
505
512
|
}
|
|
506
513
|
|
|
507
514
|
/** When a timer fires: `duration` from now, or the date at the `at` path; `undefined` if neither is valid. */
|
|
508
|
-
export function timerDue(config: Readonly<Record<string, unknown>>, data: InstanceData): Date | undefined {
|
|
515
|
+
export function timerDue(config: Readonly<Record<string, unknown>>, data: InstanceData, now: Date): Date | undefined {
|
|
509
516
|
if (typeof config.duration === "string") {
|
|
510
517
|
const ms = durationMs(config.duration);
|
|
511
|
-
return ms === undefined ? undefined : new Date(
|
|
518
|
+
return ms === undefined ? undefined : new Date(now.getTime() + ms);
|
|
512
519
|
}
|
|
513
520
|
if (typeof config.at === "string") {
|
|
514
521
|
const value = readPath(data, config.at);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ServiceProvider } from "../../../di";
|
|
2
|
+
import { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
3
|
+
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
4
|
+
import type { WfJob } from "../model/WfJob.model";
|
|
5
|
+
import { Schedules } from "./Schedules";
|
|
6
|
+
import { TimerStep } from "./TimerStep";
|
|
7
|
+
import { ToolStep } from "./ToolStep";
|
|
8
|
+
import { WorkflowEngine } from "./WorkflowEngine";
|
|
9
|
+
|
|
10
|
+
/** How long a claimed job belongs to its worker; if it dies, another one takes the job after that. */
|
|
11
|
+
const LEASE_MS = 5 * 60_000;
|
|
12
|
+
/** A job that keeps failing outside its Tool (the database, a lost version) fails its step after this many tries. */
|
|
13
|
+
const MAX_JOB_ATTEMPTS = 5;
|
|
14
|
+
|
|
15
|
+
/** Where a failure goes: the worker's log. */
|
|
16
|
+
export type JobFailureReport = (message: string, error: unknown, fields?: Readonly<Record<string, unknown>>) => void;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Claiming and running jobs of `bazis_wf_run_jobs`: the work of the worker, and
|
|
20
|
+
* of `drain` in tests. Every job runs in its own DI scope.
|
|
21
|
+
*/
|
|
22
|
+
export class WorkflowJobs {
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly services: ServiceProvider,
|
|
25
|
+
private readonly report: JobFailureReport,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
/** Takes up to `batchSize` due jobs with `FOR UPDATE SKIP LOCKED` and a lease, so instances never take one job twice. */
|
|
29
|
+
async claim(batchSize: number): Promise<WfJob[]> {
|
|
30
|
+
const scope = this.services.createScope();
|
|
31
|
+
try {
|
|
32
|
+
const db = scope.resolve(RuntimeDbContext);
|
|
33
|
+
const now = scope.resolve(WorkflowClock).now();
|
|
34
|
+
return await db.transactionScope(async () => {
|
|
35
|
+
const jobs = await db.jobs
|
|
36
|
+
.where((j) => j.kind.in(["tool", "timer", "schedule"]).and(j.dueAt.lte(now)).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
|
|
37
|
+
.orderBy((j) => j.id)
|
|
38
|
+
.take(batchSize)
|
|
39
|
+
.forUpdate({ skipLocked: true })
|
|
40
|
+
.toList();
|
|
41
|
+
for (const job of jobs) {
|
|
42
|
+
job.lockedUntil = new Date(now.getTime() + LEASE_MS);
|
|
43
|
+
job.attempts += 1;
|
|
44
|
+
}
|
|
45
|
+
await db.saveChanges();
|
|
46
|
+
return jobs;
|
|
47
|
+
});
|
|
48
|
+
} finally {
|
|
49
|
+
await scope.dispose();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async run(job: WfJob, signal: AbortSignal): Promise<void> {
|
|
54
|
+
const scope = this.services.createScope();
|
|
55
|
+
try {
|
|
56
|
+
if (job.kind === "timer") await scope.resolve(TimerStep).run(job);
|
|
57
|
+
else if (job.kind === "schedule") await scope.resolve(Schedules).fire(job);
|
|
58
|
+
else await scope.resolve(ToolStep).run(job, signal);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
this.report("job failed", error, { job: job.id, instance: job.instanceId, attempts: job.attempts });
|
|
61
|
+
await this.giveUpOrDelay(job, error).catch((failure: unknown) => this.report("job failure handling failed", failure, { job: job.id }));
|
|
62
|
+
} finally {
|
|
63
|
+
await scope.dispose();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Matches the scheduled starts against the published versions. */
|
|
68
|
+
async reconcile(): Promise<void> {
|
|
69
|
+
const scope = this.services.createScope();
|
|
70
|
+
try {
|
|
71
|
+
await scope.resolve(Schedules).reconcile();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
this.report("scheduled starts could not be planned", error);
|
|
74
|
+
} finally {
|
|
75
|
+
await scope.dispose();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A job that failed outside its Tool waits a minute per try, then fails its step for a person to retry. */
|
|
80
|
+
private async giveUpOrDelay(job: WfJob, error: unknown): Promise<void> {
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
82
|
+
const scope = this.services.createScope();
|
|
83
|
+
try {
|
|
84
|
+
const db = scope.resolve(RuntimeDbContext);
|
|
85
|
+
if (job.attempts < MAX_JOB_ATTEMPTS || job.instanceId === null || job.tokenId === null) {
|
|
86
|
+
const now = scope.resolve(WorkflowClock).now();
|
|
87
|
+
await db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ dueAt: new Date(now.getTime() + job.attempts * 60_000), lockedUntil: null, lastError: message.slice(0, 1000) });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const engine = scope.resolve(WorkflowEngine);
|
|
91
|
+
await engine.inStep(job.instanceId, async (step) => {
|
|
92
|
+
await db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
93
|
+
const token = step.tokens.find((t) => t.id === job.tokenId);
|
|
94
|
+
if (token && token.status === "waiting" && step.instance.status === "running") engine.fail(step, token, "job_failed", message.slice(0, 1000));
|
|
95
|
+
});
|
|
96
|
+
} finally {
|
|
97
|
+
await scope.dispose();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { DI, Module, scoped } from "../../di";
|
|
2
|
+
import { WorkflowClock } from "../contracts/WorkflowClock";
|
|
3
|
+
import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
|
|
2
4
|
import { withEventSubscribers } from "../../kernel";
|
|
3
5
|
import { IWorkflowRuntime } from "../runtime_module/services/IWorkflowRuntime.service";
|
|
4
6
|
import { WorkflowRuntimeModule } from "../runtime_module/WorkflowRuntime.module";
|
|
@@ -15,10 +17,10 @@ import { WorkflowTasks } from "./services/WorkflowTasks.service";
|
|
|
15
17
|
* application's codegen.
|
|
16
18
|
*/
|
|
17
19
|
@Module({
|
|
18
|
-
imports: [WorkflowRuntimeModule],
|
|
20
|
+
imports: [WorkflowDefinitionsModule, WorkflowRuntimeModule],
|
|
19
21
|
ormBazis: { context: TasksDbContext, entities: [WfTask], ownedStore: tasksStore, registerRepositories: false },
|
|
20
22
|
providers: [
|
|
21
|
-
scoped(WorkflowTasks, WorkflowTasks, [TasksDbContext, IWorkflowRuntime] as const),
|
|
23
|
+
scoped(WorkflowTasks, WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock] as const),
|
|
22
24
|
DI.scoped(DI.factoryProviderWithResolver(IWorkflowTasks, [], (resolver) => resolver.resolve(WorkflowTasks))),
|
|
23
25
|
scoped(TaskEvents, TaskEvents, [WorkflowTasks] as const),
|
|
24
26
|
...withEventSubscribers(TaskEvents),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { UniqueViolationError } from "../../../orm";
|
|
2
2
|
import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
|
|
3
|
+
import type { WorkflowClock } from "../../contracts/WorkflowClock";
|
|
3
4
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
4
5
|
import type { WorkflowInstancesDeleted, WorkflowTaskRequested, WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
|
|
5
6
|
import type { IWorkflowRuntime } from "../../runtime_module/services/IWorkflowRuntime.service";
|
|
@@ -13,6 +14,7 @@ export class WorkflowTasks extends IWorkflowTasks {
|
|
|
13
14
|
constructor(
|
|
14
15
|
private readonly db: TasksDbContext,
|
|
15
16
|
private readonly runtime: IWorkflowRuntime,
|
|
17
|
+
private readonly clock: WorkflowClock,
|
|
16
18
|
) {
|
|
17
19
|
super();
|
|
18
20
|
}
|
|
@@ -49,7 +51,7 @@ export class WorkflowTasks extends IWorkflowTasks {
|
|
|
49
51
|
if (task.status !== "open") throw new WorkflowError("wrong_status", `The task is ${task.status}`);
|
|
50
52
|
task.status = "claimed";
|
|
51
53
|
task.claimedById = actor.id;
|
|
52
|
-
task.claimedAt =
|
|
54
|
+
task.claimedAt = this.clock.now();
|
|
53
55
|
await this.db.saveChanges();
|
|
54
56
|
return detailsOf(task);
|
|
55
57
|
});
|
|
@@ -83,7 +85,7 @@ export class WorkflowTasks extends IWorkflowTasks {
|
|
|
83
85
|
await this.db.tasks.asNoTracking().where((t) => t.id.eq(task.id)).executeUpdate({
|
|
84
86
|
status: "completed",
|
|
85
87
|
completedById: actor.id,
|
|
86
|
-
completedAt:
|
|
88
|
+
completedAt: this.clock.now(),
|
|
87
89
|
output: typeof saved === "object" && saved !== null && !Array.isArray(saved) ? saved as Record<string, unknown> : null,
|
|
88
90
|
});
|
|
89
91
|
return await this.get(task.id);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { DI } from "../../di";
|
|
2
|
+
import { startTestApp, type TestApp, type TestAppOptions } from "../../testing";
|
|
3
|
+
import { durationMs } from "../../../library/workflow";
|
|
4
|
+
import { WorkflowClock } from "../contracts/WorkflowClock";
|
|
5
|
+
import type { WorkflowActor } from "../contracts/types";
|
|
6
|
+
import { WorkflowJobs } from "../runtime_module/internal/WorkflowJobs";
|
|
7
|
+
import { IWorkflowRuntime, type EmitOptions, type EmitResult, type HistoryEntry, type InstanceDetails, type StartInstance } from "../runtime_module/services/IWorkflowRuntime.service";
|
|
8
|
+
import { TasksDbContext } from "../tasks_module/model/Tasks.dbContext";
|
|
9
|
+
import { IWorkflowTasks } from "../tasks_module/services/IWorkflowTasks.service";
|
|
10
|
+
|
|
11
|
+
/** Rounds of `drain` before it gives up: a process that keeps creating due jobs never settles. */
|
|
12
|
+
const MAX_ROUNDS = 1000;
|
|
13
|
+
const BATCH = 50;
|
|
14
|
+
const TEST_ACTOR: WorkflowActor = { kind: "system", id: "test" };
|
|
15
|
+
|
|
16
|
+
/** A clock that stands still until the test moves it (WORKFLOW-001 §14). */
|
|
17
|
+
export class ManualWorkflowClock extends WorkflowClock {
|
|
18
|
+
override readonly manual = true;
|
|
19
|
+
private current: number;
|
|
20
|
+
|
|
21
|
+
constructor(start: Date | string = new Date()) {
|
|
22
|
+
super();
|
|
23
|
+
this.current = toTime(start, "now");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
now(): Date {
|
|
27
|
+
return new Date(this.current);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Moves the clock forward by an ISO duration (`PT2H`, `P1D`) or milliseconds. */
|
|
31
|
+
advance(by: string | number): Date {
|
|
32
|
+
const ms = typeof by === "number" ? by : durationMs(by);
|
|
33
|
+
if (ms === undefined || !Number.isFinite(ms) || ms < 0) throw new Error(`advance takes an ISO duration without months and years or milliseconds ≥ 0, not ${JSON.stringify(by)}`);
|
|
34
|
+
this.current += ms;
|
|
35
|
+
return this.now();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Moves the clock forward to a moment. */
|
|
39
|
+
set(to: Date | string): Date {
|
|
40
|
+
const time = toTime(to, "set");
|
|
41
|
+
if (time < this.current) throw new Error(`The workflow clock only moves forward: it is ${this.now().toISOString()}`);
|
|
42
|
+
this.current = time;
|
|
43
|
+
return this.now();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TestWorkflowOptions extends TestAppOptions {
|
|
48
|
+
/** Where the workflow clock starts; the current time by default. */
|
|
49
|
+
readonly now?: Date | string;
|
|
50
|
+
/** On whose behalf `start`, `emit` and `complete` act; `{ kind: "system", id: "test" }` by default. */
|
|
51
|
+
readonly actor?: WorkflowActor;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A started application whose processes run only when the test says so. */
|
|
55
|
+
export interface TestWorkflow {
|
|
56
|
+
readonly app: TestApp;
|
|
57
|
+
readonly clock: ManualWorkflowClock;
|
|
58
|
+
now(): Date;
|
|
59
|
+
/** Starts the latest version of a process from its manual start; jobs wait for {@link drain}. */
|
|
60
|
+
start(processKey: string, options?: Partial<StartInstance>): Promise<InstanceDetails>;
|
|
61
|
+
emit(eventKey: string, payload?: unknown, options?: EmitOptions): Promise<EmitResult>;
|
|
62
|
+
/** Completes the user task waiting at `nodeId` (through its task when the Tasks part is there). */
|
|
63
|
+
complete(instanceId: string, nodeId: string, output?: unknown, actor?: WorkflowActor): Promise<InstanceDetails>;
|
|
64
|
+
/** Plans scheduled starts and runs every job due by the workflow clock until none is left; returns how many ran. */
|
|
65
|
+
drain(): Promise<number>;
|
|
66
|
+
/** Moves the clock forward (ISO duration or milliseconds), then {@link drain}. */
|
|
67
|
+
advance(by: string | number): Promise<number>;
|
|
68
|
+
instance(instanceId: string): Promise<InstanceDetails>;
|
|
69
|
+
history(instanceId: string): Promise<readonly HistoryEntry[]>;
|
|
70
|
+
stop(): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Starts the application like `startTestApp`, with a {@link ManualWorkflowClock}
|
|
75
|
+
* and no background worker: jobs run only in `drain`, so a test of a process is
|
|
76
|
+
* deterministic. Needs PostgreSQL, as the workflow module does.
|
|
77
|
+
*
|
|
78
|
+
* ```ts
|
|
79
|
+
* const wf = await testWorkflow(AppModule, { now: "2026-11-02T09:00:00Z" });
|
|
80
|
+
* try {
|
|
81
|
+
* const order = await wf.start("order-approval", { input: { amount: 5000 } });
|
|
82
|
+
* await wf.drain();
|
|
83
|
+
* await wf.complete(order.id, "approve", { approved: true });
|
|
84
|
+
* await wf.advance("PT2H");
|
|
85
|
+
* expect((await wf.instance(order.id)).status).toBe("completed");
|
|
86
|
+
* } finally {
|
|
87
|
+
* await wf.stop();
|
|
88
|
+
* }
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export async function testWorkflow(root: Parameters<typeof startTestApp>[0], options: TestWorkflowOptions = {}): Promise<TestWorkflow> {
|
|
92
|
+
const { now, actor = TEST_ACTOR, overrides = [], ...appOptions } = options;
|
|
93
|
+
const clock = new ManualWorkflowClock(now);
|
|
94
|
+
const app = await startTestApp(root, { ...appOptions, overrides: [...overrides, DI.singleton(DI.valueProvider(WorkflowClock, clock))] });
|
|
95
|
+
const failures: unknown[] = [];
|
|
96
|
+
const jobs = new WorkflowJobs(app.container, (message, error, fields) => failures.push({ message, error, ...fields }));
|
|
97
|
+
const signal = new AbortController().signal;
|
|
98
|
+
|
|
99
|
+
const inScope = async <T>(work: (scope: ReturnType<TestApp["container"]["createScope"]>) => Promise<T>): Promise<T> => {
|
|
100
|
+
const scope = app.container.createScope();
|
|
101
|
+
try {
|
|
102
|
+
return await work(scope);
|
|
103
|
+
} finally {
|
|
104
|
+
await scope.dispose();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const drain = async (): Promise<number> => {
|
|
109
|
+
await jobs.reconcile();
|
|
110
|
+
let ran = 0;
|
|
111
|
+
for (let round = 0; round < MAX_ROUNDS; round += 1) {
|
|
112
|
+
const due = await jobs.claim(BATCH);
|
|
113
|
+
if (due.length === 0) return ran;
|
|
114
|
+
for (const job of due) await jobs.run(job, signal);
|
|
115
|
+
ran += due.length;
|
|
116
|
+
// A failure outside a Tool (a failing Tool is a step result) is a bug the test should see.
|
|
117
|
+
if (failures.length > 0) {
|
|
118
|
+
const found = failures.splice(0);
|
|
119
|
+
throw new AggregateError(found.map((failure) => (failure as { error: unknown }).error), `drain: ${found.length} job(s) failed outside their Tools: ${found.map((failure) => { const { message, error } = failure as { message: string; error: unknown }; return `${message}: ${error instanceof Error ? error.message : String(error)}`; }).join("; ")}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`drain did not settle after ${MAX_ROUNDS} rounds: a process keeps creating due jobs`);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
app,
|
|
127
|
+
clock,
|
|
128
|
+
now: () => clock.now(),
|
|
129
|
+
start: (processKey, start = {}) => inScope((scope) => scope.resolve(IWorkflowRuntime).start(processKey, { ...start, actor: start.actor ?? actor })),
|
|
130
|
+
emit: (eventKey, payload, emitOptions = {}) => inScope((scope) => scope.resolve(IWorkflowRuntime).emit(eventKey, payload, { ...emitOptions, actor: emitOptions.actor ?? actor })),
|
|
131
|
+
complete: (instanceId, nodeId, output = {}, by = actor) => inScope(async (scope) => {
|
|
132
|
+
const runtime = scope.resolve(IWorkflowRuntime);
|
|
133
|
+
const instance = await runtime.instance(instanceId);
|
|
134
|
+
const token = instance.tokens.find((t) => t.nodeId === nodeId && t.status === "waiting");
|
|
135
|
+
if (!token) {
|
|
136
|
+
const waiting = instance.tokens.filter((t) => t.status === "waiting").map((t) => t.nodeId);
|
|
137
|
+
throw new Error(`No step waits at ${nodeId} in instance ${instanceId} (${instance.status}); waiting: ${waiting.length === 0 ? "none" : waiting.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
const tasks = scope.tryResolve(IWorkflowTasks);
|
|
140
|
+
const task = tasks === undefined ? undefined : await scope.resolve(TasksDbContext).tasks.asNoTracking().firstOrDefault((t) => t.tokenId.eq(token.id).and(t.status.in(["open", "claimed"])));
|
|
141
|
+
if (tasks !== undefined && task) {
|
|
142
|
+
await tasks.complete(task.id, output, by);
|
|
143
|
+
return await runtime.instance(instanceId);
|
|
144
|
+
}
|
|
145
|
+
return await runtime.completeToken(token.id, output, by);
|
|
146
|
+
}),
|
|
147
|
+
drain,
|
|
148
|
+
advance: async (by) => {
|
|
149
|
+
clock.advance(by);
|
|
150
|
+
return await drain();
|
|
151
|
+
},
|
|
152
|
+
instance: (instanceId) => inScope((scope) => scope.resolve(IWorkflowRuntime).instance(instanceId)),
|
|
153
|
+
history: (instanceId) => inScope((scope) => scope.resolve(IWorkflowRuntime).history(instanceId)),
|
|
154
|
+
stop: () => app.stop(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function toTime(value: Date | string, what: string): number {
|
|
159
|
+
const time = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
160
|
+
if (!Number.isFinite(time)) throw new Error(`The workflow clock ${what} must be a date, not ${JSON.stringify(value)}`);
|
|
161
|
+
return time;
|
|
162
|
+
}
|