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.
Files changed (33) hide show
  1. package/cli/generateModule.ts +5 -5
  2. package/cli/generateProcess.ts +56 -0
  3. package/cli/main.ts +35 -1
  4. package/cli/moduleRegistration.ts +23 -10
  5. package/cli/parseCli.ts +45 -3
  6. package/cli/templates/process.ts +28 -0
  7. package/cli/workflow.ts +66 -0
  8. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  9. package/core/workflow/contracts/WorkflowClock.ts +21 -0
  10. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +14 -6
  11. package/core/workflow/definitions_module/code/CodeProcessCatalog.ts +19 -0
  12. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +44 -0
  13. package/core/workflow/definitions_module/code/codeProcesses.ts +85 -0
  14. package/core/workflow/definitions_module/code/defineProcess.ts +40 -0
  15. package/core/workflow/definitions_module/code/inspectProcesses.ts +74 -0
  16. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +11 -2
  17. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +106 -45
  18. package/core/workflow/index.ts +14 -0
  19. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +2 -0
  20. package/core/workflow/runtime_module/background/WorkflowRetention.ts +4 -2
  21. package/core/workflow/runtime_module/background/WorkflowWorker.ts +11 -88
  22. package/core/workflow/runtime_module/internal/Schedules.ts +4 -2
  23. package/core/workflow/runtime_module/internal/ToolStep.ts +3 -3
  24. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +18 -11
  25. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +100 -0
  26. package/core/workflow/tasks_module/WorkflowTasks.module.ts +4 -2
  27. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +4 -2
  28. package/core/workflow/testing/index.ts +162 -0
  29. package/library/terminal/messages.ts +28 -0
  30. package/library/terminal/screen.ts +1 -0
  31. package/library/workflow/index.ts +1 -0
  32. package/library/workflow/mermaid.ts +87 -0
  33. 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 { Schedules } from "../internal/Schedules";
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, with `FOR UPDATE SKIP LOCKED` and a lease, so
23
- * application instances never take one job twice. A full batch is followed by
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 = new Date()): Promise<void> {
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
- const next = nextFiring(latest, due);
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(Date.now() + Math.min(base * 2 ** Math.max(attempts - 1, 0), 86_400_000));
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 = new Date().toISOString();
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 = new Date();
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 = new Date();
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 = new Date();
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: new Date() }));
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(Date.now() + dueIn),
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 = new Date();
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(Date.now() + ms);
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 = new Date();
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: new Date(),
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
+ }