bazis 0.125.0 → 0.127.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/Workflow.module.ts +4 -2
- package/core/workflow/definitions_module/model/WfVersion.model.ts +2 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +2 -0
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +10 -0
- package/core/workflow/index.ts +7 -0
- package/core/workflow/internal/http.ts +6 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +4 -1
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +5 -1
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +21 -1
- package/core/workflow/runtime_module/events/Runtime.events.ts +6 -0
- package/core/workflow/runtime_module/http/Events.controller.ts +26 -0
- package/core/workflow/runtime_module/internal/Schedules.ts +82 -0
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +73 -9
- package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
- package/core/workflow/runtime_module/model/WfInstance.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfJob.model.ts +3 -3
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +2 -2
- package/core/workflow/runtime_module/model/WfToken.model.ts +1 -1
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +24 -0
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +43 -1
- package/core/workflow/tasks_module/WorkflowTasks.module.ts +29 -0
- package/core/workflow/tasks_module/events/TaskEvents.ts +23 -0
- package/core/workflow/tasks_module/http/Tasks.controller.ts +67 -0
- package/core/workflow/tasks_module/model/Tasks.dbContext.ts +14 -0
- package/core/workflow/tasks_module/model/WfTask.model.ts +69 -0
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +52 -0
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +167 -0
- package/library/workflow/cron.ts +89 -0
- package/library/workflow/index.ts +1 -0
- package/library/workflow/nodeKinds.ts +3 -3
- package/package.json +1 -1
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.127.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -3,13 +3,15 @@ import { WorkflowDefinitionsModule } from "./definitions_module/WorkflowDefiniti
|
|
|
3
3
|
import { IWorkflowDefinitions } from "./definitions_module/services/IWorkflowDefinitions.service";
|
|
4
4
|
import { WorkflowRuntimeModule } from "./runtime_module/WorkflowRuntime.module";
|
|
5
5
|
import { IWorkflowRuntime } from "./runtime_module/services/IWorkflowRuntime.service";
|
|
6
|
+
import { WorkflowTasksModule } from "./tasks_module/WorkflowTasks.module";
|
|
7
|
+
import { IWorkflowTasks } from "./tasks_module/services/IWorkflowTasks.service";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Business processes (WORKFLOW-001): the pack only assembles its parts
|
|
9
11
|
* ([passport](MODULE.md)). An application imports it, or only the parts it needs.
|
|
10
12
|
*/
|
|
11
13
|
@Module({
|
|
12
|
-
imports: [WorkflowDefinitionsModule, WorkflowRuntimeModule],
|
|
13
|
-
exports: [IWorkflowDefinitions, IWorkflowRuntime],
|
|
14
|
+
imports: [WorkflowDefinitionsModule, WorkflowRuntimeModule, WorkflowTasksModule],
|
|
15
|
+
exports: [IWorkflowDefinitions, IWorkflowRuntime, IWorkflowTasks],
|
|
14
16
|
})
|
|
15
17
|
export class WorkflowModule {}
|
|
@@ -9,7 +9,8 @@ export class WfVersion {
|
|
|
9
9
|
@UUID({ version: "v7" })
|
|
10
10
|
id = "";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** A reference: the foreign key makes the column a native uuid. A non-key `type: "uuid"` would generate a value. */
|
|
13
|
+
@Column({ type: "text" })
|
|
13
14
|
processId = "";
|
|
14
15
|
|
|
15
16
|
@Column({ type: "integer" })
|
|
@@ -70,6 +70,8 @@ export abstract class IWorkflowDefinitions {
|
|
|
70
70
|
abstract version(key: string, version: number): Promise<VersionDetails>;
|
|
71
71
|
/** The latest published version of a process that is not archived. */
|
|
72
72
|
abstract latest(key: string): Promise<VersionDetails>;
|
|
73
|
+
/** The latest version of every process that is not archived: where event and scheduled starts come from. */
|
|
74
|
+
abstract published(): Promise<readonly VersionDetails[]>;
|
|
73
75
|
/** A version by id, for the engine; `undefined` if there is none. */
|
|
74
76
|
abstract versionById(id: string): Promise<VersionDetails | undefined>;
|
|
75
77
|
/** Processes with a retention period: what the retention service deletes. */
|
|
@@ -141,6 +141,16 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
|
|
|
141
141
|
return await this.version(key, row.latestVersion);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
async published(): Promise<readonly VersionDetails[]> {
|
|
145
|
+
const processes = await this.db.processes.asNoTracking().where((p) => p.archivedAt.isNull().and(p.latestVersion.isNotNull())).toList();
|
|
146
|
+
if (processes.length === 0) return [];
|
|
147
|
+
const byId = new Map(processes.map((process) => [process.id, process]));
|
|
148
|
+
const versions = await this.db.versions.asNoTracking().where((v) => v.processId.in([...byId.keys()])).toList();
|
|
149
|
+
return versions
|
|
150
|
+
.filter((version) => byId.get(version.processId)?.latestVersion === version.version)
|
|
151
|
+
.map((version) => versionOf(byId.get(version.processId)!.key, version));
|
|
152
|
+
}
|
|
153
|
+
|
|
144
154
|
async versionById(id: string): Promise<VersionDetails | undefined> {
|
|
145
155
|
if (!isUuid(id)) return undefined;
|
|
146
156
|
const version = await this.db.versions.asNoTracking().firstOrDefault((v) => v.id.eq(id));
|
package/core/workflow/index.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
export { WorkflowModule } from "./Workflow.module";
|
|
7
7
|
export { WorkflowDefinitionsModule } from "./definitions_module/WorkflowDefinitions.module";
|
|
8
8
|
export { WorkflowRuntimeModule } from "./runtime_module/WorkflowRuntime.module";
|
|
9
|
+
export { WorkflowTasksModule } from "./tasks_module/WorkflowTasks.module";
|
|
10
|
+
export { IWorkflowTasks, type TaskDetails, type TaskListQuery } from "./tasks_module/services/IWorkflowTasks.service";
|
|
11
|
+
export type { TaskStatus } from "./tasks_module/model/WfTask.model";
|
|
9
12
|
export {
|
|
10
13
|
IWorkflowDefinitions,
|
|
11
14
|
type CreateProcess,
|
|
@@ -18,6 +21,8 @@ export {
|
|
|
18
21
|
} from "./definitions_module/services/IWorkflowDefinitions.service";
|
|
19
22
|
export {
|
|
20
23
|
IWorkflowRuntime,
|
|
24
|
+
type EmitOptions,
|
|
25
|
+
type EmitResult,
|
|
21
26
|
type HistoryEntry,
|
|
22
27
|
type InstanceDetails,
|
|
23
28
|
type InstanceListQuery,
|
|
@@ -27,9 +32,11 @@ export {
|
|
|
27
32
|
} from "./runtime_module/services/IWorkflowRuntime.service";
|
|
28
33
|
export {
|
|
29
34
|
WORKFLOW_INSTANCE_FINISHED,
|
|
35
|
+
WORKFLOW_INSTANCES_DELETED,
|
|
30
36
|
WORKFLOW_TASK_REQUESTED,
|
|
31
37
|
WORKFLOW_TOKEN_CLOSED,
|
|
32
38
|
type WorkflowInstanceFinished,
|
|
39
|
+
type WorkflowInstancesDeleted,
|
|
33
40
|
type WorkflowTaskRequested,
|
|
34
41
|
type WorkflowTokenClosed,
|
|
35
42
|
} from "./runtime_module/events/Runtime.events";
|
|
@@ -16,6 +16,12 @@ export async function authorize(ctx: HttpContext, action: WorkflowAction, target
|
|
|
16
16
|
return actor;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Whom the caller's tasks are addressed to, from the application's {@link WorkflowAccess}. */
|
|
20
|
+
export async function assigneesOf(ctx: HttpContext, actor: WorkflowActor): Promise<readonly string[]> {
|
|
21
|
+
const access = ctx.services.tryResolve(WorkflowAccess);
|
|
22
|
+
return access === undefined ? [] : await access.assignees(ctx, actor);
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
/** A route parameter as a string. */
|
|
20
26
|
export function param(ctx: HttpContext, name: string): string {
|
|
21
27
|
return String(ctx.params[name] ?? "");
|
|
@@ -7,8 +7,10 @@ import { WorkflowTools } from "../internal/WorkflowTools";
|
|
|
7
7
|
import { WorkflowRetention } from "./background/WorkflowRetention";
|
|
8
8
|
import { WorkflowWorker } from "./background/WorkflowWorker";
|
|
9
9
|
import { workflowRuntimeConfig } from "./config/Runtime.config";
|
|
10
|
+
import { EventsController } from "./http/Events.controller";
|
|
10
11
|
import { InstancesController } from "./http/Instances.controller";
|
|
11
12
|
import { GraphCache } from "./internal/GraphCache";
|
|
13
|
+
import { Schedules } from "./internal/Schedules";
|
|
12
14
|
import { TimerStep } from "./internal/TimerStep";
|
|
13
15
|
import { ToolStep } from "./internal/ToolStep";
|
|
14
16
|
import { WorkflowEngine } from "./internal/WorkflowEngine";
|
|
@@ -37,6 +39,7 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
|
|
|
37
39
|
))),
|
|
38
40
|
scoped(ToolStep, ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools] as const),
|
|
39
41
|
scoped(TimerStep, TimerStep, [RuntimeDbContext, WorkflowEngine] as const),
|
|
42
|
+
scoped(Schedules, Schedules, [RuntimeDbContext, WorkflowEngine, IWorkflowDefinitions] as const),
|
|
40
43
|
// The application's input validator, if it registered one anywhere; the built-in one otherwise.
|
|
41
44
|
DI.scoped(DI.factoryProviderWithResolver(IWorkflowRuntime, [], (resolver) => new WorkflowRuntime(
|
|
42
45
|
resolver.resolve(RuntimeDbContext),
|
|
@@ -45,7 +48,7 @@ import { WorkflowRuntime } from "./services/WorkflowRuntime.service";
|
|
|
45
48
|
resolver.resolve(SERVICE_PROVIDER).tryResolve(WorkflowInputValidator) ?? new DefaultWorkflowInputValidator(),
|
|
46
49
|
))),
|
|
47
50
|
],
|
|
48
|
-
controllers: [DI.bindDeps(InstancesController, IWorkflowRuntime)],
|
|
51
|
+
controllers: [DI.bindDeps(InstancesController, IWorkflowRuntime), DI.bindDeps(EventsController, IWorkflowRuntime)],
|
|
49
52
|
background: [
|
|
50
53
|
DI.bindDeps(WorkflowWorker, SERVICE_PROVIDER, workflowRuntimeConfig.token),
|
|
51
54
|
DI.bindDeps(WorkflowRetention, SERVICE_PROVIDER, workflowRuntimeConfig.token),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BackgroundService, delay } from "../../../background";
|
|
2
2
|
import type { ServiceProvider } from "../../../di";
|
|
3
|
-
import type
|
|
3
|
+
import { EventBus, type ConfigView } from "../../../kernel";
|
|
4
4
|
import { IWorkflowDefinitions } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
5
|
+
import { WORKFLOW_INSTANCES_DELETED } from "../events/Runtime.events";
|
|
5
6
|
import { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
6
7
|
import type { WorkflowSettings } from "./WorkflowWorker";
|
|
7
8
|
|
|
@@ -11,6 +12,8 @@ const DAY_MS = 86_400_000;
|
|
|
11
12
|
* Deletes `completed` and `cancelled` instances older than their process's
|
|
12
13
|
* `retentionDays`, with tokens, jobs, subscriptions and history (by cascade),
|
|
13
14
|
* `retention.batchSize` at a time. `failed` instances wait for a person and stay.
|
|
15
|
+
* Each deleted batch is published as `WORKFLOW_INSTANCES_DELETED`, so parts
|
|
16
|
+
* with their own data (tasks) drop theirs.
|
|
14
17
|
*/
|
|
15
18
|
export class WorkflowRetention extends BackgroundService {
|
|
16
19
|
constructor(
|
|
@@ -55,6 +58,7 @@ export class WorkflowRetention extends BackgroundService {
|
|
|
55
58
|
if (ids.length === 0) break;
|
|
56
59
|
await db.instances.asNoTracking().where((i) => i.id.in(ids)).executeDelete();
|
|
57
60
|
deleted += ids.length;
|
|
61
|
+
await scope.resolve(EventBus).publishScoped(scope, WORKFLOW_INSTANCES_DELETED, { instanceIds: ids });
|
|
58
62
|
if (ids.length < batchSize) break;
|
|
59
63
|
}
|
|
60
64
|
}
|
|
@@ -2,6 +2,7 @@ import { BackgroundService, delay } from "../../../background";
|
|
|
2
2
|
import type { ServiceProvider } from "../../../di";
|
|
3
3
|
import type { ConfigView } from "../../../kernel";
|
|
4
4
|
import type { workflowRuntimeConfig } from "../config/Runtime.config";
|
|
5
|
+
import { Schedules } from "../internal/Schedules";
|
|
5
6
|
import { TimerStep } from "../internal/TimerStep";
|
|
6
7
|
import { ToolStep } from "../internal/ToolStep";
|
|
7
8
|
import { WorkflowEngine } from "../internal/WorkflowEngine";
|
|
@@ -12,6 +13,8 @@ import type { WfJob } from "../model/WfJob.model";
|
|
|
12
13
|
const LEASE_MS = 5 * 60_000;
|
|
13
14
|
/** A job that keeps failing outside its Tool (the database, a lost version) fails its step after this many tries. */
|
|
14
15
|
const MAX_JOB_ATTEMPTS = 5;
|
|
16
|
+
/** How often the scheduled starts are matched against the published versions. */
|
|
17
|
+
const RECONCILE_MS = 60_000;
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
/**
|
|
@@ -32,7 +35,12 @@ export class WorkflowWorker extends BackgroundService {
|
|
|
32
35
|
if (!this.config.get("worker.enabled")) return;
|
|
33
36
|
const intervalMs = this.config.get("worker.intervalMs");
|
|
34
37
|
const batchSize = this.config.get("worker.batchSize");
|
|
38
|
+
let reconciledAt = 0;
|
|
35
39
|
while (!signal.aborted) {
|
|
40
|
+
if (Date.now() - reconciledAt >= RECONCILE_MS) {
|
|
41
|
+
reconciledAt = Date.now();
|
|
42
|
+
await this.reconcile();
|
|
43
|
+
}
|
|
36
44
|
let claimed = 0;
|
|
37
45
|
try {
|
|
38
46
|
const jobs = await this.claim(batchSize);
|
|
@@ -53,6 +61,7 @@ export class WorkflowWorker extends BackgroundService {
|
|
|
53
61
|
const scope = this.services.createScope();
|
|
54
62
|
try {
|
|
55
63
|
if (job.kind === "timer") await scope.resolve(TimerStep).run(job);
|
|
64
|
+
else if (job.kind === "schedule") await scope.resolve(Schedules).fire(job);
|
|
56
65
|
else await scope.resolve(ToolStep).run(job, signal);
|
|
57
66
|
} catch (error) {
|
|
58
67
|
this.reportFailure("job failed", error, { job: job.id, instance: job.instanceId, attempts: job.attempts });
|
|
@@ -62,6 +71,17 @@ export class WorkflowWorker extends BackgroundService {
|
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
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
|
+
|
|
65
85
|
/** A job that failed outside its Tool waits a minute per try, then fails its step for a person to retry. */
|
|
66
86
|
private async giveUpOrDelay(job: WfJob, error: unknown): Promise<void> {
|
|
67
87
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -90,7 +110,7 @@ export class WorkflowWorker extends BackgroundService {
|
|
|
90
110
|
return await db.transactionScope(async () => {
|
|
91
111
|
const now = new Date();
|
|
92
112
|
const jobs = await db.jobs
|
|
93
|
-
.where((j) => j.kind.in(["tool", "timer"]).and(j.dueAt.lte(now)).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
|
|
113
|
+
.where((j) => j.kind.in(["tool", "timer", "schedule"]).and(j.dueAt.lte(now)).and(j.lockedUntil.isNull().or(j.lockedUntil.lt(now))))
|
|
94
114
|
.orderBy((j) => j.id)
|
|
95
115
|
.take(batchSize)
|
|
96
116
|
.forUpdate({ skipLocked: true })
|
|
@@ -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");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { contextOnlyController } from "../../../http/Routing/contextOnlyControllers";
|
|
2
|
+
import { Controller, Post, type HttpContext } from "../../../http";
|
|
3
|
+
import { authorize, body, optionalString, param } from "../../internal/http";
|
|
4
|
+
import { IWorkflowRuntime } from "../services/IWorkflowRuntime.service";
|
|
5
|
+
|
|
6
|
+
/** Events from outside — a webhook, another service — over HTTP (WORKFLOW-001 §4). */
|
|
7
|
+
@Controller("workflow/events")
|
|
8
|
+
export class EventsController {
|
|
9
|
+
constructor(private readonly runtime: IWorkflowRuntime) {}
|
|
10
|
+
|
|
11
|
+
@Post(":eventKey")
|
|
12
|
+
async emit(ctx: HttpContext) {
|
|
13
|
+
const eventKey = param(ctx, "eventKey");
|
|
14
|
+
const request = await body(ctx);
|
|
15
|
+
const partition = optionalString(request.partition, "partition") ?? null;
|
|
16
|
+
const actor = await authorize(ctx, "events.emit", { partition });
|
|
17
|
+
const result = await this.runtime.emit(eventKey, request.payload ?? {}, {
|
|
18
|
+
correlation: optionalString(request.correlation, "correlation") ?? null,
|
|
19
|
+
partition,
|
|
20
|
+
actor,
|
|
21
|
+
});
|
|
22
|
+
ctx.res.status(202);
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
contextOnlyController(EventsController);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { parseCron } from "../../../../library/workflow";
|
|
2
|
+
import type { IWorkflowDefinitions, VersionDetails } from "../../definitions_module/services/IWorkflowDefinitions.service";
|
|
3
|
+
import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
4
|
+
import { WfJob } from "../model/WfJob.model";
|
|
5
|
+
import type { WorkflowEngine } from "./WorkflowEngine";
|
|
6
|
+
|
|
7
|
+
const SCHEDULE_ACTOR = { kind: "system", id: "schedule" } as const;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Scheduled starts (`scheduleStart`, cron in UTC). Every latest published
|
|
11
|
+
* version with such nodes has one `schedule` job, due at the nearest firing of
|
|
12
|
+
* any of them. `reconcile` keeps the jobs in line with the catalog; `fire`
|
|
13
|
+
* starts the instances of a due job and plans the next firing in the same
|
|
14
|
+
* transaction, so a crash never starts one firing twice. Firings missed while
|
|
15
|
+
* no worker ran are not caught up: the next one is planned from now.
|
|
16
|
+
*/
|
|
17
|
+
export class Schedules {
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly db: RuntimeDbContext,
|
|
20
|
+
private readonly engine: WorkflowEngine,
|
|
21
|
+
private readonly definitions: IWorkflowDefinitions,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/** Adds jobs for new scheduled versions and drops those of replaced or archived ones. */
|
|
25
|
+
async reconcile(now = new Date()): Promise<void> {
|
|
26
|
+
const versions = (await this.definitions.published()).filter((version) => scheduleNodes(version).length > 0);
|
|
27
|
+
const wanted = new Map(versions.map((version) => [version.id, version]));
|
|
28
|
+
const added: WfJob[] = [];
|
|
29
|
+
await this.db.transactionScope(async () => {
|
|
30
|
+
// One reconciler at a time across application instances.
|
|
31
|
+
await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext('bazis.workflow.schedules'))");
|
|
32
|
+
const jobs = await this.db.jobs.asNoTracking().where((j) => j.kind.eq("schedule")).toList();
|
|
33
|
+
const stale = jobs.filter((job) => job.versionId === null || !wanted.has(job.versionId)).map((job) => job.id);
|
|
34
|
+
if (stale.length > 0) await this.db.jobs.asNoTracking().where((j) => j.id.in(stale)).executeDelete();
|
|
35
|
+
const planned = new Set(jobs.map((job) => job.versionId));
|
|
36
|
+
for (const version of versions) {
|
|
37
|
+
if (planned.has(version.id)) continue;
|
|
38
|
+
const dueAt = nextFiring(version, now);
|
|
39
|
+
if (dueAt === undefined) continue;
|
|
40
|
+
const job = Object.assign(new WfJob(), { id: Bun.randomUUIDv7(), kind: "schedule" as const, versionId: version.id, dueAt });
|
|
41
|
+
this.db.jobs.add(job);
|
|
42
|
+
added.push(job);
|
|
43
|
+
}
|
|
44
|
+
await this.db.saveChanges();
|
|
45
|
+
});
|
|
46
|
+
// The jobs belong to the worker from now on: this context must not hold them.
|
|
47
|
+
for (const job of added) this.db.detach(job);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A `schedule` job is due: start an instance from every node firing at this minute and plan the next firing. */
|
|
51
|
+
async fire(job: WfJob): Promise<void> {
|
|
52
|
+
const version = job.versionId === null ? undefined : await this.definitions.versionById(job.versionId);
|
|
53
|
+
const latest = version === undefined ? undefined : (await this.definitions.published()).find((item) => item.id === version.id);
|
|
54
|
+
if (!latest) {
|
|
55
|
+
await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const due = new Date(Math.floor(job.dueAt.getTime() / 60_000) * 60_000);
|
|
59
|
+
const graph = this.engine.cache(latest);
|
|
60
|
+
await this.db.transactionScope(async () => {
|
|
61
|
+
const next = nextFiring(latest, due);
|
|
62
|
+
if (next === undefined) await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeDelete();
|
|
63
|
+
else await this.db.jobs.asNoTracking().where((j) => j.id.eq(job.id)).executeUpdate({ dueAt: next, lockedUntil: null, attempts: 0, lastError: null });
|
|
64
|
+
for (const [nodeId, cron] of scheduleNodes(latest)) {
|
|
65
|
+
if (!cron.matches(due)) continue;
|
|
66
|
+
await this.engine.begin(graph, nodeId, { input: {}, partition: null, businessKey: null, startedVia: "schedule", actor: SCHEDULE_ACTOR });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function scheduleNodes(version: VersionDetails) {
|
|
73
|
+
return Object.entries(version.document.nodes).flatMap(([id, node]) => {
|
|
74
|
+
const cron = node.kind === "scheduleStart" && typeof node.config?.cron === "string" ? parseCron(node.config.cron) : undefined;
|
|
75
|
+
return cron === undefined ? [] : [[id, cron] as const];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function nextFiring(version: VersionDetails, after: Date): Date | undefined {
|
|
80
|
+
const times = scheduleNodes(version).map(([, cron]) => cron.next(after)?.getTime()).filter((time): time is number => time !== undefined);
|
|
81
|
+
return times.length === 0 ? undefined : new Date(Math.min(...times));
|
|
82
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { RuntimeDbContext } from "../model/Runtime.dbContext";
|
|
|
8
8
|
import { WfHistory, type HistoryType } from "../model/WfHistory.model";
|
|
9
9
|
import { WfInstance, type StartedVia } from "../model/WfInstance.model";
|
|
10
10
|
import { WfJob } from "../model/WfJob.model";
|
|
11
|
+
import { WfSubscription } from "../model/WfSubscription.model";
|
|
11
12
|
import { WfToken } from "../model/WfToken.model";
|
|
12
13
|
import { buildGraph, type GraphCache, type ProcessGraph } from "./GraphCache";
|
|
13
14
|
|
|
@@ -36,8 +37,6 @@ export interface BeginOptions {
|
|
|
36
37
|
readonly actor: WorkflowActor;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/** Node kinds that come with later stages: an instance reaching one fails with a clear reason. */
|
|
40
|
-
const NOT_YET = new Set(["eventWait"]);
|
|
41
40
|
const LIVE = new Set(["active", "waiting", "failed"]);
|
|
42
41
|
|
|
43
42
|
/**
|
|
@@ -174,9 +173,32 @@ export class WorkflowEngine {
|
|
|
174
173
|
this.history(step, token, token.nodeId, "timer", { firedAt });
|
|
175
174
|
return await this.complete(step, tokenId, { firedAt });
|
|
176
175
|
}
|
|
177
|
-
if (node?.kind !== "boundaryTimer"
|
|
176
|
+
if (node?.kind !== "boundaryTimer") return false;
|
|
177
|
+
return await this.fireBoundary(step, token, node, { firedAt }, "timer");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* An event the token waits for arrived. An `eventWait` step moves on with
|
|
182
|
+
* the payload (and `saveTo`); a `boundaryEvent` takes its flow like a
|
|
183
|
+
* boundary timer. `false` when the token no longer waits.
|
|
184
|
+
*/
|
|
185
|
+
async eventReceived(step: Step, tokenId: string, eventKey: string, payload: Record<string, unknown>): Promise<boolean> {
|
|
186
|
+
const token = step.tokens.find((t) => t.id === tokenId);
|
|
187
|
+
if (!token || token.status !== "waiting" || step.instance.status !== "running") return false;
|
|
188
|
+
const node = step.graph.document.nodes[token.nodeId];
|
|
189
|
+
if (node?.kind === "eventWait") {
|
|
190
|
+
this.history(step, token, token.nodeId, "event", { event: eventKey });
|
|
191
|
+
return await this.complete(step, tokenId, payload);
|
|
192
|
+
}
|
|
193
|
+
if (node?.kind !== "boundaryEvent") return false;
|
|
194
|
+
return await this.fireBoundary(step, token, node, payload, "event", { event: eventKey });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A boundary timer or event fired: an interrupting one closes the watched step first, then the boundary takes its flow. */
|
|
198
|
+
private async fireBoundary(step: Step, token: WfToken, node: ProcessNode, output: Record<string, unknown>, type: "timer" | "event", details: Record<string, unknown> = {}): Promise<boolean> {
|
|
199
|
+
if (node.attachedTo === undefined) return false;
|
|
178
200
|
const interrupting = node.config?.interrupting !== false;
|
|
179
|
-
this.history(step, token, token.nodeId, "timer"
|
|
201
|
+
this.history(step, token, token.nodeId, type, { ...details, ...(type === "timer" ? output : {}), interrupting });
|
|
180
202
|
if (interrupting) {
|
|
181
203
|
for (const host of step.tokens.filter((t) => t.nodeId === node.attachedTo && LIVE.has(t.status))) {
|
|
182
204
|
this.close(host, "cancelled");
|
|
@@ -186,7 +208,7 @@ export class WorkflowEngine {
|
|
|
186
208
|
}
|
|
187
209
|
this.disarm(step, node.attachedTo, token.id);
|
|
188
210
|
}
|
|
189
|
-
step.instance.nodes = { ...step.instance.nodes, [token.nodeId]:
|
|
211
|
+
step.instance.nodes = { ...step.instance.nodes, [token.nodeId]: output };
|
|
190
212
|
await this.advance(step, this.leave(step, token, this.single(step, token.nodeId)));
|
|
191
213
|
return true;
|
|
192
214
|
}
|
|
@@ -277,10 +299,6 @@ export class WorkflowEngine {
|
|
|
277
299
|
}
|
|
278
300
|
// An error boundary that caught a step's failure: it only leads on.
|
|
279
301
|
if (role === "boundary") return this.leave(step, token, this.single(step, token.nodeId));
|
|
280
|
-
if (NOT_YET.has(node.kind)) {
|
|
281
|
-
this.fail(step, token, "not_supported", `${node.kind} steps are not supported yet`);
|
|
282
|
-
return [];
|
|
283
|
-
}
|
|
284
302
|
switch (node.kind) {
|
|
285
303
|
case "serviceTask":
|
|
286
304
|
return this.arm(step, token, this.waitForTool(step, token));
|
|
@@ -288,6 +306,8 @@ export class WorkflowEngine {
|
|
|
288
306
|
return this.arm(step, token, this.waitForTask(step, token, node));
|
|
289
307
|
case "timerCatch":
|
|
290
308
|
return this.arm(step, token, this.waitForTimer(step, token, node));
|
|
309
|
+
case "eventWait":
|
|
310
|
+
return this.arm(step, token, this.waitForEvent(step, token, node));
|
|
291
311
|
case "exclusiveGateway":
|
|
292
312
|
return this.choose(step, token);
|
|
293
313
|
case "parallelGateway":
|
|
@@ -316,6 +336,17 @@ export class WorkflowEngine {
|
|
|
316
336
|
private arm(step: Step, host: WfToken, next: WfToken[]): WfToken[] {
|
|
317
337
|
if (host.status !== "waiting") return next;
|
|
318
338
|
for (const [id, node] of step.graph.boundaries.get(host.nodeId) ?? []) {
|
|
339
|
+
if (node.kind === "boundaryEvent") {
|
|
340
|
+
const correlation = correlationOf(node.config ?? {}, dataOf(step.instance));
|
|
341
|
+
if (correlation === undefined) {
|
|
342
|
+
this.fail(step, host, "bad_correlation", `the correlation of ${id} has no value`);
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
const watcher = this.spawn(step, id, null);
|
|
346
|
+
watcher.status = "waiting";
|
|
347
|
+
this.subscribe(step, watcher, String(node.config?.event), correlation);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
319
350
|
if (node.kind !== "boundaryTimer") continue;
|
|
320
351
|
const dueAt = timerDue(node.config ?? {}, dataOf(step.instance));
|
|
321
352
|
if (dueAt === undefined) {
|
|
@@ -338,6 +369,28 @@ export class WorkflowEngine {
|
|
|
338
369
|
}
|
|
339
370
|
}
|
|
340
371
|
|
|
372
|
+
private waitForEvent(step: Step, token: WfToken, node: ProcessNode): WfToken[] {
|
|
373
|
+
const correlation = correlationOf(node.config ?? {}, dataOf(step.instance));
|
|
374
|
+
if (correlation === undefined) {
|
|
375
|
+
this.fail(step, token, "bad_correlation", `the correlation of ${token.nodeId} has no value`);
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
token.status = "waiting";
|
|
379
|
+
this.subscribe(step, token, String(node.config?.event), correlation);
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private subscribe(step: Step, token: WfToken, eventKey: string, correlation: string | null): void {
|
|
384
|
+
this.db.subscriptions.add(Object.assign(new WfSubscription(), {
|
|
385
|
+
id: Bun.randomUUIDv7(),
|
|
386
|
+
instanceId: step.instance.id,
|
|
387
|
+
tokenId: token.id,
|
|
388
|
+
eventKey,
|
|
389
|
+
correlation,
|
|
390
|
+
partition: step.instance.partition,
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
|
|
341
394
|
private waitForTool(step: Step, token: WfToken): WfToken[] {
|
|
342
395
|
token.status = "waiting";
|
|
343
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() }));
|
|
@@ -465,6 +518,17 @@ export function timerDue(config: Readonly<Record<string, unknown>>, data: Instan
|
|
|
465
518
|
return undefined;
|
|
466
519
|
}
|
|
467
520
|
|
|
521
|
+
/**
|
|
522
|
+
* The correlation a wait matches: the value at `config.correlation` as a
|
|
523
|
+
* string, `null` without a path (any event of the key), `undefined` when the
|
|
524
|
+
* path has no value.
|
|
525
|
+
*/
|
|
526
|
+
export function correlationOf(config: Readonly<Record<string, unknown>>, data: InstanceData): string | null | undefined {
|
|
527
|
+
if (typeof config.correlation !== "string") return null;
|
|
528
|
+
const value = readPath(data, config.correlation);
|
|
529
|
+
return value === undefined || value === null || value === "" || typeof value === "object" ? undefined : String(value);
|
|
530
|
+
}
|
|
531
|
+
|
|
468
532
|
export function dataOf(instance: WfInstance): InstanceData {
|
|
469
533
|
return { input: instance.input, vars: instance.vars, nodes: instance.nodes };
|
|
470
534
|
}
|
|
@@ -11,10 +11,10 @@ export class WfHistory {
|
|
|
11
11
|
@UUID({ version: "v7" })
|
|
12
12
|
id = "";
|
|
13
13
|
|
|
14
|
-
@Column({ type: "
|
|
14
|
+
@Column({ type: "text" })
|
|
15
15
|
instanceId = "";
|
|
16
16
|
|
|
17
|
-
@Column({ type: "
|
|
17
|
+
@Column({ type: "text", nullable: true })
|
|
18
18
|
tokenId: string | null = null;
|
|
19
19
|
|
|
20
20
|
@Column({ type: "text", nullable: true })
|
|
@@ -19,15 +19,15 @@ export class WfJob {
|
|
|
19
19
|
|
|
20
20
|
/** `null` for a `schedule` job: its instance does not exist yet. */
|
|
21
21
|
@Index()
|
|
22
|
-
@Column({ type: "
|
|
22
|
+
@Column({ type: "text", nullable: true })
|
|
23
23
|
instanceId: string | null = null;
|
|
24
24
|
|
|
25
25
|
@Index()
|
|
26
|
-
@Column({ type: "
|
|
26
|
+
@Column({ type: "text", nullable: true })
|
|
27
27
|
tokenId: string | null = null;
|
|
28
28
|
|
|
29
29
|
/** The version a `schedule` job starts. */
|
|
30
|
-
@Column({ type: "
|
|
30
|
+
@Column({ type: "text", nullable: true })
|
|
31
31
|
versionId: string | null = null;
|
|
32
32
|
|
|
33
33
|
@Index()
|
|
@@ -9,11 +9,11 @@ export class WfSubscription {
|
|
|
9
9
|
@UUID({ version: "v7" })
|
|
10
10
|
id = "";
|
|
11
11
|
|
|
12
|
-
@Column({ type: "
|
|
12
|
+
@Column({ type: "text" })
|
|
13
13
|
instanceId = "";
|
|
14
14
|
|
|
15
15
|
@Index()
|
|
16
|
-
@Column({ type: "
|
|
16
|
+
@Column({ type: "text" })
|
|
17
17
|
tokenId = "";
|
|
18
18
|
|
|
19
19
|
@Column({ type: "text" })
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FormIssue } from "../../../../library/workflow";
|
|
1
2
|
import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
|
|
2
3
|
import type { HistoryType } from "../model/WfHistory.model";
|
|
3
4
|
import type { InstanceStatus, StartedVia } from "../model/WfInstance.model";
|
|
@@ -55,6 +56,24 @@ export interface HistoryEntry {
|
|
|
55
56
|
readonly at: Date;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
export interface EmitOptions {
|
|
60
|
+
/** Which waits it is for: `order:42`. Waits without a correlation take any event of the key. */
|
|
61
|
+
readonly correlation?: string | null;
|
|
62
|
+
/** Only the waits of this partition; it is also the partition of instances the event starts. */
|
|
63
|
+
readonly partition?: string | null;
|
|
64
|
+
/** On whose behalf started instances run; `{ kind: "system", id: "event:<key>" }` by default. */
|
|
65
|
+
readonly actor?: WorkflowActor;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface EmitResult {
|
|
69
|
+
/** Instances started by an `eventStart`. */
|
|
70
|
+
readonly started: readonly string[];
|
|
71
|
+
/** Instances whose `eventWait` or `boundaryEvent` took the event. */
|
|
72
|
+
readonly continued: readonly string[];
|
|
73
|
+
/** Processes with an `eventStart` whose input the payload did not pass. */
|
|
74
|
+
readonly rejected: readonly { readonly processKey: string; readonly issues: readonly FormIssue[] }[];
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
export interface InstanceListQuery extends WorkflowPaging {
|
|
59
78
|
readonly partition?: string;
|
|
60
79
|
readonly status?: InstanceStatus;
|
|
@@ -75,6 +94,11 @@ export abstract class IWorkflowRuntime {
|
|
|
75
94
|
abstract retry(instanceId: string, options: { readonly actor: WorkflowActor }): Promise<InstanceDetails>;
|
|
76
95
|
/** Finishes a user task's wait with the form output and moves on; `wrong_status` when the token no longer waits. */
|
|
77
96
|
abstract completeToken(tokenId: string, output: unknown, actor: WorkflowActor): Promise<InstanceDetails>;
|
|
97
|
+
/**
|
|
98
|
+
* Delivers an event: starts processes whose latest version has an
|
|
99
|
+
* `eventStart` for the key, and moves the instances waiting for it.
|
|
100
|
+
*/
|
|
101
|
+
abstract emit(eventKey: string, payload?: unknown, options?: EmitOptions): Promise<EmitResult>;
|
|
78
102
|
/** Instances, newest first. */
|
|
79
103
|
abstract instances(query?: InstanceListQuery): Promise<WorkflowPage<InstanceSummary>>;
|
|
80
104
|
abstract instance(instanceId: string): Promise<InstanceDetails>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FormSpec } from "../../../../library/workflow";
|
|
1
|
+
import type { FormIssue, FormSpec } from "../../../../library/workflow";
|
|
2
2
|
import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
|
|
3
3
|
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
4
4
|
import type { WorkflowInputValidator } from "../../contracts/WorkflowInputValidator";
|
|
@@ -10,6 +10,8 @@ import type { WfInstance } from "../model/WfInstance.model";
|
|
|
10
10
|
import type { WfToken } from "../model/WfToken.model";
|
|
11
11
|
import {
|
|
12
12
|
IWorkflowRuntime,
|
|
13
|
+
type EmitOptions,
|
|
14
|
+
type EmitResult,
|
|
13
15
|
type HistoryEntry,
|
|
14
16
|
type InstanceDetails,
|
|
15
17
|
type InstanceListQuery,
|
|
@@ -18,6 +20,7 @@ import {
|
|
|
18
20
|
} from "./IWorkflowRuntime.service";
|
|
19
21
|
|
|
20
22
|
const STATUSES = new Set(["running", "completed", "cancelled", "failed"]);
|
|
23
|
+
const EVENT_KEY = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/;
|
|
21
24
|
|
|
22
25
|
export class WorkflowRuntime extends IWorkflowRuntime {
|
|
23
26
|
constructor(
|
|
@@ -87,6 +90,45 @@ export class WorkflowRuntime extends IWorkflowRuntime {
|
|
|
87
90
|
return await this.instance(token.instanceId);
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
async emit(eventKey: string, payload: unknown = {}, options: EmitOptions = {}): Promise<EmitResult> {
|
|
94
|
+
if (typeof eventKey !== "string" || !EVENT_KEY.test(eventKey)) throw new WorkflowError("invalid_input", "eventKey must look like payments.received");
|
|
95
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new WorkflowError("invalid_input", "payload must be a JSON object");
|
|
96
|
+
const data = payload as Record<string, unknown>;
|
|
97
|
+
const correlation = optionalText(options.correlation, "correlation");
|
|
98
|
+
const partition = optionalText(options.partition, "partition");
|
|
99
|
+
const actor = options.actor ?? { kind: "system" as const, id: `event:${eventKey}` };
|
|
100
|
+
actorOf(actor);
|
|
101
|
+
|
|
102
|
+
let waits = this.db.subscriptions.asNoTracking().where((s) => s.eventKey.eq(eventKey));
|
|
103
|
+
waits = correlation === null ? waits.where((s) => s.correlation.isNull()) : waits.where((s) => s.correlation.isNull().or(s.correlation.eq(correlation)));
|
|
104
|
+
if (partition !== null) waits = waits.where((s) => s.partition.eq(partition));
|
|
105
|
+
const continued = new Set<string>();
|
|
106
|
+
for (const wait of await waits.orderBy((s) => s.id).toList()) {
|
|
107
|
+
const moved = await this.engine.inStep(wait.instanceId, async (step) => {
|
|
108
|
+
await this.db.subscriptions.asNoTracking().where((s) => s.id.eq(wait.id)).executeDelete();
|
|
109
|
+
return await this.engine.eventReceived(step, wait.tokenId, eventKey, data);
|
|
110
|
+
});
|
|
111
|
+
if (moved === true) continued.add(wait.instanceId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const started: string[] = [];
|
|
115
|
+
const rejected: { processKey: string; issues: readonly FormIssue[] }[] = [];
|
|
116
|
+
for (const version of await this.definitions.published()) {
|
|
117
|
+
for (const [nodeId, node] of Object.entries(version.document.nodes)) {
|
|
118
|
+
if (node.kind !== "eventStart" || node.config?.event !== eventKey) continue;
|
|
119
|
+
// An event carries more than a process needs: fields the input does not declare are dropped, not refused.
|
|
120
|
+
const { value, issues: all } = await this.validator.validate(version.document.input, data, { purpose: "input", processKey: version.key, partition });
|
|
121
|
+
const issues = all.filter((issue) => issue.code !== "unknown");
|
|
122
|
+
if (issues.length > 0) {
|
|
123
|
+
rejected.push({ processKey: version.key, issues });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
started.push(await this.engine.begin(this.engine.cache(version), nodeId, { input: value, partition, businessKey: correlation, startedVia: "event", actor }));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { started, continued: [...continued], rejected };
|
|
130
|
+
}
|
|
131
|
+
|
|
90
132
|
async instances(query: InstanceListQuery = {}): Promise<WorkflowPage<InstanceSummary>> {
|
|
91
133
|
const { limit, offset } = pagingOf(query);
|
|
92
134
|
if (query.status !== undefined && !STATUSES.has(query.status)) throw new WorkflowError("invalid_input", "status must be running, completed, cancelled or failed");
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { DI, Module, scoped } from "../../di";
|
|
2
|
+
import { withEventSubscribers } from "../../kernel";
|
|
3
|
+
import { IWorkflowRuntime } from "../runtime_module/services/IWorkflowRuntime.service";
|
|
4
|
+
import { WorkflowRuntimeModule } from "../runtime_module/WorkflowRuntime.module";
|
|
5
|
+
import { TaskEvents } from "./events/TaskEvents";
|
|
6
|
+
import { TasksController } from "./http/Tasks.controller";
|
|
7
|
+
import { TasksDbContext, tasksStore } from "./model/Tasks.dbContext";
|
|
8
|
+
import { WfTask } from "./model/WfTask.model";
|
|
9
|
+
import { IWorkflowTasks } from "./services/IWorkflowTasks.service";
|
|
10
|
+
import { WorkflowTasks } from "./services/WorkflowTasks.service";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tasks for people: the inbox of `userTask` steps ([passport](MODULE.md)).
|
|
14
|
+
* Dependencies are explicit: the framework's classes are not covered by the
|
|
15
|
+
* application's codegen.
|
|
16
|
+
*/
|
|
17
|
+
@Module({
|
|
18
|
+
imports: [WorkflowRuntimeModule],
|
|
19
|
+
ormBazis: { context: TasksDbContext, entities: [WfTask], ownedStore: tasksStore, registerRepositories: false },
|
|
20
|
+
providers: [
|
|
21
|
+
scoped(WorkflowTasks, WorkflowTasks, [TasksDbContext, IWorkflowRuntime] as const),
|
|
22
|
+
DI.scoped(DI.factoryProviderWithResolver(IWorkflowTasks, [], (resolver) => resolver.resolve(WorkflowTasks))),
|
|
23
|
+
scoped(TaskEvents, TaskEvents, [WorkflowTasks] as const),
|
|
24
|
+
...withEventSubscribers(TaskEvents),
|
|
25
|
+
],
|
|
26
|
+
controllers: [DI.bindDeps(TasksController, IWorkflowTasks)],
|
|
27
|
+
exports: [IWorkflowTasks],
|
|
28
|
+
})
|
|
29
|
+
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FormSpec } from "../../../../library/workflow";
|
|
2
|
+
import type { WorkflowActor, WorkflowPage, WorkflowPaging } from "../../contracts/types";
|
|
3
|
+
import type { TaskStatus } from "../model/WfTask.model";
|
|
4
|
+
|
|
5
|
+
export interface TaskDetails {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly instanceId: string;
|
|
8
|
+
readonly tokenId: string;
|
|
9
|
+
readonly processKey: string;
|
|
10
|
+
readonly nodeId: string;
|
|
11
|
+
readonly partition: string | null;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly assignee: string;
|
|
14
|
+
readonly form: FormSpec | null;
|
|
15
|
+
readonly dueAt: Date | null;
|
|
16
|
+
readonly status: TaskStatus;
|
|
17
|
+
readonly claimedBy: string | null;
|
|
18
|
+
readonly claimedAt: Date | null;
|
|
19
|
+
readonly completedBy: string | null;
|
|
20
|
+
readonly completedAt: Date | null;
|
|
21
|
+
readonly output: Record<string, unknown> | null;
|
|
22
|
+
readonly createdAt: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TaskListQuery extends WorkflowPaging {
|
|
26
|
+
/** Tasks addressed to any of these (`role:support`, `user:42`), plus those the actor claimed. */
|
|
27
|
+
readonly assignees: readonly string[];
|
|
28
|
+
/** Whose claimed tasks count as theirs. */
|
|
29
|
+
readonly actor?: WorkflowActor;
|
|
30
|
+
/** `open` and `claimed` by default. */
|
|
31
|
+
readonly status?: readonly TaskStatus[];
|
|
32
|
+
readonly partition?: string;
|
|
33
|
+
readonly processKey?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Tasks for people (WORKFLOW-001 §5): the inbox of `userTask` steps. A task
|
|
38
|
+
* appears when its step starts waiting and is cancelled when the step closes
|
|
39
|
+
* otherwise. Errors are {@link WorkflowError}: `not_found`, `conflict` (claimed
|
|
40
|
+
* by another person), `wrong_status`, `invalid_input` with the form issues.
|
|
41
|
+
*/
|
|
42
|
+
export abstract class IWorkflowTasks {
|
|
43
|
+
/** The tasks of these assignees, oldest first. */
|
|
44
|
+
abstract list(query: TaskListQuery): Promise<WorkflowPage<TaskDetails>>;
|
|
45
|
+
abstract get(taskId: string): Promise<TaskDetails>;
|
|
46
|
+
/** Takes an open task: nobody else can complete it until it is released. */
|
|
47
|
+
abstract claim(taskId: string, actor: WorkflowActor): Promise<TaskDetails>;
|
|
48
|
+
/** Gives a claimed task back. */
|
|
49
|
+
abstract release(taskId: string, actor: WorkflowActor): Promise<TaskDetails>;
|
|
50
|
+
/** Fills the form: the process moves on. */
|
|
51
|
+
abstract complete(taskId: string, output: unknown, actor: WorkflowActor): Promise<TaskDetails>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { UniqueViolationError } from "../../../orm";
|
|
2
|
+
import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
|
|
3
|
+
import { WorkflowError } from "../../contracts/WorkflowError";
|
|
4
|
+
import type { WorkflowInstancesDeleted, WorkflowTaskRequested, WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
|
|
5
|
+
import type { IWorkflowRuntime } from "../../runtime_module/services/IWorkflowRuntime.service";
|
|
6
|
+
import type { TasksDbContext } from "../model/Tasks.dbContext";
|
|
7
|
+
import { WfTask, type TaskStatus } from "../model/WfTask.model";
|
|
8
|
+
import { IWorkflowTasks, type TaskDetails, type TaskListQuery } from "./IWorkflowTasks.service";
|
|
9
|
+
|
|
10
|
+
const STATUSES = new Set<TaskStatus>(["open", "claimed", "completed", "cancelled"]);
|
|
11
|
+
|
|
12
|
+
export class WorkflowTasks extends IWorkflowTasks {
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly db: TasksDbContext,
|
|
15
|
+
private readonly runtime: IWorkflowRuntime,
|
|
16
|
+
) {
|
|
17
|
+
super();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async list(query: TaskListQuery): Promise<WorkflowPage<TaskDetails>> {
|
|
21
|
+
const { limit, offset } = pagingOf(query);
|
|
22
|
+
const statuses = [...(query.status ?? ["open", "claimed"])];
|
|
23
|
+
if (statuses.length === 0 || statuses.some((status) => !STATUSES.has(status))) throw new WorkflowError("invalid_input", "status must be open, claimed, completed or cancelled");
|
|
24
|
+
const assignees = [...query.assignees];
|
|
25
|
+
const mine = query.actor?.id;
|
|
26
|
+
if (assignees.length === 0 && mine === undefined) return { items: [], total: 0 };
|
|
27
|
+
let source = this.db.tasks.asNoTracking().where((t) => t.status.in(statuses));
|
|
28
|
+
source = mine === undefined
|
|
29
|
+
? source.where((t) => t.assignee.in(assignees))
|
|
30
|
+
: assignees.length === 0
|
|
31
|
+
? source.where((t) => t.claimedById.eq(mine))
|
|
32
|
+
: source.where((t) => t.assignee.in(assignees).or(t.claimedById.eq(mine)));
|
|
33
|
+
if (query.partition !== undefined) source = source.where((t) => t.partition.eq(query.partition!));
|
|
34
|
+
if (query.processKey !== undefined) source = source.where((t) => t.processKey.eq(query.processKey!));
|
|
35
|
+
const total = await source.count();
|
|
36
|
+
const rows = await source.orderBy((t) => t.id).skip(offset).take(limit).toList();
|
|
37
|
+
return { items: rows.map(detailsOf), total };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async get(taskId: string): Promise<TaskDetails> {
|
|
41
|
+
return detailsOf(await this.task(taskId, false));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async claim(taskId: string, actor: WorkflowActor): Promise<TaskDetails> {
|
|
45
|
+
return await this.db.transactionScope(async () => {
|
|
46
|
+
const task = await this.locked(taskId);
|
|
47
|
+
if (task.status === "claimed" && task.claimedById === actor.id) return detailsOf(task);
|
|
48
|
+
if (task.status === "claimed") throw new WorkflowError("conflict", "The task is taken by someone else");
|
|
49
|
+
if (task.status !== "open") throw new WorkflowError("wrong_status", `The task is ${task.status}`);
|
|
50
|
+
task.status = "claimed";
|
|
51
|
+
task.claimedById = actor.id;
|
|
52
|
+
task.claimedAt = new Date();
|
|
53
|
+
await this.db.saveChanges();
|
|
54
|
+
return detailsOf(task);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async release(taskId: string, actor: WorkflowActor): Promise<TaskDetails> {
|
|
59
|
+
return await this.db.transactionScope(async () => {
|
|
60
|
+
const task = await this.locked(taskId);
|
|
61
|
+
if (task.status !== "claimed" || task.claimedById !== actor.id) throw new WorkflowError("wrong_status", "Only the person who took the task gives it back");
|
|
62
|
+
task.status = "open";
|
|
63
|
+
task.claimedById = null;
|
|
64
|
+
task.claimedAt = null;
|
|
65
|
+
await this.db.saveChanges();
|
|
66
|
+
return detailsOf(task);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async complete(taskId: string, output: unknown, actor: WorkflowActor): Promise<TaskDetails> {
|
|
71
|
+
const task = await this.task(taskId, false);
|
|
72
|
+
if (task.status === "claimed" && task.claimedById !== actor.id) throw new WorkflowError("conflict", "The task is taken by someone else");
|
|
73
|
+
if (task.status !== "open" && task.status !== "claimed") throw new WorkflowError("wrong_status", `The task is ${task.status}`);
|
|
74
|
+
let instance;
|
|
75
|
+
try {
|
|
76
|
+
// The form is checked and the process moves on first; the task follows.
|
|
77
|
+
instance = await this.runtime.completeToken(task.tokenId, output, actor);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof WorkflowError && error.code === "wrong_status") await this.close(task.tokenId, "cancelled");
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const saved = instance.nodes[task.nodeId];
|
|
83
|
+
await this.db.tasks.asNoTracking().where((t) => t.id.eq(task.id)).executeUpdate({
|
|
84
|
+
status: "completed",
|
|
85
|
+
completedById: actor.id,
|
|
86
|
+
completedAt: new Date(),
|
|
87
|
+
output: typeof saved === "object" && saved !== null && !Array.isArray(saved) ? saved as Record<string, unknown> : null,
|
|
88
|
+
});
|
|
89
|
+
return await this.get(task.id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A step started waiting for a person: its task. Repeated events of one token make one task. */
|
|
93
|
+
async requested(event: WorkflowTaskRequested): Promise<void> {
|
|
94
|
+
const assignee = "role" in event.assignee ? `role:${event.assignee.role}` : `user:${event.assignee.user}`;
|
|
95
|
+
this.db.tasks.add(Object.assign(new WfTask(), {
|
|
96
|
+
tokenId: event.tokenId,
|
|
97
|
+
instanceId: event.instanceId,
|
|
98
|
+
processKey: event.processKey,
|
|
99
|
+
nodeId: event.nodeId,
|
|
100
|
+
partition: event.partition,
|
|
101
|
+
title: event.title,
|
|
102
|
+
assignee,
|
|
103
|
+
form: event.form,
|
|
104
|
+
dueAt: event.dueAt,
|
|
105
|
+
}));
|
|
106
|
+
try {
|
|
107
|
+
await this.db.saveChanges();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!(error instanceof UniqueViolationError)) throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The step closed otherwise than by its task: the task is cancelled. */
|
|
114
|
+
async closed(event: WorkflowTokenClosed): Promise<void> {
|
|
115
|
+
await this.close(event.tokenId, "cancelled");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Retention deleted the instances: their tasks go too. */
|
|
119
|
+
async deleted(event: WorkflowInstancesDeleted): Promise<void> {
|
|
120
|
+
if (event.instanceIds.length > 0) await this.db.tasks.asNoTracking().where((t) => t.instanceId.in([...event.instanceIds])).executeDelete();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async close(tokenId: string, status: "cancelled"): Promise<void> {
|
|
124
|
+
await this.db.tasks.asNoTracking().where((t) => t.tokenId.eq(tokenId).and(t.status.in(["open", "claimed"]))).executeUpdate({ status });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async task(taskId: string, tracked: boolean): Promise<WfTask> {
|
|
128
|
+
if (!isUuid(taskId)) throw notFound(taskId);
|
|
129
|
+
const source = tracked ? this.db.tasks : this.db.tasks.asNoTracking();
|
|
130
|
+
const task = await source.firstOrDefault((t) => t.id.eq(taskId));
|
|
131
|
+
if (!task) throw notFound(taskId);
|
|
132
|
+
return task;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async locked(taskId: string): Promise<WfTask> {
|
|
136
|
+
if (!isUuid(taskId)) throw notFound(taskId);
|
|
137
|
+
const task = await this.db.tasks.where((t) => t.id.eq(taskId)).forUpdate().firstOrDefault();
|
|
138
|
+
if (!task) throw notFound(taskId);
|
|
139
|
+
return task;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function notFound(taskId: string): WorkflowError {
|
|
144
|
+
return new WorkflowError("not_found", `Task ${taskId} does not exist`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function detailsOf(row: WfTask): TaskDetails {
|
|
148
|
+
return {
|
|
149
|
+
id: row.id,
|
|
150
|
+
instanceId: row.instanceId,
|
|
151
|
+
tokenId: row.tokenId,
|
|
152
|
+
processKey: row.processKey,
|
|
153
|
+
nodeId: row.nodeId,
|
|
154
|
+
partition: row.partition,
|
|
155
|
+
title: row.title,
|
|
156
|
+
assignee: row.assignee,
|
|
157
|
+
form: row.form,
|
|
158
|
+
dueAt: row.dueAt,
|
|
159
|
+
status: row.status,
|
|
160
|
+
claimedBy: row.claimedById,
|
|
161
|
+
claimedAt: row.claimedAt,
|
|
162
|
+
completedBy: row.completedById,
|
|
163
|
+
completedAt: row.completedAt,
|
|
164
|
+
output: row.output,
|
|
165
|
+
createdAt: row.createdAt,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** A parsed five-field cron expression, evaluated in UTC. */
|
|
2
|
+
export interface CronSchedule {
|
|
3
|
+
/** Whether the expression fires at this minute. */
|
|
4
|
+
matches(date: Date): boolean;
|
|
5
|
+
/** The first minute after `after` when it fires; `undefined` if none within five years. */
|
|
6
|
+
next(after: Date): Date | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const FIELDS = [
|
|
10
|
+
{ min: 0, max: 59 },
|
|
11
|
+
{ min: 0, max: 23 },
|
|
12
|
+
{ min: 1, max: 31 },
|
|
13
|
+
{ min: 1, max: 12 },
|
|
14
|
+
{ min: 0, max: 7 },
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
const MINUTE = 60_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parses `minute hour day-of-month month day-of-week` with `*`, numbers,
|
|
21
|
+
* ranges `a-b`, steps `/n` and lists `a,b`. Day of week 0 and 7 are Sunday.
|
|
22
|
+
* When both day fields are restricted, either one matching is enough, as in
|
|
23
|
+
* classic cron. Returns `undefined` for an invalid expression.
|
|
24
|
+
*/
|
|
25
|
+
export function parseCron(expression: string): CronSchedule | undefined {
|
|
26
|
+
const parts = expression.trim().split(/\s+/);
|
|
27
|
+
if (parts.length !== 5) return undefined;
|
|
28
|
+
const sets: Set<number>[] = [];
|
|
29
|
+
for (const [index, part] of parts.entries()) {
|
|
30
|
+
const set = field(part, FIELDS[index]!.min, FIELDS[index]!.max);
|
|
31
|
+
if (set === undefined) return undefined;
|
|
32
|
+
sets.push(set);
|
|
33
|
+
}
|
|
34
|
+
const [minutes, hours, days, months, weekdays] = sets as [Set<number>, Set<number>, Set<number>, Set<number>, Set<number>];
|
|
35
|
+
if (weekdays.has(7)) weekdays.add(0);
|
|
36
|
+
const daysRestricted = parts[2] !== "*";
|
|
37
|
+
const weekdaysRestricted = parts[4] !== "*";
|
|
38
|
+
|
|
39
|
+
const dayMatches = (date: Date): boolean => {
|
|
40
|
+
const day = days.has(date.getUTCDate());
|
|
41
|
+
const weekday = weekdays.has(date.getUTCDay());
|
|
42
|
+
return daysRestricted && weekdaysRestricted ? day || weekday : day && weekday;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const matches = (date: Date): boolean =>
|
|
46
|
+
minutes.has(date.getUTCMinutes()) && hours.has(date.getUTCHours()) && months.has(date.getUTCMonth() + 1) && dayMatches(date);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
matches,
|
|
50
|
+
next(after: Date): Date | undefined {
|
|
51
|
+
const limit = after.getTime() + 5 * 366 * 24 * 60 * MINUTE;
|
|
52
|
+
const date = new Date(Math.floor(after.getTime() / MINUTE) * MINUTE + MINUTE);
|
|
53
|
+
while (date.getTime() <= limit) {
|
|
54
|
+
if (!months.has(date.getUTCMonth() + 1)) {
|
|
55
|
+
date.setUTCMonth(date.getUTCMonth() + 1, 1);
|
|
56
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
57
|
+
} else if (!dayMatches(date)) {
|
|
58
|
+
date.setUTCDate(date.getUTCDate() + 1);
|
|
59
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
60
|
+
} else if (!hours.has(date.getUTCHours())) {
|
|
61
|
+
date.setUTCHours(date.getUTCHours() + 1, 0, 0, 0);
|
|
62
|
+
} else if (!minutes.has(date.getUTCMinutes())) {
|
|
63
|
+
date.setTime(date.getTime() + MINUTE);
|
|
64
|
+
} else {
|
|
65
|
+
return date;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function field(text: string, min: number, max: number): Set<number> | undefined {
|
|
74
|
+
const out = new Set<number>();
|
|
75
|
+
for (const item of text.split(",")) {
|
|
76
|
+
const match = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(item);
|
|
77
|
+
if (!match) return undefined;
|
|
78
|
+
const step = match[2] === undefined ? 1 : Number(match[2]);
|
|
79
|
+
let from = min, to = max;
|
|
80
|
+
if (match[1] !== "*") {
|
|
81
|
+
const [a, b] = match[1]!.split("-").map(Number) as [number, number | undefined];
|
|
82
|
+
from = a;
|
|
83
|
+
to = b ?? (match[2] === undefined ? a : max);
|
|
84
|
+
}
|
|
85
|
+
if (step < 1 || from < min || to > max || from > to) return undefined;
|
|
86
|
+
for (let value = from; value <= to; value += step) out.add(value);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
@@ -21,4 +21,5 @@ export { PROCESS_LIMITS, validateProcess, hasErrors, type ProcessIssue, type Pro
|
|
|
21
21
|
export { DATA_PATH, isPath, readPath, resolveInput, type InstanceData } from "./paths";
|
|
22
22
|
export { evaluateCondition } from "./conditions";
|
|
23
23
|
export { durationMs } from "./duration";
|
|
24
|
+
export { parseCron, type CronSchedule } from "./cron";
|
|
24
25
|
export { checkFormSpec, formFieldCodes, validateForm, type FormField, type FormFieldType, type FormIssue, type FormSpec } from "./formFields";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseCron } from "./cron";
|
|
1
2
|
import { checkFormSpec } from "./formFields";
|
|
2
3
|
import { DATA_PATH } from "./paths";
|
|
3
4
|
import type { NodeKind } from "./ProcessDocument";
|
|
@@ -42,8 +43,7 @@ function first(...checks: (string | null)[]): string | null {
|
|
|
42
43
|
function cron(config: Record<string, unknown>): string | null {
|
|
43
44
|
const value = config.cron;
|
|
44
45
|
if (typeof value !== "string") return "config.cron is required";
|
|
45
|
-
|
|
46
|
-
return parts.length === 5 && parts.every((part) => /^[\d*/,\-]+$/.test(part)) ? null : "config.cron must have five fields";
|
|
46
|
+
return parseCron(value) === undefined ? "config.cron must be five valid fields: minute hour day month weekday (UTC)" : null;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** A timer: either `duration` (ISO 8601) or `at`, a path to a date in instance data. */
|
|
@@ -99,7 +99,7 @@ export const NODE_KINDS: readonly NodeKindDefinition[] = Object.freeze([
|
|
|
99
99
|
{ kind: "parallelGateway", role: "gateway", check: none },
|
|
100
100
|
{ kind: "boundaryError", role: "boundary", check: (c) => string(c, "errorCode", /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, false) },
|
|
101
101
|
{ kind: "boundaryTimer", role: "boundary", check: (c) => first(timer(c), boolean(c, "interrupting")) },
|
|
102
|
-
{ kind: "boundaryEvent", role: "boundary", check: (c) => first(string(c, "event", DOTTED_KEY), boolean(c, "interrupting")) },
|
|
102
|
+
{ kind: "boundaryEvent", role: "boundary", check: (c) => first(string(c, "event", DOTTED_KEY), string(c, "correlation", DATA_PATH, false), boolean(c, "interrupting")) },
|
|
103
103
|
{ kind: "end", role: "end", check: none },
|
|
104
104
|
] satisfies NodeKindDefinition[]);
|
|
105
105
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.127.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|