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.
- package/cli/generateModule.ts +5 -5
- package/cli/generateProcess.ts +56 -0
- package/cli/main.ts +35 -1
- package/cli/moduleRegistration.ts +23 -10
- package/cli/parseCli.ts +45 -3
- package/cli/templates/process.ts +28 -0
- package/cli/workflow.ts +66 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/Workflow.module.ts +4 -2
- package/core/workflow/contracts/WorkflowClock.ts +21 -0
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +14 -6
- package/core/workflow/definitions_module/code/CodeProcessCatalog.ts +19 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +44 -0
- package/core/workflow/definitions_module/code/codeProcesses.ts +85 -0
- package/core/workflow/definitions_module/code/defineProcess.ts +40 -0
- package/core/workflow/definitions_module/code/inspectProcesses.ts +74 -0
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +11 -2
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +106 -45
- package/core/workflow/index.ts +21 -0
- package/core/workflow/internal/http.ts +6 -0
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +2 -0
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +9 -3
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +11 -88
- package/core/workflow/runtime_module/events/Runtime.events.ts +6 -0
- package/core/workflow/runtime_module/internal/Schedules.ts +4 -2
- package/core/workflow/runtime_module/internal/ToolStep.ts +3 -3
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +18 -11
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +100 -0
- package/core/workflow/tasks_module/WorkflowTasks.module.ts +31 -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 +169 -0
- package/core/workflow/testing/index.ts +162 -0
- package/library/terminal/messages.ts +28 -0
- package/library/terminal/screen.ts +1 -0
- package/library/workflow/index.ts +1 -0
- package/library/workflow/mermaid.ts +87 -0
- package/package.json +2 -1
package/cli/generateModule.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface GenerateModuleResult {
|
|
|
39
39
|
readonly warnings: readonly string[];
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
interface PlannedWrite {
|
|
42
|
+
export interface PlannedWrite {
|
|
43
43
|
readonly absolute: string;
|
|
44
44
|
readonly content: string;
|
|
45
45
|
readonly original: string | undefined;
|
|
@@ -82,7 +82,7 @@ export async function generateModulePack(options: GenerateModulePackOptions): Pr
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** The existing composite module `name` under `modulesRoot`: `Catalog` → `catalog_modules/Catalog.module.ts`. */
|
|
85
|
-
async function findPack(modulesRoot: string, name: string): Promise<{ readonly dir: string; readonly entry: string; readonly moduleClass: string }> {
|
|
85
|
+
export async function findPack(modulesRoot: string, name: string): Promise<{ readonly dir: string; readonly entry: string; readonly moduleClass: string }> {
|
|
86
86
|
const naming = parseModuleName(name);
|
|
87
87
|
const dir = path.join(modulesRoot, `${naming.folder}_modules`);
|
|
88
88
|
const entry = path.join(dir, `${naming.module}.module.ts`);
|
|
@@ -150,7 +150,7 @@ async function generateFiles(options: GenerateModuleOptions, naming: ModuleNamin
|
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
async function fileInfo(absolute: string) {
|
|
153
|
+
export async function fileInfo(absolute: string) {
|
|
154
154
|
try {
|
|
155
155
|
return await lstat(absolute);
|
|
156
156
|
} catch (error) {
|
|
@@ -161,7 +161,7 @@ async function fileInfo(absolute: string) {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/** Relative imports must survive runtime realpath resolution (e.g. /var -> /private/var). */
|
|
164
|
-
async function canonicalDirectory(directory: string): Promise<string> {
|
|
164
|
+
export async function canonicalDirectory(directory: string): Promise<string> {
|
|
165
165
|
try {
|
|
166
166
|
return await realpath(directory);
|
|
167
167
|
} catch (error) {
|
|
@@ -172,7 +172,7 @@ async function canonicalDirectory(directory: string): Promise<string> {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
async function commitWrites(writes: readonly PlannedWrite[]): Promise<void> {
|
|
175
|
+
export async function commitWrites(writes: readonly PlannedWrite[]): Promise<void> {
|
|
176
176
|
const completed: PlannedWrite[] = [];
|
|
177
177
|
const createdDirectories = new Set<string>();
|
|
178
178
|
try {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { canonicalDirectory, commitWrites, fileInfo, findPack, type GenerateModuleResult, type PlannedWrite } from "./generateModule";
|
|
4
|
+
import { registerInModuleField } from "./moduleRegistration";
|
|
5
|
+
import { parseModuleName } from "./naming";
|
|
6
|
+
import { packPartFolder } from "./templates/pack";
|
|
7
|
+
import { buildProcessTemplate } from "./templates/process";
|
|
8
|
+
|
|
9
|
+
export interface GenerateProcessOptions {
|
|
10
|
+
/** OrderApproval, order-approval. */
|
|
11
|
+
readonly name: string;
|
|
12
|
+
/** The module that owns the process: `Orders` → `{modulesRoot}/orders/Orders.module.ts`. */
|
|
13
|
+
readonly module: string;
|
|
14
|
+
/** The composite module the owner is a part of. */
|
|
15
|
+
readonly pack?: string;
|
|
16
|
+
/** Default: src/app/modules, relative to cwd. */
|
|
17
|
+
readonly modulesRoot?: string;
|
|
18
|
+
readonly force?: boolean;
|
|
19
|
+
readonly dryRun?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates `processes/<Name>.process.ts` in a module and lists the process in
|
|
24
|
+
* the module's `@Module({ processes })` (WORKFLOW-001 §14).
|
|
25
|
+
*/
|
|
26
|
+
export async function generateProcess(options: GenerateProcessOptions): Promise<GenerateModuleResult> {
|
|
27
|
+
const naming = parseModuleName(options.name);
|
|
28
|
+
const owner = parseModuleName(options.module);
|
|
29
|
+
const modulesRoot = await canonicalDirectory(path.resolve(options.modulesRoot ?? "src/app/modules"));
|
|
30
|
+
const pack = options.pack === undefined ? undefined : await findPack(modulesRoot, options.pack);
|
|
31
|
+
const moduleDir = pack ? path.join(pack.dir, packPartFolder(owner)) : path.join(modulesRoot, owner.folder);
|
|
32
|
+
const moduleFile = path.join(moduleDir, `${owner.module}.module.ts`);
|
|
33
|
+
const moduleInfo = await fileInfo(moduleFile);
|
|
34
|
+
if (moduleInfo?.isFile() !== true) {
|
|
35
|
+
throw new Error(`Module ${owner.moduleClass} not found: ${path.relative(process.cwd(), moduleFile)}.${pack ? "" : " For a part of a composite module add --pack <Name>."}`);
|
|
36
|
+
}
|
|
37
|
+
const processFile = path.join(moduleDir, "processes", `${naming.module}.process.ts`);
|
|
38
|
+
const existing = await fileInfo(processFile);
|
|
39
|
+
if (existing && !options.force) throw new Error(`File already exists: ${processFile}. Use --force to overwrite.`);
|
|
40
|
+
if (existing && !existing.isFile()) throw new Error(`Refusing to overwrite a non-regular file: ${processFile}`);
|
|
41
|
+
const moduleSource = await readFile(moduleFile, "utf8");
|
|
42
|
+
const writes: PlannedWrite[] = [
|
|
43
|
+
{ absolute: processFile, content: buildProcessTemplate(naming, owner.moduleClass), original: existing ? await readFile(processFile, "utf8") : undefined },
|
|
44
|
+
{ absolute: moduleFile, content: registerInModuleField(moduleSource, moduleFile, processFile, `${naming.module}Process`, { field: "processes", hostClass: owner.moduleClass }), original: moduleSource },
|
|
45
|
+
];
|
|
46
|
+
const changed = writes.filter((write) => write.content !== write.original);
|
|
47
|
+
if (!options.dryRun) await commitWrites(changed);
|
|
48
|
+
return {
|
|
49
|
+
moduleDir,
|
|
50
|
+
files: [path.relative(process.cwd(), processFile)],
|
|
51
|
+
registered: true,
|
|
52
|
+
dryRun: options.dryRun === true,
|
|
53
|
+
warnings: [],
|
|
54
|
+
changes: changed.map((write) => ({ path: path.relative(process.cwd(), write.absolute), action: write.original === undefined ? "create" : "update" })),
|
|
55
|
+
};
|
|
56
|
+
}
|
package/cli/main.ts
CHANGED
|
@@ -6,12 +6,18 @@ import { parseCliArgs, type ParsedGenerateArgs } from "./parseCli";
|
|
|
6
6
|
import { runCodegen, type CodegenOptions } from "./codegen";
|
|
7
7
|
import { generateProject } from "./generateProject";
|
|
8
8
|
import { runBuild, runDev, runTest } from "./build";
|
|
9
|
+
import type { ParsedProcessArgs } from "./parseCli";
|
|
9
10
|
import { bazisVersion, commandFailed, commandHeader, createScreens, lineOutput, requiredBun, type CliOutput } from "./ui";
|
|
10
11
|
|
|
11
12
|
export const USAGE = `Usage:
|
|
12
13
|
bazis new <Name> [options] Create a new application project
|
|
13
14
|
bazis g module <Name> [options] Atomic module (alias: m)
|
|
14
15
|
bazis g pack <Name> --parts <a,b> [options] Composite module (aliases: p, module-pack)
|
|
16
|
+
bazis g process <Name> --module <Name> Process in code: processes/<Name>.process.ts in the module,
|
|
17
|
+
listed in its @Module processes (alias: pr; --pack <Name>
|
|
18
|
+
for a part of a composite module)
|
|
19
|
+
bazis workflow check [--app-module <path>] Check the processes of the modules against their Tools
|
|
20
|
+
bazis workflow diagram <key> Print a process as a Mermaid flowchart
|
|
15
21
|
bazis codegen [--target <name|all>] Generate DI wiring into src/generated/bazis
|
|
16
22
|
bazis dev [--watch] Codegen, then run the app from source with
|
|
17
23
|
BAZIS_ENV=development (unless set);
|
|
@@ -58,6 +64,9 @@ Examples (from the project root):
|
|
|
58
64
|
bunx bazis g module Mailer --empty --no-register
|
|
59
65
|
bunx bazis g pack DataManager --parts tables,fields,validators,records
|
|
60
66
|
bunx bazis g module Prices --empty --pack Catalog
|
|
67
|
+
bunx bazis g process OrderApproval --module Orders
|
|
68
|
+
bunx bazis workflow check
|
|
69
|
+
bunx bazis workflow diagram order-approval > order-approval.mmd
|
|
61
70
|
bunx bazis codegen --target production
|
|
62
71
|
bunx bazis dev --watch
|
|
63
72
|
bunx bazis test
|
|
@@ -134,15 +143,40 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = defa
|
|
|
134
143
|
if (parsed.kind === "test") return await runTest(cwd, parsed.args, codegen, ui);
|
|
135
144
|
if (parsed.kind === "build") return await runBuild(cwd, { bin: parsed.bin, outfile: parsed.outfile, dotenv: parsed.dotenv, platform: parsed.platform }, codegen, ui);
|
|
136
145
|
if (parsed.kind === "new") return await runNew(parsed, ui);
|
|
146
|
+
if (parsed.kind === "workflow") {
|
|
147
|
+
const { runWorkflowCheck, runWorkflowDiagram } = await import("./workflow");
|
|
148
|
+
return parsed.action === "check" ? await runWorkflowCheck(cwd, parsed.appModule, ui) : await runWorkflowDiagram(cwd, parsed.key, parsed.appModule, runtime.log);
|
|
149
|
+
}
|
|
137
150
|
// Module registration parses the host module with the TypeScript API: load it
|
|
138
151
|
// only after switching TypeScript 7 to @typescript/typescript6.
|
|
139
152
|
useTypeScriptCompilerApi(cwd);
|
|
153
|
+
if (parsed.kind === "process") return await runGenerateProcess(cwd, parsed.args, ui);
|
|
140
154
|
return await runGenerate(cwd, parsed.args, codegen, ui);
|
|
141
155
|
} catch (error) {
|
|
142
|
-
return commandFailed(ui.err, ui.err.t.failure.command, [{ tag: "CLI", where: parsed.kind === "generate" ? `g ${parsed.args.generator}` : parsed.kind, cause: error instanceof Error ? error.message : String(error) }], 1);
|
|
156
|
+
return commandFailed(ui.err, ui.err.t.failure.command, [{ tag: "CLI", where: parsed.kind === "generate" ? `g ${parsed.args.generator}` : parsed.kind === "process" ? "g process" : parsed.kind === "workflow" ? `workflow ${parsed.action}` : parsed.kind, cause: error instanceof Error ? error.message : String(error) }], 1);
|
|
143
157
|
}
|
|
144
158
|
}
|
|
145
159
|
|
|
160
|
+
async function runGenerateProcess(cwd: string, args: ParsedProcessArgs, ui: CliOutput): Promise<number> {
|
|
161
|
+
const s = ui.out;
|
|
162
|
+
const t = s.t;
|
|
163
|
+
const { generateProcess } = await import("./generateProcess");
|
|
164
|
+
commandHeader(s, "gen", cwd, [
|
|
165
|
+
s.kv(t.workflow.processKind, s.strong(args.name)),
|
|
166
|
+
s.kv(t.noun("module", 1), s.text(args.pack === undefined ? args.module : `${args.pack} / ${args.module}`)),
|
|
167
|
+
...(args.dryRun ? [s.fg(PALETTE.yellow, t.common.dryRun)] : []),
|
|
168
|
+
]);
|
|
169
|
+
const result = await generateProcess(args);
|
|
170
|
+
s.section(t.section.files, s.muted(path.relative(cwd, result.moduleDir) || result.moduleDir));
|
|
171
|
+
s.step("ok", t.step.files, s.num(result.changes.length, t.noun("change", result.changes.length)));
|
|
172
|
+
s.tree(result.changes.map((change) => ({ text: `${change.action === "create" ? s.strong("+", PALETTE.lime) : s.strong("~", PALETTE.yellow)} ${s.text(change.path)}` })), s.style.color ? 9 : 4);
|
|
173
|
+
const host = result.changes.find((change) => change.action === "update")?.path;
|
|
174
|
+
if (host !== undefined) s.line(` ${s.strong("↗", PALETTE.export)} ${s.text(t.workflow.processRegistered(host))}`);
|
|
175
|
+
s.line();
|
|
176
|
+
s.banner("success", result.dryRun ? t.workflow.processPlanned(args.name) : t.workflow.processCreated(args.name));
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
146
180
|
async function runCodegenCommand(cwd: string, target: string | undefined, codegen: CliRuntime["codegen"], ui: CliOutput): Promise<number> {
|
|
147
181
|
const s = ui.out;
|
|
148
182
|
const t = s.t;
|
|
@@ -10,9 +10,19 @@ export function moduleImportPath(fromFile: string, moduleFile: string): string {
|
|
|
10
10
|
|
|
11
11
|
/** Edits only the import and the selected @Module metadata, preserving other source text. */
|
|
12
12
|
export function registerModuleInSource(content: string, appFile: string, moduleFile: string, moduleClass: string): string {
|
|
13
|
+
return registerInModuleField(content, appFile, moduleFile, moduleClass, { field: "imports", hostClass: "AppModule" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Adds `moduleClass` (an export of `moduleFile`) to the array `field` of the
|
|
18
|
+
* `@Module` in `appFile` and imports it: `imports` for a module, `processes`
|
|
19
|
+
* for a process. The class named `hostClass` is chosen, or the only one.
|
|
20
|
+
*/
|
|
21
|
+
export function registerInModuleField(content: string, appFile: string, moduleFile: string, moduleClass: string, options: { readonly field: string; readonly hostClass?: string }): string {
|
|
22
|
+
const { field } = options;
|
|
13
23
|
const diagnostics = ts.transpileModule(content, { fileName: appFile, reportDiagnostics: true }).diagnostics ?? [];
|
|
14
24
|
if (diagnostics.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)) {
|
|
15
|
-
throw new Error(`Cannot register
|
|
25
|
+
throw new Error(`Cannot register in invalid TypeScript: ${appFile}`);
|
|
16
26
|
}
|
|
17
27
|
const source = ts.createSourceFile(appFile, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
18
28
|
const importPath = moduleImportPath(appFile, moduleFile);
|
|
@@ -75,26 +85,26 @@ export function registerModuleInSource(content: string, appFile: string, moduleF
|
|
|
75
85
|
candidates.push({ name: statement.name?.text, metadata });
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
|
-
const candidate = candidates.find((item) => item.name ===
|
|
79
|
-
if (!candidate) throw new Error(
|
|
88
|
+
const candidate = candidates.find((item) => item.name === options.hostClass) ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
89
|
+
if (!candidate) throw new Error(`Could not select a unique @Module class for registration in ${path.basename(appFile)}.`);
|
|
80
90
|
const metadata = candidate.metadata;
|
|
81
91
|
if (metadata.properties.some(ts.isSpreadAssignment)) throw new Error("Automatic registration does not support spread @Module metadata; register the module explicitly.");
|
|
82
92
|
if (metadata.properties.some((property) => property.name && ts.isComputedPropertyName(property.name))) throw new Error("Automatic registration does not support computed @Module properties; register the module explicitly.");
|
|
83
|
-
const importProperties = metadata.properties.filter((property) => property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && property.name.text ===
|
|
84
|
-
if (importProperties.length > 1) throw new Error(
|
|
93
|
+
const importProperties = metadata.properties.filter((property) => property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && property.name.text === field);
|
|
94
|
+
if (importProperties.length > 1) throw new Error(`Duplicate ${field} properties in @Module metadata.`);
|
|
85
95
|
const property = importProperties[0];
|
|
86
96
|
const edits: Edit[] = [];
|
|
87
97
|
if (property) {
|
|
88
98
|
if (!ts.isPropertyAssignment(property) || !ts.isArrayLiteralExpression(property.initializer)) {
|
|
89
|
-
throw new Error(
|
|
99
|
+
throw new Error(`@Module ${field} must be an array literal for automatic registration.`);
|
|
90
100
|
}
|
|
91
101
|
const array = property.initializer;
|
|
92
|
-
if (array.elements.some(ts.isOmittedExpression)) throw new Error(
|
|
102
|
+
if (array.elements.some(ts.isOmittedExpression)) throw new Error(`@Module ${field} contains an empty element; fix the array before registration.`);
|
|
93
103
|
if (!array.elements.some((element) => ts.isIdentifier(element) && element.text === binding)) {
|
|
94
104
|
appendMember(content, array, array.elements, binding, edits, eol);
|
|
95
105
|
}
|
|
96
106
|
} else {
|
|
97
|
-
appendMember(content, metadata, metadata.properties,
|
|
107
|
+
appendMember(content, metadata, metadata.properties, `${field}: [${binding}]`, edits, eol);
|
|
98
108
|
}
|
|
99
109
|
if (!imported) {
|
|
100
110
|
const lastImport = imports.at(-1);
|
|
@@ -110,12 +120,15 @@ export function registerModuleInSource(content: string, appFile: string, moduleF
|
|
|
110
120
|
function appendMember(content: string, node: ts.Node, members: ts.NodeArray<ts.Node>, member: string, edits: Edit[], eol: string): void {
|
|
111
121
|
const close = node.end - 1;
|
|
112
122
|
const last = members.at(-1);
|
|
113
|
-
if (last && !members.hasTrailingComma) edits.push({ at: last.end, text: "," });
|
|
114
123
|
const inner = content.slice(node.getStart() + 1, close);
|
|
115
124
|
if (!inner.includes("\n")) {
|
|
116
|
-
|
|
125
|
+
// One line: right after the last member (and its comma), `{ a, b }` → `{ a, b, c }`.
|
|
126
|
+
if (!last) edits.push({ at: close, text: inner.length > 0 ? `${member} ` : member });
|
|
127
|
+
else if (members.hasTrailingComma) edits.push({ at: content.indexOf(",", last.end) + 1, text: ` ${member},` });
|
|
128
|
+
else edits.push({ at: last.end, text: `, ${member}` });
|
|
117
129
|
return;
|
|
118
130
|
}
|
|
131
|
+
if (last && !members.hasTrailingComma) edits.push({ at: last.end, text: "," });
|
|
119
132
|
const lineStart = content.lastIndexOf("\n", close) + 1;
|
|
120
133
|
const closingIndent = content.slice(lineStart, close);
|
|
121
134
|
if (/^\s*$/.test(closingIndent)) {
|
package/cli/parseCli.ts
CHANGED
|
@@ -18,11 +18,24 @@ export interface ParsedGenerateArgs {
|
|
|
18
18
|
readonly target?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface ParsedProcessArgs {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** The module that owns the process. */
|
|
24
|
+
readonly module: string;
|
|
25
|
+
readonly pack?: string;
|
|
26
|
+
readonly modulesRoot?: string;
|
|
27
|
+
readonly force: boolean;
|
|
28
|
+
readonly dryRun: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export type ParseCliResult =
|
|
22
32
|
| { readonly kind: "help"; readonly help: boolean }
|
|
23
33
|
| { readonly kind: "version" }
|
|
24
34
|
| { readonly kind: "new"; readonly name: string; readonly outputPath?: string; readonly frameworkPath?: string; readonly linkFramework: boolean; readonly vendor: boolean; readonly dryRun: boolean }
|
|
25
35
|
| { readonly kind: "generate"; readonly args: ParsedGenerateArgs }
|
|
36
|
+
| { readonly kind: "process"; readonly args: ParsedProcessArgs }
|
|
37
|
+
| { readonly kind: "workflow"; readonly action: "check"; readonly appModule?: string }
|
|
38
|
+
| { readonly kind: "workflow"; readonly action: "diagram"; readonly key: string; readonly appModule?: string }
|
|
26
39
|
| { readonly kind: "codegen"; readonly target?: string }
|
|
27
40
|
| { readonly kind: "dev"; readonly watch: boolean }
|
|
28
41
|
| { readonly kind: "test"; readonly args: readonly string[] }
|
|
@@ -33,7 +46,7 @@ const GENERATOR_ALIASES = new Map<string, "module" | "pack">([
|
|
|
33
46
|
["module", "module"], ["m", "module"],
|
|
34
47
|
["pack", "pack"], ["p", "pack"], ["module-pack", "pack"],
|
|
35
48
|
]);
|
|
36
|
-
const VALUE_OPTIONS = new Set(["--modules-root", "--app-module", "--pack", "--parts", "--target", "--path", "--framework", "--outfile", "--platform"]);
|
|
49
|
+
const VALUE_OPTIONS = new Set(["--modules-root", "--app-module", "--pack", "--module", "--parts", "--target", "--path", "--framework", "--outfile", "--platform"]);
|
|
37
50
|
/** Platforms of `bun build --compile --target=bun-<platform>`. */
|
|
38
51
|
export const BINARY_PLATFORM = /^(linux|darwin|windows)-(x64|arm64)(-musl)?(-baseline|-modern)?$/;
|
|
39
52
|
const FLAG_OPTIONS = new Set(["--no-register", "--force", "--full", "--enterprise", "--minimal", "--empty", "--dry-run", "--no-codegen", "--link-framework", "--vendor", "--bin", "--watch", "--dotenv"]);
|
|
@@ -114,13 +127,42 @@ export function parseCliArgs(argv: readonly string[]): ParseCliResult {
|
|
|
114
127
|
}
|
|
115
128
|
return { kind: "codegen", target };
|
|
116
129
|
}
|
|
130
|
+
if (command === "workflow") {
|
|
131
|
+
for (const option of options.keys()) {
|
|
132
|
+
if (option !== "--app-module") return error(`Option ${option} is not supported by workflow.`);
|
|
133
|
+
}
|
|
134
|
+
const appModule = options.get("--app-module");
|
|
135
|
+
if (generatorToken === "check" && positional.length === 2) return { kind: "workflow", action: "check", ...(appModule === undefined ? {} : { appModule }) };
|
|
136
|
+
if (generatorToken === "diagram" && positional.length === 3) return { kind: "workflow", action: "diagram", key: name!, ...(appModule === undefined ? {} : { appModule }) };
|
|
137
|
+
return error("Use: bazis workflow check [--app-module <path>] or bazis workflow diagram <key> [--app-module <path>]");
|
|
138
|
+
}
|
|
139
|
+
if (options.has("--module") && !((command === "g" || command === "generate") && (generatorToken === "process" || generatorToken === "pr"))) return error("--module is only supported by g process.");
|
|
140
|
+
if ((command === "g" || command === "generate") && (generatorToken === "process" || generatorToken === "pr")) {
|
|
141
|
+
for (const option of options.keys()) {
|
|
142
|
+
if (!["--module", "--pack", "--modules-root", "--force", "--dry-run"].includes(option)) return error(`Option ${option} is not supported by g process.`);
|
|
143
|
+
}
|
|
144
|
+
if (name === undefined || positional.length !== 3) return error("Use: bazis g process <Name> --module <Name> [--pack <Name>]");
|
|
145
|
+
const module = options.get("--module");
|
|
146
|
+
if (module === undefined) return error("g process needs the module that owns the process: --module <Name>.");
|
|
147
|
+
try {
|
|
148
|
+
parseModuleName(name);
|
|
149
|
+
parseModuleName(module);
|
|
150
|
+
if (options.has("--pack")) parseModuleName(options.get("--pack")!);
|
|
151
|
+
} catch (cause) {
|
|
152
|
+
return error(cause instanceof Error ? cause.message : String(cause));
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
kind: "process",
|
|
156
|
+
args: { name, module, pack: options.get("--pack"), modulesRoot: options.get("--modules-root"), force: options.has("--force"), dryRun: options.has("--dry-run") },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
117
159
|
if (command !== "g" && command !== "generate") {
|
|
118
160
|
return error(`Unknown command: ${command ?? "(missing)"}. Use: bazis --help`);
|
|
119
161
|
}
|
|
120
|
-
if (generatorToken === undefined) return error("Generator is required: module (m)
|
|
162
|
+
if (generatorToken === undefined) return error("Generator is required: module (m), pack (p) or process (pr).");
|
|
121
163
|
if (options.has("--path") || options.has("--framework") || options.has("--link-framework") || options.has("--vendor")) return error("--path, --framework, --link-framework and --vendor are only supported by new.");
|
|
122
164
|
const generator = GENERATOR_ALIASES.get(generatorToken);
|
|
123
|
-
if (generator === undefined) return error(`Unknown generator: ${generatorToken}. Supported: module (m), pack (p)`);
|
|
165
|
+
if (generator === undefined) return error(`Unknown generator: ${generatorToken}. Supported: module (m), pack (p), process (pr)`);
|
|
124
166
|
if (name === undefined) return error("Module name is required.");
|
|
125
167
|
if (positional.length !== 3) return error(`Unexpected argument: ${positional[3]}`);
|
|
126
168
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ModuleNaming } from "../naming";
|
|
2
|
+
|
|
3
|
+
/** `processes/<Name>.process.ts`: a manual start and an end, to grow into the real process. */
|
|
4
|
+
export function buildProcessTemplate(process: ModuleNaming, moduleClass: string): string {
|
|
5
|
+
const title = process.folder.split("-").map((word, index) => (index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word)).join(" ");
|
|
6
|
+
return `import { defineProcess } from "bazis/core/workflow";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ${title}: a process of ${moduleClass}. It is published when the application
|
|
10
|
+
* starts and changes only here. Check it with \`bunx bazis workflow check\`,
|
|
11
|
+
* draw it with \`bunx bazis workflow diagram ${process.folder}\`.
|
|
12
|
+
*/
|
|
13
|
+
export const ${process.module}Process = defineProcess({
|
|
14
|
+
key: "${process.folder}",
|
|
15
|
+
title: "${title}",
|
|
16
|
+
document: {
|
|
17
|
+
schemaVersion: "bazis.workflow/v1",
|
|
18
|
+
nodes: {
|
|
19
|
+
Start: { kind: "manualStart", title: "Start" },
|
|
20
|
+
End: { kind: "end", title: "End" },
|
|
21
|
+
},
|
|
22
|
+
flows: {
|
|
23
|
+
f1: { source: "Start", target: "End" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
`;
|
|
28
|
+
}
|
package/cli/workflow.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { PALETTE } from "../library/terminal";
|
|
4
|
+
import { processToMermaid } from "../library/workflow";
|
|
5
|
+
import { isBazisModuleClass } from "../core/di/module/Module";
|
|
6
|
+
import type { BazisModuleRef } from "../core/di";
|
|
7
|
+
import { inspectProcesses, type ProcessInspection } from "../core/workflow/definitions_module/code/inspectProcesses";
|
|
8
|
+
import { commandFailed, commandHeader, type CliOutput } from "./ui";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The application's root module, imported without starting anything: the
|
|
12
|
+
* export `AppModule` of the app module file, or its only `@Module` class.
|
|
13
|
+
*/
|
|
14
|
+
export async function loadAppModule(cwd: string, appModule = "src/app/modules/App.module.ts"): Promise<{ readonly file: string; readonly root: BazisModuleRef }> {
|
|
15
|
+
const file = path.resolve(cwd, appModule);
|
|
16
|
+
if (!(await Bun.file(file).exists())) throw new Error(`App module not found: ${path.relative(cwd, file)}. Pass --app-module <path>.`);
|
|
17
|
+
const exports = await import(pathToFileURL(file).href) as Record<string, unknown>;
|
|
18
|
+
const modules = Object.entries(exports).filter(([, value]) => isBazisModuleClass(value));
|
|
19
|
+
const chosen = modules.find(([name]) => name === "AppModule") ?? (modules.length === 1 ? modules[0] : undefined);
|
|
20
|
+
if (!chosen) throw new Error(`${path.relative(cwd, file)} exports ${modules.length === 0 ? "no @Module class" : "several @Module classes and none is AppModule"}.`);
|
|
21
|
+
return { file, root: chosen[1] as BazisModuleRef };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `bazis workflow check`: every process of the module tree against the Tools; exit code 1 on errors. */
|
|
25
|
+
export async function runWorkflowCheck(cwd: string, appModule: string | undefined, ui: CliOutput): Promise<number> {
|
|
26
|
+
const s = ui.out;
|
|
27
|
+
const t = s.t.workflow;
|
|
28
|
+
const { file, root } = await loadAppModule(cwd, appModule);
|
|
29
|
+
commandHeader(s, "workflow", cwd, [s.kv(t.app, s.text(path.relative(cwd, file)))]);
|
|
30
|
+
const inspection = inspectProcesses(root);
|
|
31
|
+
s.section(t.section, s.muted(`${inspection.processes.length} ${s.t.noun("process", inspection.processes.length)} · ${inspection.tools.length} ${s.t.noun("tool", inspection.tools.length)}`));
|
|
32
|
+
if (inspection.processes.length === 0) s.line(` ${s.muted(t.none)}`);
|
|
33
|
+
for (const { process, module, errors, warnings } of inspection.processes) {
|
|
34
|
+
const nodes = Object.keys(process.document.nodes ?? {}).length;
|
|
35
|
+
s.step(errors.length > 0 ? "fail" : warnings.length > 0 ? "warn" : "ok", process.key, s.muted(`${module} · ${nodes} ${s.t.noun("node", nodes)}`));
|
|
36
|
+
s.tree([...errors.map((text) => ({ text: s.fg(PALETTE.red, text) })), ...warnings.map((text) => ({ text: s.fg(PALETTE.yellow, text) }))], s.style.color ? 9 : 4);
|
|
37
|
+
}
|
|
38
|
+
const errors = errorCount(inspection);
|
|
39
|
+
s.line();
|
|
40
|
+
if (errors > 0) {
|
|
41
|
+
const problems = [
|
|
42
|
+
...inspection.errors.map((cause) => ({ tag: "WORKFLOW", where: "processes", cause })),
|
|
43
|
+
...inspection.processes.filter((entry) => entry.errors.length > 0).map((entry) => ({ tag: "WORKFLOW", where: `${entry.module} · ${entry.process.key}`, cause: entry.errors.join("\n") })),
|
|
44
|
+
];
|
|
45
|
+
return commandFailed(ui.err, t.failed(errors), problems, 1, t.fix);
|
|
46
|
+
}
|
|
47
|
+
s.banner("success", t.passed(inspection.processes.length));
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `bazis workflow diagram <key>`: the process as Mermaid on stdout, nothing else, so it can be redirected. */
|
|
52
|
+
export async function runWorkflowDiagram(cwd: string, key: string, appModule: string | undefined, log: (text: string) => void): Promise<number> {
|
|
53
|
+
const { root } = await loadAppModule(cwd, appModule);
|
|
54
|
+
const inspection = inspectProcesses(root);
|
|
55
|
+
const entry = inspection.processes.find(({ process }) => process.key === key);
|
|
56
|
+
if (!entry) {
|
|
57
|
+
const keys = inspection.processes.map(({ process }) => process.key);
|
|
58
|
+
throw new Error(`No process "${key}" is declared in code. ${keys.length === 0 ? "The modules declare no processes." : `Declared: ${keys.join(", ")}.`}`);
|
|
59
|
+
}
|
|
60
|
+
log(processToMermaid(entry.process.document).trimEnd());
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function errorCount(inspection: ProcessInspection): number {
|
|
65
|
+
return inspection.errors.length + inspection.processes.reduce((sum, entry) => sum + entry.errors.length, 0);
|
|
66
|
+
}
|
|
@@ -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.128.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 {}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the engine takes the time from (WORKFLOW-001 §14): due times of timers,
|
|
3
|
+
* retries, scheduled starts and job delays, the retention cutoff and task marks.
|
|
4
|
+
* Record stamps (`createdAt`, history `at`) stay the database's time. The
|
|
5
|
+
* default is the system clock; tests replace it (`bazis/core/workflow/testing`).
|
|
6
|
+
*/
|
|
7
|
+
export abstract class WorkflowClock {
|
|
8
|
+
abstract now(): Date;
|
|
9
|
+
/**
|
|
10
|
+
* Time moves only when a test moves it: the worker and retention then leave
|
|
11
|
+
* the jobs to the test's `drain`, so nothing runs behind its back.
|
|
12
|
+
*/
|
|
13
|
+
readonly manual?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The system clock. */
|
|
17
|
+
export class SystemWorkflowClock extends WorkflowClock {
|
|
18
|
+
now(): Date {
|
|
19
|
+
return new Date();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { DI, Module, SERVICE_PROVIDER, scoped } from "../../di";
|
|
1
|
+
import { DI, HOSTED_SERVICE, Module, SERVICE_PROVIDER, scoped } from "../../di";
|
|
2
|
+
import { SystemWorkflowClock, WorkflowClock } from "../contracts/WorkflowClock";
|
|
2
3
|
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
4
|
+
import { CodeProcessCatalog } from "./code/CodeProcessCatalog";
|
|
5
|
+
import { CodeProcessPublisher } from "./code/CodeProcessPublisher";
|
|
6
|
+
import { codeProcessesOf } from "./code/codeProcesses";
|
|
3
7
|
import { ProcessesController } from "./http/Processes.controller";
|
|
4
8
|
import { DefinitionsDbContext, definitionsStore } from "./model/Definitions.dbContext";
|
|
5
9
|
import { WfProcess } from "./model/WfProcess.model";
|
|
@@ -8,17 +12,21 @@ import { IWorkflowDefinitions } from "./services/IWorkflowDefinitions.service";
|
|
|
8
12
|
import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
|
-
* The process catalog: drafts and published versions
|
|
12
|
-
*
|
|
13
|
-
* application's codegen.
|
|
15
|
+
* The process catalog: drafts and published versions, and the processes
|
|
16
|
+
* declared in code ([passport](MODULE.md)). Dependencies are explicit: the
|
|
17
|
+
* framework's classes are not covered by the application's codegen.
|
|
14
18
|
*/
|
|
15
19
|
@Module({
|
|
16
20
|
ormBazis: { context: DefinitionsDbContext, entities: [WfProcess, WfVersion], ownedStore: definitionsStore, registerRepositories: false },
|
|
17
21
|
providers: [
|
|
22
|
+
DI.singleton(DI.factoryProvider(WorkflowClock, [], () => new SystemWorkflowClock())),
|
|
18
23
|
DI.singleton(DI.factoryProviderWithResolver(WorkflowTools, [], (resolver) => new WorkflowTools(resolver.resolve(SERVICE_PROVIDER)))),
|
|
19
|
-
|
|
24
|
+
DI.singleton(DI.factoryProviderWithResolver(CodeProcessCatalog, [], (resolver) => new CodeProcessCatalog(codeProcessesOf(resolver.resolve(SERVICE_PROVIDER))))),
|
|
25
|
+
scoped(WorkflowDefinitions, WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock] as const),
|
|
26
|
+
DI.scoped(DI.factoryProviderWithResolver(IWorkflowDefinitions, [], (resolver) => resolver.resolve(WorkflowDefinitions))),
|
|
27
|
+
DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => new CodeProcessPublisher(resolver.resolve(SERVICE_PROVIDER)))),
|
|
20
28
|
],
|
|
21
29
|
controllers: [DI.bindDeps(ProcessesController, IWorkflowDefinitions)],
|
|
22
|
-
exports: [IWorkflowDefinitions, WorkflowTools],
|
|
30
|
+
exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock],
|
|
23
31
|
})
|
|
24
32
|
export class WorkflowDefinitionsModule {}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { OwnedCodeProcess } from "./codeProcesses";
|
|
2
|
+
|
|
3
|
+
/** The processes declared in the application's modules, by key: which module owns a key. */
|
|
4
|
+
export class CodeProcessCatalog {
|
|
5
|
+
private readonly byKey: ReadonlyMap<string, OwnedCodeProcess>;
|
|
6
|
+
|
|
7
|
+
constructor(entries: readonly OwnedCodeProcess[]) {
|
|
8
|
+
this.byKey = new Map([...entries].sort((a, b) => a.process.key.localeCompare(b.process.key)).map((entry) => [entry.process.key, entry]));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The module whose `processes` declare `key`; `undefined` for a process of the catalog. */
|
|
12
|
+
moduleOf(key: string): string | undefined {
|
|
13
|
+
return this.byKey.get(key)?.module;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
list(): readonly OwnedCodeProcess[] {
|
|
17
|
+
return [...this.byKey.values()];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { markConfigurationError, type ServiceProvider } from "../../../di";
|
|
2
|
+
import type { HostedService } from "../../../di/extensions/hosted-service";
|
|
3
|
+
import { LOGGER } from "../../../kernel";
|
|
4
|
+
import { WorkflowTools } from "../../internal/WorkflowTools";
|
|
5
|
+
import { WorkflowDefinitions } from "../services/WorkflowDefinitions.service";
|
|
6
|
+
import { CodeProcessCatalog } from "./CodeProcessCatalog";
|
|
7
|
+
import { definitionProblems, WorkflowSetupError } from "./codeProcesses";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Publishes the processes of `@Module({ processes })` at start, after the
|
|
11
|
+
* tables exist (WORKFLOW-001 §14). A document with an unknown Tool stops the
|
|
12
|
+
* start with every finding of every process; the same document again gives no
|
|
13
|
+
* new version.
|
|
14
|
+
*/
|
|
15
|
+
export class CodeProcessPublisher implements HostedService {
|
|
16
|
+
readonly diagnosticName = "workflow:processes";
|
|
17
|
+
|
|
18
|
+
constructor(private readonly services: ServiceProvider) {}
|
|
19
|
+
|
|
20
|
+
async start(): Promise<void> {
|
|
21
|
+
const entries = this.services.resolve(CodeProcessCatalog).list();
|
|
22
|
+
if (entries.length === 0) return;
|
|
23
|
+
const tools = this.services.resolve(WorkflowTools);
|
|
24
|
+
const problems = entries.flatMap(({ process, module }) => {
|
|
25
|
+
const found = definitionProblems(process, (name) => tools.has(name));
|
|
26
|
+
return found.length === 0 ? [] : [`${module}: process "${process.key}":\n${found.map((line) => ` - ${line}`).join("\n")}`];
|
|
27
|
+
});
|
|
28
|
+
if (problems.length > 0) {
|
|
29
|
+
throw markConfigurationError(new WorkflowSetupError(`Processes declared in code cannot be published:\n${problems.join("\n")}`));
|
|
30
|
+
}
|
|
31
|
+
const scope = this.services.createScope();
|
|
32
|
+
try {
|
|
33
|
+
const results = await scope.resolve(WorkflowDefinitions).publishCode(entries);
|
|
34
|
+
this.services.tryResolve(LOGGER)?.info("workflow processes from code", {
|
|
35
|
+
published: results.filter((result) => result.published).map((result) => `${result.key} v${result.version}`),
|
|
36
|
+
unchanged: results.filter((result) => !result.published).map((result) => result.key),
|
|
37
|
+
});
|
|
38
|
+
} finally {
|
|
39
|
+
await scope.dispose();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
stop(): void {}
|
|
44
|
+
}
|