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
@@ -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,169 @@
1
+ import { UniqueViolationError } from "../../../orm";
2
+ import { isUuid, pagingOf, type WorkflowActor, type WorkflowPage } from "../../contracts/types";
3
+ import type { WorkflowClock } from "../../contracts/WorkflowClock";
4
+ import { WorkflowError } from "../../contracts/WorkflowError";
5
+ import type { WorkflowInstancesDeleted, WorkflowTaskRequested, WorkflowTokenClosed } from "../../runtime_module/events/Runtime.events";
6
+ import type { IWorkflowRuntime } from "../../runtime_module/services/IWorkflowRuntime.service";
7
+ import type { TasksDbContext } from "../model/Tasks.dbContext";
8
+ import { WfTask, type TaskStatus } from "../model/WfTask.model";
9
+ import { IWorkflowTasks, type TaskDetails, type TaskListQuery } from "./IWorkflowTasks.service";
10
+
11
+ const STATUSES = new Set<TaskStatus>(["open", "claimed", "completed", "cancelled"]);
12
+
13
+ export class WorkflowTasks extends IWorkflowTasks {
14
+ constructor(
15
+ private readonly db: TasksDbContext,
16
+ private readonly runtime: IWorkflowRuntime,
17
+ private readonly clock: WorkflowClock,
18
+ ) {
19
+ super();
20
+ }
21
+
22
+ async list(query: TaskListQuery): Promise<WorkflowPage<TaskDetails>> {
23
+ const { limit, offset } = pagingOf(query);
24
+ const statuses = [...(query.status ?? ["open", "claimed"])];
25
+ if (statuses.length === 0 || statuses.some((status) => !STATUSES.has(status))) throw new WorkflowError("invalid_input", "status must be open, claimed, completed or cancelled");
26
+ const assignees = [...query.assignees];
27
+ const mine = query.actor?.id;
28
+ if (assignees.length === 0 && mine === undefined) return { items: [], total: 0 };
29
+ let source = this.db.tasks.asNoTracking().where((t) => t.status.in(statuses));
30
+ source = mine === undefined
31
+ ? source.where((t) => t.assignee.in(assignees))
32
+ : assignees.length === 0
33
+ ? source.where((t) => t.claimedById.eq(mine))
34
+ : source.where((t) => t.assignee.in(assignees).or(t.claimedById.eq(mine)));
35
+ if (query.partition !== undefined) source = source.where((t) => t.partition.eq(query.partition!));
36
+ if (query.processKey !== undefined) source = source.where((t) => t.processKey.eq(query.processKey!));
37
+ const total = await source.count();
38
+ const rows = await source.orderBy((t) => t.id).skip(offset).take(limit).toList();
39
+ return { items: rows.map(detailsOf), total };
40
+ }
41
+
42
+ async get(taskId: string): Promise<TaskDetails> {
43
+ return detailsOf(await this.task(taskId, false));
44
+ }
45
+
46
+ async claim(taskId: string, actor: WorkflowActor): Promise<TaskDetails> {
47
+ return await this.db.transactionScope(async () => {
48
+ const task = await this.locked(taskId);
49
+ if (task.status === "claimed" && task.claimedById === actor.id) return detailsOf(task);
50
+ if (task.status === "claimed") throw new WorkflowError("conflict", "The task is taken by someone else");
51
+ if (task.status !== "open") throw new WorkflowError("wrong_status", `The task is ${task.status}`);
52
+ task.status = "claimed";
53
+ task.claimedById = actor.id;
54
+ task.claimedAt = this.clock.now();
55
+ await this.db.saveChanges();
56
+ return detailsOf(task);
57
+ });
58
+ }
59
+
60
+ async release(taskId: string, actor: WorkflowActor): Promise<TaskDetails> {
61
+ return await this.db.transactionScope(async () => {
62
+ const task = await this.locked(taskId);
63
+ if (task.status !== "claimed" || task.claimedById !== actor.id) throw new WorkflowError("wrong_status", "Only the person who took the task gives it back");
64
+ task.status = "open";
65
+ task.claimedById = null;
66
+ task.claimedAt = null;
67
+ await this.db.saveChanges();
68
+ return detailsOf(task);
69
+ });
70
+ }
71
+
72
+ async complete(taskId: string, output: unknown, actor: WorkflowActor): Promise<TaskDetails> {
73
+ const task = await this.task(taskId, false);
74
+ if (task.status === "claimed" && task.claimedById !== actor.id) throw new WorkflowError("conflict", "The task is taken by someone else");
75
+ if (task.status !== "open" && task.status !== "claimed") throw new WorkflowError("wrong_status", `The task is ${task.status}`);
76
+ let instance;
77
+ try {
78
+ // The form is checked and the process moves on first; the task follows.
79
+ instance = await this.runtime.completeToken(task.tokenId, output, actor);
80
+ } catch (error) {
81
+ if (error instanceof WorkflowError && error.code === "wrong_status") await this.close(task.tokenId, "cancelled");
82
+ throw error;
83
+ }
84
+ const saved = instance.nodes[task.nodeId];
85
+ await this.db.tasks.asNoTracking().where((t) => t.id.eq(task.id)).executeUpdate({
86
+ status: "completed",
87
+ completedById: actor.id,
88
+ completedAt: this.clock.now(),
89
+ output: typeof saved === "object" && saved !== null && !Array.isArray(saved) ? saved as Record<string, unknown> : null,
90
+ });
91
+ return await this.get(task.id);
92
+ }
93
+
94
+ /** A step started waiting for a person: its task. Repeated events of one token make one task. */
95
+ async requested(event: WorkflowTaskRequested): Promise<void> {
96
+ const assignee = "role" in event.assignee ? `role:${event.assignee.role}` : `user:${event.assignee.user}`;
97
+ this.db.tasks.add(Object.assign(new WfTask(), {
98
+ tokenId: event.tokenId,
99
+ instanceId: event.instanceId,
100
+ processKey: event.processKey,
101
+ nodeId: event.nodeId,
102
+ partition: event.partition,
103
+ title: event.title,
104
+ assignee,
105
+ form: event.form,
106
+ dueAt: event.dueAt,
107
+ }));
108
+ try {
109
+ await this.db.saveChanges();
110
+ } catch (error) {
111
+ if (!(error instanceof UniqueViolationError)) throw error;
112
+ }
113
+ }
114
+
115
+ /** The step closed otherwise than by its task: the task is cancelled. */
116
+ async closed(event: WorkflowTokenClosed): Promise<void> {
117
+ await this.close(event.tokenId, "cancelled");
118
+ }
119
+
120
+ /** Retention deleted the instances: their tasks go too. */
121
+ async deleted(event: WorkflowInstancesDeleted): Promise<void> {
122
+ if (event.instanceIds.length > 0) await this.db.tasks.asNoTracking().where((t) => t.instanceId.in([...event.instanceIds])).executeDelete();
123
+ }
124
+
125
+ private async close(tokenId: string, status: "cancelled"): Promise<void> {
126
+ await this.db.tasks.asNoTracking().where((t) => t.tokenId.eq(tokenId).and(t.status.in(["open", "claimed"]))).executeUpdate({ status });
127
+ }
128
+
129
+ private async task(taskId: string, tracked: boolean): Promise<WfTask> {
130
+ if (!isUuid(taskId)) throw notFound(taskId);
131
+ const source = tracked ? this.db.tasks : this.db.tasks.asNoTracking();
132
+ const task = await source.firstOrDefault((t) => t.id.eq(taskId));
133
+ if (!task) throw notFound(taskId);
134
+ return task;
135
+ }
136
+
137
+ private async locked(taskId: string): Promise<WfTask> {
138
+ if (!isUuid(taskId)) throw notFound(taskId);
139
+ const task = await this.db.tasks.where((t) => t.id.eq(taskId)).forUpdate().firstOrDefault();
140
+ if (!task) throw notFound(taskId);
141
+ return task;
142
+ }
143
+ }
144
+
145
+ function notFound(taskId: string): WorkflowError {
146
+ return new WorkflowError("not_found", `Task ${taskId} does not exist`);
147
+ }
148
+
149
+ function detailsOf(row: WfTask): TaskDetails {
150
+ return {
151
+ id: row.id,
152
+ instanceId: row.instanceId,
153
+ tokenId: row.tokenId,
154
+ processKey: row.processKey,
155
+ nodeId: row.nodeId,
156
+ partition: row.partition,
157
+ title: row.title,
158
+ assignee: row.assignee,
159
+ form: row.form,
160
+ dueAt: row.dueAt,
161
+ status: row.status,
162
+ claimedBy: row.claimedById,
163
+ claimedAt: row.claimedAt,
164
+ completedBy: row.completedById,
165
+ completedAt: row.completedAt,
166
+ output: row.output,
167
+ createdAt: row.createdAt,
168
+ };
169
+ }
@@ -0,0 +1,162 @@
1
+ import { DI } from "../../di";
2
+ import { startTestApp, type TestApp, type TestAppOptions } from "../../testing";
3
+ import { durationMs } from "../../../library/workflow";
4
+ import { WorkflowClock } from "../contracts/WorkflowClock";
5
+ import type { WorkflowActor } from "../contracts/types";
6
+ import { WorkflowJobs } from "../runtime_module/internal/WorkflowJobs";
7
+ import { IWorkflowRuntime, type EmitOptions, type EmitResult, type HistoryEntry, type InstanceDetails, type StartInstance } from "../runtime_module/services/IWorkflowRuntime.service";
8
+ import { TasksDbContext } from "../tasks_module/model/Tasks.dbContext";
9
+ import { IWorkflowTasks } from "../tasks_module/services/IWorkflowTasks.service";
10
+
11
+ /** Rounds of `drain` before it gives up: a process that keeps creating due jobs never settles. */
12
+ const MAX_ROUNDS = 1000;
13
+ const BATCH = 50;
14
+ const TEST_ACTOR: WorkflowActor = { kind: "system", id: "test" };
15
+
16
+ /** A clock that stands still until the test moves it (WORKFLOW-001 §14). */
17
+ export class ManualWorkflowClock extends WorkflowClock {
18
+ override readonly manual = true;
19
+ private current: number;
20
+
21
+ constructor(start: Date | string = new Date()) {
22
+ super();
23
+ this.current = toTime(start, "now");
24
+ }
25
+
26
+ now(): Date {
27
+ return new Date(this.current);
28
+ }
29
+
30
+ /** Moves the clock forward by an ISO duration (`PT2H`, `P1D`) or milliseconds. */
31
+ advance(by: string | number): Date {
32
+ const ms = typeof by === "number" ? by : durationMs(by);
33
+ if (ms === undefined || !Number.isFinite(ms) || ms < 0) throw new Error(`advance takes an ISO duration without months and years or milliseconds ≥ 0, not ${JSON.stringify(by)}`);
34
+ this.current += ms;
35
+ return this.now();
36
+ }
37
+
38
+ /** Moves the clock forward to a moment. */
39
+ set(to: Date | string): Date {
40
+ const time = toTime(to, "set");
41
+ if (time < this.current) throw new Error(`The workflow clock only moves forward: it is ${this.now().toISOString()}`);
42
+ this.current = time;
43
+ return this.now();
44
+ }
45
+ }
46
+
47
+ export interface TestWorkflowOptions extends TestAppOptions {
48
+ /** Where the workflow clock starts; the current time by default. */
49
+ readonly now?: Date | string;
50
+ /** On whose behalf `start`, `emit` and `complete` act; `{ kind: "system", id: "test" }` by default. */
51
+ readonly actor?: WorkflowActor;
52
+ }
53
+
54
+ /** A started application whose processes run only when the test says so. */
55
+ export interface TestWorkflow {
56
+ readonly app: TestApp;
57
+ readonly clock: ManualWorkflowClock;
58
+ now(): Date;
59
+ /** Starts the latest version of a process from its manual start; jobs wait for {@link drain}. */
60
+ start(processKey: string, options?: Partial<StartInstance>): Promise<InstanceDetails>;
61
+ emit(eventKey: string, payload?: unknown, options?: EmitOptions): Promise<EmitResult>;
62
+ /** Completes the user task waiting at `nodeId` (through its task when the Tasks part is there). */
63
+ complete(instanceId: string, nodeId: string, output?: unknown, actor?: WorkflowActor): Promise<InstanceDetails>;
64
+ /** Plans scheduled starts and runs every job due by the workflow clock until none is left; returns how many ran. */
65
+ drain(): Promise<number>;
66
+ /** Moves the clock forward (ISO duration or milliseconds), then {@link drain}. */
67
+ advance(by: string | number): Promise<number>;
68
+ instance(instanceId: string): Promise<InstanceDetails>;
69
+ history(instanceId: string): Promise<readonly HistoryEntry[]>;
70
+ stop(): Promise<void>;
71
+ }
72
+
73
+ /**
74
+ * Starts the application like `startTestApp`, with a {@link ManualWorkflowClock}
75
+ * and no background worker: jobs run only in `drain`, so a test of a process is
76
+ * deterministic. Needs PostgreSQL, as the workflow module does.
77
+ *
78
+ * ```ts
79
+ * const wf = await testWorkflow(AppModule, { now: "2026-11-02T09:00:00Z" });
80
+ * try {
81
+ * const order = await wf.start("order-approval", { input: { amount: 5000 } });
82
+ * await wf.drain();
83
+ * await wf.complete(order.id, "approve", { approved: true });
84
+ * await wf.advance("PT2H");
85
+ * expect((await wf.instance(order.id)).status).toBe("completed");
86
+ * } finally {
87
+ * await wf.stop();
88
+ * }
89
+ * ```
90
+ */
91
+ export async function testWorkflow(root: Parameters<typeof startTestApp>[0], options: TestWorkflowOptions = {}): Promise<TestWorkflow> {
92
+ const { now, actor = TEST_ACTOR, overrides = [], ...appOptions } = options;
93
+ const clock = new ManualWorkflowClock(now);
94
+ const app = await startTestApp(root, { ...appOptions, overrides: [...overrides, DI.singleton(DI.valueProvider(WorkflowClock, clock))] });
95
+ const failures: unknown[] = [];
96
+ const jobs = new WorkflowJobs(app.container, (message, error, fields) => failures.push({ message, error, ...fields }));
97
+ const signal = new AbortController().signal;
98
+
99
+ const inScope = async <T>(work: (scope: ReturnType<TestApp["container"]["createScope"]>) => Promise<T>): Promise<T> => {
100
+ const scope = app.container.createScope();
101
+ try {
102
+ return await work(scope);
103
+ } finally {
104
+ await scope.dispose();
105
+ }
106
+ };
107
+
108
+ const drain = async (): Promise<number> => {
109
+ await jobs.reconcile();
110
+ let ran = 0;
111
+ for (let round = 0; round < MAX_ROUNDS; round += 1) {
112
+ const due = await jobs.claim(BATCH);
113
+ if (due.length === 0) return ran;
114
+ for (const job of due) await jobs.run(job, signal);
115
+ ran += due.length;
116
+ // A failure outside a Tool (a failing Tool is a step result) is a bug the test should see.
117
+ if (failures.length > 0) {
118
+ const found = failures.splice(0);
119
+ throw new AggregateError(found.map((failure) => (failure as { error: unknown }).error), `drain: ${found.length} job(s) failed outside their Tools: ${found.map((failure) => { const { message, error } = failure as { message: string; error: unknown }; return `${message}: ${error instanceof Error ? error.message : String(error)}`; }).join("; ")}`);
120
+ }
121
+ }
122
+ throw new Error(`drain did not settle after ${MAX_ROUNDS} rounds: a process keeps creating due jobs`);
123
+ };
124
+
125
+ return {
126
+ app,
127
+ clock,
128
+ now: () => clock.now(),
129
+ start: (processKey, start = {}) => inScope((scope) => scope.resolve(IWorkflowRuntime).start(processKey, { ...start, actor: start.actor ?? actor })),
130
+ emit: (eventKey, payload, emitOptions = {}) => inScope((scope) => scope.resolve(IWorkflowRuntime).emit(eventKey, payload, { ...emitOptions, actor: emitOptions.actor ?? actor })),
131
+ complete: (instanceId, nodeId, output = {}, by = actor) => inScope(async (scope) => {
132
+ const runtime = scope.resolve(IWorkflowRuntime);
133
+ const instance = await runtime.instance(instanceId);
134
+ const token = instance.tokens.find((t) => t.nodeId === nodeId && t.status === "waiting");
135
+ if (!token) {
136
+ const waiting = instance.tokens.filter((t) => t.status === "waiting").map((t) => t.nodeId);
137
+ throw new Error(`No step waits at ${nodeId} in instance ${instanceId} (${instance.status}); waiting: ${waiting.length === 0 ? "none" : waiting.join(", ")}`);
138
+ }
139
+ const tasks = scope.tryResolve(IWorkflowTasks);
140
+ const task = tasks === undefined ? undefined : await scope.resolve(TasksDbContext).tasks.asNoTracking().firstOrDefault((t) => t.tokenId.eq(token.id).and(t.status.in(["open", "claimed"])));
141
+ if (tasks !== undefined && task) {
142
+ await tasks.complete(task.id, output, by);
143
+ return await runtime.instance(instanceId);
144
+ }
145
+ return await runtime.completeToken(token.id, output, by);
146
+ }),
147
+ drain,
148
+ advance: async (by) => {
149
+ clock.advance(by);
150
+ return await drain();
151
+ },
152
+ instance: (instanceId) => inScope((scope) => scope.resolve(IWorkflowRuntime).instance(instanceId)),
153
+ history: (instanceId) => inScope((scope) => scope.resolve(IWorkflowRuntime).history(instanceId)),
154
+ stop: () => app.stop(),
155
+ };
156
+ }
157
+
158
+ function toTime(value: Date | string, what: string): number {
159
+ const time = value instanceof Date ? value.getTime() : Date.parse(value);
160
+ if (!Number.isFinite(time)) throw new Error(`The workflow clock ${what} must be a date, not ${JSON.stringify(value)}`);
161
+ return time;
162
+ }
@@ -32,6 +32,8 @@ const NOUNS_EN = {
32
32
  change: ["change", "changes", "changes"],
33
33
  part: ["part", "parts", "parts"],
34
34
  context: ["database context", "database contexts", "database contexts"],
35
+ process: ["process", "processes", "processes"],
36
+ node: ["node", "nodes", "nodes"],
35
37
  } as const satisfies Record<string, Forms>;
36
38
 
37
39
  export type Noun = keyof typeof NOUNS_EN;
@@ -60,6 +62,8 @@ const NOUNS_RU: Record<Noun, Forms> = {
60
62
  change: ["изменение", "изменения", "изменений"],
61
63
  part: ["часть", "части", "частей"],
62
64
  context: ["контекст базы данных", "контекста базы данных", "контекстов базы данных"],
65
+ process: ["процесс", "процесса", "процессов"],
66
+ node: ["узел", "узла", "узлов"],
63
67
  };
64
68
 
65
69
  /** Genitive after «из N»: «из 1 подключения», «из 3 подключений», «из 21 подключения». */
@@ -239,6 +243,18 @@ const en = {
239
243
  needsCache: "this module caches: pass runApp(AppModule, { cache: memory() })",
240
244
  keptOnFailure: "scaffold files were kept — fix the error and run bazis codegen",
241
245
  },
246
+ workflow: {
247
+ app: "app",
248
+ section: "PROCESSES IN CODE",
249
+ none: "no module declares processes — add one with bazis g process <Name> --module <Name>",
250
+ passed: (count: number) => `${count} ${formEn(count, NOUNS_EN.process)} checked`,
251
+ failed: (count: number) => `process check failed · ${count} ${formEn(count, NOUNS_EN.error)}`,
252
+ fix: "fix the documents or the Tools: the application refuses to start with these errors",
253
+ processKind: "process",
254
+ processCreated: (name: string) => `process ${name} is generated`,
255
+ processPlanned: (name: string) => `process ${name} is planned`,
256
+ processRegistered: (host: string) => `listed in processes of ${host}`,
257
+ },
242
258
  errors: {
243
259
  unknown: "Use bazis --help to list commands and options.",
244
260
  bunTooOld: (required: string, actual: string) => `bazis needs Bun ${required.startsWith(">=") ? `${required.slice(2).trim()} or newer` : required}; this is Bun ${actual}.`,
@@ -404,6 +420,18 @@ const ru: Messages = {
404
420
  needsCache: "модуль кэширует: передайте runApp(AppModule, { cache: memory() })",
405
421
  keptOnFailure: "файлы модуля оставлены — исправьте ошибку и запустите bazis codegen",
406
422
  },
423
+ workflow: {
424
+ app: "приложение",
425
+ section: "ПРОЦЕССЫ В КОДЕ",
426
+ none: "ни один модуль не объявляет процессы — добавьте процесс: bazis g process <Name> --module <Name>",
427
+ passed: (count) => `процессы в порядке · ${count} ${formRu(count, NOUNS_RU.process)}`,
428
+ failed: (count) => `проверка процессов не прошла · ${count} ${formRu(count, NOUNS_RU.error)}`,
429
+ fix: "исправьте документы или Tools: с этими ошибками приложение не запустится",
430
+ processKind: "процесс",
431
+ processCreated: (name) => `процесс ${name} создан`,
432
+ processPlanned: (name) => `процесс ${name} запланирован`,
433
+ processRegistered: (host) => `добавлен в processes модуля ${host}`,
434
+ },
407
435
  errors: {
408
436
  unknown: "Список команд и опций: bazis --help.",
409
437
  bunTooOld: (required, actual) => `bazis нужен Bun ${required.startsWith(">=") ? `${required.slice(2).trim()} или новее` : required}, а запущен Bun ${actual}.`,
@@ -58,6 +58,7 @@ export const MODE = Object.freeze({
58
58
  new: ["#ff2ee6", "#7b61ff"] as readonly string[],
59
59
  gen: ["#c77dff", "#ff2ee6"] as readonly string[],
60
60
  codegen: ["#00f0ff", "#c77dff"] as readonly string[],
61
+ workflow: ["#3dff7a", "#ffd60a"] as readonly string[],
61
62
  });
62
63
 
63
64
  /**
@@ -22,4 +22,5 @@ export { DATA_PATH, isPath, readPath, resolveInput, type InstanceData } from "./
22
22
  export { evaluateCondition } from "./conditions";
23
23
  export { durationMs } from "./duration";
24
24
  export { parseCron, type CronSchedule } from "./cron";
25
+ export { processToMermaid } from "./mermaid";
25
26
  export { checkFormSpec, formFieldCodes, validateForm, type FormField, type FormFieldType, type FormIssue, type FormSpec } from "./formFields";
@@ -0,0 +1,87 @@
1
+ import type { Condition, NodeKind, ProcessDocument } from "./ProcessDocument";
2
+
3
+ /** Words Mermaid reads as syntax: such node ids are renamed. */
4
+ const RESERVED = new Set(["end", "graph", "flowchart", "subgraph", "style", "class", "classdef", "click", "default", "linkstyle", "direction"]);
5
+
6
+ const SHAPES: Record<NodeKind, readonly [open: string, close: string]> = {
7
+ manualStart: ["([", "])"],
8
+ scheduleStart: ["([", "])"],
9
+ eventStart: ["([", "])"],
10
+ end: ["((", "))"],
11
+ userTask: ["[", "]"],
12
+ serviceTask: ["[[", "]]"],
13
+ timerCatch: ["{{", "}}"],
14
+ eventWait: ["{{", "}}"],
15
+ exclusiveGateway: ["{", "}"],
16
+ parallelGateway: ["{", "}"],
17
+ boundaryError: ["((", "))"],
18
+ boundaryTimer: ["((", "))"],
19
+ boundaryEvent: ["((", "))"],
20
+ };
21
+
22
+ /**
23
+ * A process as a Mermaid `flowchart` (WORKFLOW-001 §14): starts are stadiums,
24
+ * Tool steps subroutines, tasks rectangles, waits hexagons, gateways rhombi,
25
+ * ends and boundary events circles. A boundary event hangs on its step by a
26
+ * dotted link. Labels show the node kind's detail: the Tool, the cron, the
27
+ * duration, the event key; flows show their title or condition.
28
+ */
29
+ export function processToMermaid(document: ProcessDocument, direction: "LR" | "TB" = "LR"): string {
30
+ const ids = new Map<string, string>();
31
+ const used = new Set<string>();
32
+ const idOf = (nodeId: string): string => {
33
+ const known = ids.get(nodeId);
34
+ if (known !== undefined) return known;
35
+ let id = /^[A-Za-z][A-Za-z0-9_]*$/.test(nodeId) && !RESERVED.has(nodeId.toLowerCase()) ? nodeId : `n_${nodeId.replace(/[^A-Za-z0-9_]/g, "_")}`;
36
+ while (used.has(id)) id = `${id}_`;
37
+ used.add(id);
38
+ ids.set(nodeId, id);
39
+ return id;
40
+ };
41
+ const lines = [`flowchart ${direction}`];
42
+ for (const [nodeId, node] of Object.entries(document.nodes)) {
43
+ const [open, close] = SHAPES[node.kind] ?? ["[", "]"];
44
+ const detail = detailOf(node.kind, node.config ?? {});
45
+ lines.push(` ${idOf(nodeId)}${open}"${label(node.title || nodeId)}${detail === undefined ? "" : `<br/><small>${label(detail)}</small>`}"${close}`);
46
+ }
47
+ for (const [nodeId, node] of Object.entries(document.nodes)) {
48
+ if (node.attachedTo !== undefined && document.nodes[node.attachedTo] !== undefined) lines.push(` ${idOf(node.attachedTo)} -.- ${idOf(nodeId)}`);
49
+ }
50
+ for (const flow of Object.values(document.flows)) {
51
+ if (document.nodes[flow.source] === undefined || document.nodes[flow.target] === undefined) continue;
52
+ const text = flow.title ?? (flow.condition !== undefined ? conditionText(flow.condition) : flow.isDefault === true ? "default" : undefined);
53
+ lines.push(` ${idOf(flow.source)} -->${text === undefined ? "" : `|"${label(text)}"|`} ${idOf(flow.target)}`);
54
+ }
55
+ return `${lines.join("\n")}\n`;
56
+ }
57
+
58
+ function detailOf(kind: NodeKind, config: Readonly<Record<string, unknown>>): string | undefined {
59
+ const text = (value: unknown) => (typeof value === "string" && value !== "" ? value : undefined);
60
+ switch (kind) {
61
+ case "serviceTask": return text(config.tool);
62
+ case "scheduleStart": return text(config.cron);
63
+ case "eventStart":
64
+ case "eventWait":
65
+ case "boundaryEvent": return text(config.event);
66
+ case "timerCatch":
67
+ case "boundaryTimer": return text(config.duration) ?? (text(config.at) === undefined ? undefined : `at ${config.at}`);
68
+ case "boundaryError": return text(config.errorCode);
69
+ case "userTask": {
70
+ const assignee = config.assignee as { role?: unknown; user?: unknown } | undefined;
71
+ return assignee === undefined ? undefined : text(assignee.role) !== undefined ? `role ${assignee.role}` : text(assignee.user) === undefined ? undefined : `user ${assignee.user}`;
72
+ }
73
+ default: return undefined;
74
+ }
75
+ }
76
+
77
+ function conditionText(condition: Condition): string {
78
+ if ("all" in condition) return condition.all.map(conditionText).join(" and ");
79
+ if ("any" in condition) return condition.any.map(conditionText).join(" or ");
80
+ if ("not" in condition) return `not (${conditionText(condition.not)})`;
81
+ return condition.op === "empty" || condition.op === "notEmpty" ? `${condition.path} ${condition.op}` : `${condition.path} ${condition.op} ${JSON.stringify(condition.value)}`;
82
+ }
83
+
84
+ /** Text inside a quoted Mermaid label: quotes and markup are entities. */
85
+ function label(value: string): string {
86
+ return value.replace(/&/g, "&amp;").replace(/"/g, "#quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.126.0",
3
+ "version": "0.128.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",
@@ -44,6 +44,7 @@
44
44
  "./core/testing": "./core/testing/index.ts",
45
45
  "./core/websocket": "./core/websocket/index.ts",
46
46
  "./core/workflow": "./core/workflow/index.ts",
47
+ "./core/workflow/testing": "./core/workflow/testing/index.ts",
47
48
  "./library/boundary": "./library/boundary/index.ts",
48
49
  "./library/http-client": "./library/http-client/index.ts",
49
50
  "./library/jsonapi": "./library/jsonapi/index.ts",