bazis 0.126.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 (41) 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/Workflow.module.ts +4 -2
  10. package/core/workflow/contracts/WorkflowClock.ts +21 -0
  11. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +14 -6
  12. package/core/workflow/definitions_module/code/CodeProcessCatalog.ts +19 -0
  13. package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +44 -0
  14. package/core/workflow/definitions_module/code/codeProcesses.ts +85 -0
  15. package/core/workflow/definitions_module/code/defineProcess.ts +40 -0
  16. package/core/workflow/definitions_module/code/inspectProcesses.ts +74 -0
  17. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +11 -2
  18. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +106 -45
  19. package/core/workflow/index.ts +21 -0
  20. package/core/workflow/internal/http.ts +6 -0
  21. package/core/workflow/runtime_module/WorkflowRuntime.module.ts +2 -0
  22. package/core/workflow/runtime_module/background/WorkflowRetention.ts +9 -3
  23. package/core/workflow/runtime_module/background/WorkflowWorker.ts +11 -88
  24. package/core/workflow/runtime_module/events/Runtime.events.ts +6 -0
  25. package/core/workflow/runtime_module/internal/Schedules.ts +4 -2
  26. package/core/workflow/runtime_module/internal/ToolStep.ts +3 -3
  27. package/core/workflow/runtime_module/internal/WorkflowEngine.ts +18 -11
  28. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +100 -0
  29. package/core/workflow/tasks_module/WorkflowTasks.module.ts +31 -0
  30. package/core/workflow/tasks_module/events/TaskEvents.ts +23 -0
  31. package/core/workflow/tasks_module/http/Tasks.controller.ts +67 -0
  32. package/core/workflow/tasks_module/model/Tasks.dbContext.ts +14 -0
  33. package/core/workflow/tasks_module/model/WfTask.model.ts +69 -0
  34. package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +52 -0
  35. package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +169 -0
  36. package/core/workflow/testing/index.ts +162 -0
  37. package/library/terminal/messages.ts +28 -0
  38. package/library/terminal/screen.ts +1 -0
  39. package/library/workflow/index.ts +1 -0
  40. package/library/workflow/mermaid.ts +87 -0
  41. 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}. */
@@ -32,6 +32,12 @@ export interface WorkflowInstanceFinished {
32
32
  readonly status: Extract<InstanceStatus, "completed" | "cancelled">;
33
33
  }
34
34
 
35
+ /** Retention deleted these finished instances with their tokens and history. */
36
+ export interface WorkflowInstancesDeleted {
37
+ readonly instanceIds: readonly string[];
38
+ }
39
+
35
40
  export const WORKFLOW_TASK_REQUESTED = createEventToken<WorkflowTaskRequested>("workflow.task-requested");
36
41
  export const WORKFLOW_TOKEN_CLOSED = createEventToken<WorkflowTokenClosed>("workflow.token-closed");
37
42
  export const WORKFLOW_INSTANCE_FINISHED = createEventToken<WorkflowInstanceFinished>("workflow.instance-finished");
43
+ export const WORKFLOW_INSTANCES_DELETED = createEventToken<WorkflowInstancesDeleted>("workflow.instances-deleted");
@@ -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
+ }
@@ -0,0 +1,31 @@
1
+ import { DI, Module, scoped } from "../../di";
2
+ import { WorkflowClock } from "../contracts/WorkflowClock";
3
+ import { WorkflowDefinitionsModule } from "../definitions_module/WorkflowDefinitions.module";
4
+ import { withEventSubscribers } from "../../kernel";
5
+ import { IWorkflowRuntime } from "../runtime_module/services/IWorkflowRuntime.service";
6
+ import { WorkflowRuntimeModule } from "../runtime_module/WorkflowRuntime.module";
7
+ import { TaskEvents } from "./events/TaskEvents";
8
+ import { TasksController } from "./http/Tasks.controller";
9
+ import { TasksDbContext, tasksStore } from "./model/Tasks.dbContext";
10
+ import { WfTask } from "./model/WfTask.model";
11
+ import { IWorkflowTasks } from "./services/IWorkflowTasks.service";
12
+ import { WorkflowTasks } from "./services/WorkflowTasks.service";
13
+
14
+ /**
15
+ * Tasks for people: the inbox of `userTask` steps ([passport](MODULE.md)).
16
+ * Dependencies are explicit: the framework's classes are not covered by the
17
+ * application's codegen.
18
+ */
19
+ @Module({
20
+ imports: [WorkflowDefinitionsModule, WorkflowRuntimeModule],
21
+ ormBazis: { context: TasksDbContext, entities: [WfTask], ownedStore: tasksStore, registerRepositories: false },
22
+ providers: [
23
+ scoped(WorkflowTasks, WorkflowTasks, [TasksDbContext, IWorkflowRuntime, WorkflowClock] as const),
24
+ DI.scoped(DI.factoryProviderWithResolver(IWorkflowTasks, [], (resolver) => resolver.resolve(WorkflowTasks))),
25
+ scoped(TaskEvents, TaskEvents, [WorkflowTasks] as const),
26
+ ...withEventSubscribers(TaskEvents),
27
+ ],
28
+ controllers: [DI.bindDeps(TasksController, IWorkflowTasks)],
29
+ exports: [IWorkflowTasks],
30
+ })
31
+ export class WorkflowTasksModule {}
@@ -0,0 +1,23 @@
1
+ import { OnEvent } from "../../../kernel";
2
+ import { WORKFLOW_INSTANCES_DELETED, WORKFLOW_TASK_REQUESTED, WORKFLOW_TOKEN_CLOSED, type WorkflowInstancesDeleted, type WorkflowTaskRequested, type WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
3
+ import type { WorkflowTasks } from "../services/WorkflowTasks.service";
4
+
5
+ /** Keeps the inbox in line with the steps: a waiting user task gets a task, a closed one cancels it. */
6
+ export class TaskEvents {
7
+ constructor(private readonly tasks: WorkflowTasks) {}
8
+
9
+ @OnEvent(WORKFLOW_TASK_REQUESTED)
10
+ requested(event: WorkflowTaskRequested): Promise<void> {
11
+ return this.tasks.requested(event);
12
+ }
13
+
14
+ @OnEvent(WORKFLOW_TOKEN_CLOSED)
15
+ closed(event: WorkflowTokenClosed): Promise<void> {
16
+ return this.tasks.closed(event);
17
+ }
18
+
19
+ @OnEvent(WORKFLOW_INSTANCES_DELETED)
20
+ deleted(event: WorkflowInstancesDeleted): Promise<void> {
21
+ return this.tasks.deleted(event);
22
+ }
23
+ }
@@ -0,0 +1,67 @@
1
+ import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
2
+ import { Controller, Get, Post, type HttpContext } from "../../../http";
3
+ import type { WorkflowActor } from "../../contracts/types";
4
+ import { WorkflowError } from "../../contracts/WorkflowError";
5
+ import { assigneesOf, authorize, body, optionalString, pagingQuery, param } from "../../internal/http";
6
+ import type { TaskStatus } from "../model/WfTask.model";
7
+ import { IWorkflowTasks, type TaskDetails } from "../services/IWorkflowTasks.service";
8
+
9
+ /**
10
+ * The caller's tasks (WORKFLOW-001 §5): those addressed to any of
11
+ * `WorkflowAccess.assignees` or claimed by the caller. Another person's task
12
+ * answers 404, as if it did not exist.
13
+ */
14
+ @Controller("workflow/tasks")
15
+ export class TasksController {
16
+ constructor(private readonly tasks: IWorkflowTasks) {}
17
+
18
+ @Get()
19
+ async list(ctx: HttpContext) {
20
+ const partition = optionalString(ctx.query("partition"), "partition");
21
+ const actor = await authorize(ctx, "tasks.work", { partition: partition ?? null });
22
+ const status = ctx.query("status");
23
+ const page = await this.tasks.list({
24
+ ...pagingQuery(ctx),
25
+ assignees: await assigneesOf(ctx, actor),
26
+ actor,
27
+ ...(status === undefined ? {} : { status: status.split(",") as TaskStatus[] }),
28
+ partition,
29
+ processKey: optionalString(ctx.query("processKey"), "processKey"),
30
+ });
31
+ return { data: page.items, meta: { total: page.total } };
32
+ }
33
+
34
+ @Get(":id")
35
+ async get(ctx: HttpContext) {
36
+ return (await this.visible(ctx)).task;
37
+ }
38
+
39
+ @Post(":id/claim")
40
+ async claim(ctx: HttpContext) {
41
+ const { task, actor } = await this.visible(ctx);
42
+ return await this.tasks.claim(task.id, actor);
43
+ }
44
+
45
+ @Post(":id/release")
46
+ async release(ctx: HttpContext) {
47
+ const { task, actor } = await this.visible(ctx);
48
+ return await this.tasks.release(task.id, actor);
49
+ }
50
+
51
+ @Post(":id/complete")
52
+ async complete(ctx: HttpContext) {
53
+ const { task, actor } = await this.visible(ctx);
54
+ const request = await body(ctx);
55
+ return await this.tasks.complete(task.id, request.output ?? {}, actor);
56
+ }
57
+
58
+ /** The task if the caller may see it: addressed to one of their assignees or claimed by them. */
59
+ private async visible(ctx: HttpContext): Promise<{ task: TaskDetails; actor: WorkflowActor }> {
60
+ const task = await this.tasks.get(param(ctx, "id"));
61
+ const actor = await authorize(ctx, "tasks.work", { processKey: task.processKey, partition: task.partition, instanceId: task.instanceId, taskId: task.id });
62
+ const assignees = await assigneesOf(ctx, actor);
63
+ if (!assignees.includes(task.assignee) && task.claimedBy !== actor.id) throw new WorkflowError("not_found", `Task ${task.id} does not exist`);
64
+ return { task, actor };
65
+ }
66
+ }
67
+ contextOnlyController(TasksController);
@@ -0,0 +1,14 @@
1
+ import { DbContext, defineOrmOwnedStoreV1 } from "../../../orm";
2
+ import { WfTask } from "./WfTask.model";
3
+
4
+ export class TasksDbContext extends DbContext {
5
+ readonly tasks = this.set(WfTask);
6
+ }
7
+
8
+ /** The table of the Tasks part: bazis creates and checks it in the application database. */
9
+ export const tasksStore = defineOrmOwnedStoreV1({
10
+ contract: "bazis.orm-owned-store/v1",
11
+ storeKey: "bazis.workflow.tasks",
12
+ formatVersion: 1,
13
+ ownedScope: { schema: "public", tablePrefix: "bazis_wf_task_" },
14
+ });
@@ -0,0 +1,69 @@
1
+ import { Column, Entity, Index, UUID } from "../../../orm";
2
+ import type { FormSpec } from "../../../../library/workflow";
3
+
4
+ /** `open` waits for someone, `claimed` is taken by one person, `completed` and `cancelled` are done. */
5
+ export type TaskStatus = "open" | "claimed" | "completed" | "cancelled";
6
+
7
+ /**
8
+ * A task for a person: a `userTask` step of an instance waiting for its form.
9
+ * Created from `WORKFLOW_TASK_REQUESTED`; references are text (other stores).
10
+ */
11
+ @Index(["assignee", "status"])
12
+ @Index(["partition", "status"])
13
+ @Entity({ table: "bazis_wf_task_items" })
14
+ export class WfTask {
15
+ @UUID({ version: "v7" })
16
+ id = "";
17
+
18
+ /** The waiting token: one task per token. */
19
+ @Index({ unique: true })
20
+ @Column({ type: "text" })
21
+ tokenId = "";
22
+
23
+ @Index()
24
+ @Column({ type: "text" })
25
+ instanceId = "";
26
+
27
+ @Column({ type: "text" })
28
+ processKey = "";
29
+
30
+ @Column({ type: "text" })
31
+ nodeId = "";
32
+
33
+ @Column({ type: "text", nullable: true })
34
+ partition: string | null = null;
35
+
36
+ @Column({ type: "text" })
37
+ title = "";
38
+
39
+ /** `role:<code>` or `user:<id>`. */
40
+ @Column({ type: "text" })
41
+ assignee = "";
42
+
43
+ @Column({ type: "json", nullable: true })
44
+ form: FormSpec | null = null;
45
+
46
+ @Column({ type: "datetime", nullable: true })
47
+ dueAt: Date | null = null;
48
+
49
+ @Column({ type: "text" })
50
+ status: TaskStatus = "open";
51
+
52
+ @Column({ type: "text", nullable: true })
53
+ claimedById: string | null = null;
54
+
55
+ @Column({ type: "datetime", nullable: true })
56
+ claimedAt: Date | null = null;
57
+
58
+ @Column({ type: "text", nullable: true })
59
+ completedById: string | null = null;
60
+
61
+ @Column({ type: "datetime", nullable: true })
62
+ completedAt: Date | null = null;
63
+
64
+ @Column({ type: "json", nullable: true })
65
+ output: Record<string, unknown> | null = null;
66
+
67
+ @Column({ type: "createdAt" })
68
+ createdAt = new Date(0);
69
+ }