@nanobpm/urban 0.13.0 → 0.15.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/dist/cli.js +40 -2
- package/dist/runtime/core/host.d.ts +7 -3
- package/dist/runtime/core/modules/workers.d.ts +17 -2
- package/dist/toolkit/gen.d.ts +7 -0
- package/dist/toolkit/gen.js +12 -7
- package/dist/toolkit/index.d.ts +5 -1
- package/dist/toolkit/index.js +4 -1
- package/dist/toolkit/scaffold/workers.d.ts +48 -0
- package/dist/toolkit/scaffold/workers.js +114 -0
- package/dist/toolkit/scaffold.d.ts +31 -0
- package/dist/toolkit/scaffold.js +54 -0
- package/package.json +1 -1
- package/src/cli.ts +39 -2
- package/src/runtime/core/host.ts +7 -3
- package/src/runtime/core/modules/workers.test.ts +53 -1
- package/src/runtime/core/modules/workers.ts +19 -4
- package/src/toolkit/gen.ts +17 -7
- package/src/toolkit/index.ts +18 -1
- package/src/toolkit/scaffold/workers.test.ts +196 -0
- package/src/toolkit/scaffold/workers.ts +178 -0
- package/src/toolkit/scaffold.ts +104 -0
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// --port <n>, -h/--help, -v/--version.
|
|
12
12
|
import { collectManifestIssues, installSignalHandlers, loadManifest, runDev, runFromEnv, selectHost, } from "./runtime/index.js";
|
|
13
13
|
import { scaffold, slugify } from "create-urban-app";
|
|
14
|
-
import { createNodeGenIO, runGen } from "./toolkit/index.js";
|
|
14
|
+
import { createNodeGenIO, runGen, scaffoldWorkers } from "./toolkit/index.js";
|
|
15
15
|
import { pathToFileURL } from "node:url";
|
|
16
16
|
import { readFileSync } from "node:fs";
|
|
17
17
|
// Read the package version from package.json (one hop up from both src/cli.ts and the
|
|
@@ -27,7 +27,7 @@ function readVersion() {
|
|
|
27
27
|
}
|
|
28
28
|
const VERSION = readVersion();
|
|
29
29
|
function parse(argv) {
|
|
30
|
-
const f = { root: ".", manifest: "nano.app.json", check: false, deno: false, help: false, version: false, _: [] };
|
|
30
|
+
const f = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, help: false, version: false, _: [] };
|
|
31
31
|
const need = (i, flag) => {
|
|
32
32
|
const v = argv[i];
|
|
33
33
|
if (v === undefined || v.startsWith("-"))
|
|
@@ -42,6 +42,8 @@ function parse(argv) {
|
|
|
42
42
|
f.version = true;
|
|
43
43
|
else if (a === "--check")
|
|
44
44
|
f.check = true;
|
|
45
|
+
else if (a === "--write")
|
|
46
|
+
f.write = true;
|
|
45
47
|
else if (a === "--deno")
|
|
46
48
|
f.deno = true;
|
|
47
49
|
else if (a === "--")
|
|
@@ -71,6 +73,7 @@ Usage:
|
|
|
71
73
|
urban new <name> [--root <path>] [--deno] scaffold a new Urban app
|
|
72
74
|
urban check validate the manifest
|
|
73
75
|
urban gen [--check] derive artifacts (migrations, worker-io)
|
|
76
|
+
urban stubs [--write] scaffold write-once handler stubs from the model
|
|
74
77
|
urban run materialize + serve the app
|
|
75
78
|
urban dev run + watch sources, hot-reload on change
|
|
76
79
|
urban deploy deploy models only, then exit
|
|
@@ -117,6 +120,39 @@ async function cmdGen(f) {
|
|
|
117
120
|
console.log(` • ${a.path}`);
|
|
118
121
|
return 0;
|
|
119
122
|
}
|
|
123
|
+
async function cmdStubs(f) {
|
|
124
|
+
const io = createNodeGenIO();
|
|
125
|
+
const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
|
|
126
|
+
const created = run.outcomes.filter((o) => o.status === "created");
|
|
127
|
+
const would = run.outcomes.filter((o) => o.status === "would-create");
|
|
128
|
+
const kept = run.outcomes.filter((o) => o.status === "kept");
|
|
129
|
+
const typing = (o) => o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
|
|
130
|
+
if (run.write) {
|
|
131
|
+
console.log(`✔ scaffolded ${created.length} worker stub(s):`);
|
|
132
|
+
for (const o of created)
|
|
133
|
+
console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
134
|
+
if (run.manifestPatched) {
|
|
135
|
+
console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
if (would.length === 0) {
|
|
140
|
+
console.log(`✔ no new worker stubs to scaffold`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
|
|
144
|
+
for (const o of would)
|
|
145
|
+
console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const o of kept)
|
|
149
|
+
console.log(` = ${o.handlerPath} (kept — already exists)`);
|
|
150
|
+
if (run.skipped.length > 0) {
|
|
151
|
+
console.log(` skipped ${run.skipped.length}: ` +
|
|
152
|
+
run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
|
|
153
|
+
}
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
120
156
|
async function cmdRun(f, mount) {
|
|
121
157
|
const app = await runFromEnv({
|
|
122
158
|
root: f.root,
|
|
@@ -185,6 +221,8 @@ export async function main(argv) {
|
|
|
185
221
|
return cmdCheck(f);
|
|
186
222
|
case "gen":
|
|
187
223
|
return cmdGen(f);
|
|
224
|
+
case "stubs":
|
|
225
|
+
return cmdStubs(f);
|
|
188
226
|
case "run":
|
|
189
227
|
return cmdRun(f);
|
|
190
228
|
case "dev":
|
|
@@ -74,13 +74,17 @@ export interface HostContext {
|
|
|
74
74
|
/** Structured log sink. */
|
|
75
75
|
log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
|
|
76
76
|
}
|
|
77
|
-
/** A job handed to a worker handler, plus the ack/fail callbacks.
|
|
78
|
-
|
|
77
|
+
/** A job handed to a worker handler, plus the ack/fail callbacks. `In` types the process
|
|
78
|
+
* variables carried on the job; it defaults to an open record when a handler doesn't
|
|
79
|
+
* declare a concrete input shape. The `object` bound (rather than `Record<string, unknown>`)
|
|
80
|
+
* lets plain `interface` shapes be used as `In` — interfaces lack the implicit index
|
|
81
|
+
* signature that a `Record` bound would demand. */
|
|
82
|
+
export interface EngineJob<In extends object = Record<string, unknown>> {
|
|
79
83
|
jobKey: string;
|
|
80
84
|
jobType: string;
|
|
81
85
|
processInstanceKey?: string;
|
|
82
86
|
elementId?: string;
|
|
83
|
-
variables:
|
|
87
|
+
variables: In;
|
|
84
88
|
}
|
|
85
89
|
export type JobHandler = (job: EngineJob) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void;
|
|
86
90
|
/** A registered worker subscription. */
|
|
@@ -6,8 +6,23 @@ import { type DecisionEvaluator } from "./llm.ts";
|
|
|
6
6
|
* SDK shape and adds decision context to a parse failure rather than surfacing a raw
|
|
7
7
|
* "is not a function" / `JSON.parse` error. */
|
|
8
8
|
export declare function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator;
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* A handler as authored by an app: the job plus the injected app API.
|
|
11
|
+
*
|
|
12
|
+
* Both type parameters are optional:
|
|
13
|
+
* - `In` types `job.variables` (the process variables the job carries).
|
|
14
|
+
* - `Out` types the completion **variables map** the handler returns to the engine.
|
|
15
|
+
* Each defaults to an open `Record<string, unknown>`, so `AppJobHandler`,
|
|
16
|
+
* `AppJobHandler<In>`, and `AppJobHandler<In, Out>` are all valid.
|
|
17
|
+
*
|
|
18
|
+
* The bound is `extends object` (not `Record<string, unknown>`) so a plain `interface In {…}`
|
|
19
|
+
* / `interface Out {…}` can be supplied — interfaces have no implicit index signature and so
|
|
20
|
+
* don't satisfy a `Record` bound. The looser bound means `Out` is not *forced* to be a plain
|
|
21
|
+
* variables map at the type level; authors are expected to return a JSON-object of completion
|
|
22
|
+
* variables (not a `Date`, `Map`, array, etc.), which is what the engine sends on job
|
|
23
|
+
* completion. Both parameters are erased at runtime — they only shape authoring-time types.
|
|
24
|
+
*/
|
|
25
|
+
export type AppJobHandler<In extends object = Record<string, unknown>, Out extends object = Record<string, unknown>> = (job: EngineJob<In>, app: AppApi) => Promise<Out | void> | Out | void;
|
|
11
26
|
/**
|
|
12
27
|
* Resolve a handler for `jobType` from a loaded module, in priority order:
|
|
13
28
|
* 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
|
package/dist/toolkit/gen.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DerivedArtifact } from "./artifact.ts";
|
|
2
|
+
import { type ModelSource } from "./derivers/worker-io.ts";
|
|
2
3
|
/** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
|
|
3
4
|
export interface GenIO {
|
|
4
5
|
readText(path: string): Promise<string>;
|
|
@@ -19,6 +20,12 @@ export interface GenResult {
|
|
|
19
20
|
}
|
|
20
21
|
declare function join(root: string, rel: string): string;
|
|
21
22
|
declare function dirOf(p: string): string;
|
|
23
|
+
/** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
|
|
24
|
+
export declare function readModels(root: string, io: GenIO, manifest: {
|
|
25
|
+
models?: {
|
|
26
|
+
processes?: string[];
|
|
27
|
+
};
|
|
28
|
+
}): Promise<ModelSource[]>;
|
|
22
29
|
/** Collect all artifacts a run would produce, without touching disk beyond reads. */
|
|
23
30
|
export declare function collectArtifacts(opts: GenOptions): Promise<DerivedArtifact[]>;
|
|
24
31
|
/** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
|
package/dist/toolkit/gen.js
CHANGED
|
@@ -30,6 +30,17 @@ async function expandPattern(root, io, pattern) {
|
|
|
30
30
|
.map((n) => (dir === "." ? n : `${dir}/${n}`))
|
|
31
31
|
.sort();
|
|
32
32
|
}
|
|
33
|
+
/** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
|
|
34
|
+
export async function readModels(root, io, manifest) {
|
|
35
|
+
const procPatterns = manifest.models?.processes ?? [];
|
|
36
|
+
const models = [];
|
|
37
|
+
for (const pat of procPatterns) {
|
|
38
|
+
for (const rel of await expandPattern(root, io, pat)) {
|
|
39
|
+
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return models;
|
|
43
|
+
}
|
|
33
44
|
/** Collect all artifacts a run would produce, without touching disk beyond reads. */
|
|
34
45
|
export async function collectArtifacts(opts) {
|
|
35
46
|
const { root, io } = opts;
|
|
@@ -41,13 +52,7 @@ export async function collectArtifacts(opts) {
|
|
|
41
52
|
artifacts.push(...deriveMigrations(manifest));
|
|
42
53
|
}
|
|
43
54
|
// 2. models → worker I/O index
|
|
44
|
-
const
|
|
45
|
-
const models = [];
|
|
46
|
-
for (const pat of procPatterns) {
|
|
47
|
-
for (const rel of await expandPattern(root, io, pat)) {
|
|
48
|
-
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
49
|
-
}
|
|
50
|
-
}
|
|
55
|
+
const models = await readModels(root, io, manifest);
|
|
51
56
|
if (models.length > 0) {
|
|
52
57
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
53
58
|
artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
|
package/dist/toolkit/index.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export declare const DERIVERS: readonly [import("./artifact.ts").Deriver<import(
|
|
|
8
8
|
models: import("./derivers/worker-io.ts").ModelSource[];
|
|
9
9
|
declaredTypeIds: Iterable<string>;
|
|
10
10
|
}>];
|
|
11
|
-
export { collectArtifacts, runGen, joinPath } from "./gen.ts";
|
|
11
|
+
export { collectArtifacts, runGen, joinPath, readModels } from "./gen.ts";
|
|
12
12
|
export type { GenIO, GenOptions, GenResult } from "./gen.ts";
|
|
13
13
|
export { createNodeGenIO } from "./fsio.ts";
|
|
14
|
+
export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffold/workers.ts";
|
|
15
|
+
export type { ScaffoldWorker, SkippedWorker, SkipReason, StubManifestEntry, WorkerScaffoldPlan, WorkerStubPlan, } from "./scaffold/workers.ts";
|
|
16
|
+
export { scaffoldWorkers } from "./scaffold.ts";
|
|
17
|
+
export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
|
package/dist/toolkit/index.js
CHANGED
|
@@ -11,5 +11,8 @@ import { migrationsDeriver } from "./derivers/migrations.js";
|
|
|
11
11
|
import { workerIoDeriver } from "./derivers/worker-io.js";
|
|
12
12
|
export const DERIVERS = [migrationsDeriver, workerIoDeriver];
|
|
13
13
|
// Gen orchestrator + IO
|
|
14
|
-
export { collectArtifacts, runGen, joinPath } from "./gen.js";
|
|
14
|
+
export { collectArtifacts, runGen, joinPath, readModels } from "./gen.js";
|
|
15
15
|
export { createNodeGenIO } from "./fsio.js";
|
|
16
|
+
// Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
|
|
17
|
+
export { planWorkerScaffold, renderWorkerStub, slugifyTaskType, } from "./scaffold/workers.js";
|
|
18
|
+
export { scaffoldWorkers } from "./scaffold.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ModelSource } from "../derivers/worker-io.ts";
|
|
2
|
+
/** A worker to plan against: the manifest's already-wired workers. */
|
|
3
|
+
export interface ScaffoldWorker {
|
|
4
|
+
taskType: string;
|
|
5
|
+
handler?: string;
|
|
6
|
+
llm?: string;
|
|
7
|
+
}
|
|
8
|
+
/** The `workers[]` manifest entry a stub is wired in as. */
|
|
9
|
+
export interface StubManifestEntry {
|
|
10
|
+
taskType: string;
|
|
11
|
+
handler: string;
|
|
12
|
+
}
|
|
13
|
+
/** One planned handler stub. */
|
|
14
|
+
export interface WorkerStubPlan {
|
|
15
|
+
taskType: string;
|
|
16
|
+
process?: string;
|
|
17
|
+
elementId?: string;
|
|
18
|
+
/** Directory slug under `workers/` (unique across the plan). */
|
|
19
|
+
slug: string;
|
|
20
|
+
/** App-relative handler path: `workers/<slug>/worker.ts`. */
|
|
21
|
+
handlerPath: string;
|
|
22
|
+
/** True when the data-envelope `in`/`out` names a declared domain type (so it is typed). */
|
|
23
|
+
typedIn: boolean;
|
|
24
|
+
typedOut: boolean;
|
|
25
|
+
/** The stub file contents. */
|
|
26
|
+
stub: string;
|
|
27
|
+
manifestEntry: StubManifestEntry;
|
|
28
|
+
}
|
|
29
|
+
export type SkipReason = "already-wired" | "orchestrator" | "duplicate";
|
|
30
|
+
export interface SkippedWorker {
|
|
31
|
+
taskType: string;
|
|
32
|
+
reason: SkipReason;
|
|
33
|
+
}
|
|
34
|
+
export interface WorkerScaffoldPlan {
|
|
35
|
+
plans: WorkerStubPlan[];
|
|
36
|
+
skipped: SkippedWorker[];
|
|
37
|
+
}
|
|
38
|
+
/** Turn a task type into a filesystem-safe directory slug. */
|
|
39
|
+
export declare function slugifyTaskType(taskType: string): string;
|
|
40
|
+
/** Render a stub matching a real hand-authored worker (default-exported AppJobHandler). */
|
|
41
|
+
export declare function renderWorkerStub(taskType: string, typedIn: boolean, typedOut: boolean): string;
|
|
42
|
+
/**
|
|
43
|
+
* Plan write-once handler stubs from the models. A stub is planned for each service-task
|
|
44
|
+
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, and
|
|
45
|
+
* not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
|
|
46
|
+
* envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
|
|
47
|
+
*/
|
|
48
|
+
export declare function planWorkerScaffold(models: ModelSource[], workers?: ScaffoldWorker[], declaredTypeIds?: Iterable<string>): WorkerScaffoldPlan;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Pure planner for the write-once worker-stub scaffolder (ADR 0056). Given the app's models,
|
|
2
|
+
// its manifest workers, and the set of declared domain type ids, it plans one typed handler
|
|
3
|
+
// stub per un-wired service task — the ONE part of an Urban app that cannot be derived from
|
|
4
|
+
// the model (the handler *body* is human logic). Everything here is pure and testable like a
|
|
5
|
+
// deriver; the impure write-if-absent edge lives in `../scaffold.ts`.
|
|
6
|
+
//
|
|
7
|
+
// scaffold ≠ derive: stubs are human-owned files under `workers/<slug>/worker.ts`, so they are
|
|
8
|
+
// planned once and never overwritten — unlike the derived `nano-generated/` tree.
|
|
9
|
+
import { scanModelWorkers } from "../derivers/worker-io.js";
|
|
10
|
+
/** Suffix the imperative orchestrator task type carries (ADR 0054 / defineWorkflow). */
|
|
11
|
+
const ORCHESTRATOR_SUFFIX = ":__orchestrate";
|
|
12
|
+
/** Relative import from `workers/<slug>/worker.ts` to the generated worker-io types. */
|
|
13
|
+
const GENERATED_IMPORT = "../../nano-generated/worker-io.d.ts";
|
|
14
|
+
/** Turn a task type into a filesystem-safe directory slug. */
|
|
15
|
+
export function slugifyTaskType(taskType) {
|
|
16
|
+
const s = taskType
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
19
|
+
.replace(/^-+|-+$/g, "");
|
|
20
|
+
return s.length > 0 ? s : "worker";
|
|
21
|
+
}
|
|
22
|
+
/** Render a stub matching a real hand-authored worker (default-exported AppJobHandler). */
|
|
23
|
+
export function renderWorkerStub(taskType, typedIn, typedOut) {
|
|
24
|
+
const key = JSON.stringify(taskType); // e.g. "pr.finalize" — matches the deriver's property keys
|
|
25
|
+
const syms = [];
|
|
26
|
+
if (typedIn)
|
|
27
|
+
syms.push("WorkerInputs");
|
|
28
|
+
if (typedOut)
|
|
29
|
+
syms.push("WorkerOutputs");
|
|
30
|
+
const generatedImport = syms.length > 0
|
|
31
|
+
? `import type { ${syms.join(", ")} } from ${JSON.stringify(GENERATED_IMPORT)};\n`
|
|
32
|
+
: "";
|
|
33
|
+
let generic = "";
|
|
34
|
+
if (typedIn && typedOut)
|
|
35
|
+
generic = `<WorkerInputs[${key}], WorkerOutputs[${key}]>`;
|
|
36
|
+
else if (typedIn)
|
|
37
|
+
generic = `<WorkerInputs[${key}]>`;
|
|
38
|
+
else if (typedOut)
|
|
39
|
+
generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
|
|
40
|
+
const varsNote = typedIn
|
|
41
|
+
? ` // \`job.variables\` is typed as WorkerInputs[${key}].`
|
|
42
|
+
: ` // \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
|
|
43
|
+
return (`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
|
|
44
|
+
`// This file is yours to edit — \`urban stubs\` will never overwrite it. Implement the body\n` +
|
|
45
|
+
`// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
|
|
46
|
+
`import type { AppJobHandler } from "@nanobpm/urban";\n` +
|
|
47
|
+
generatedImport +
|
|
48
|
+
`\n` +
|
|
49
|
+
`const handler: AppJobHandler${generic} = async (job, app) => {\n` +
|
|
50
|
+
varsNote +
|
|
51
|
+
`\n` +
|
|
52
|
+
` // Reach app state and services through the injected \`app\` API.\n` +
|
|
53
|
+
` throw new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
|
|
54
|
+
`};\n` +
|
|
55
|
+
`\n` +
|
|
56
|
+
`export default handler;\n`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Plan write-once handler stubs from the models. A stub is planned for each service-task
|
|
60
|
+
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, and
|
|
61
|
+
* not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
|
|
62
|
+
* envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
|
|
63
|
+
*/
|
|
64
|
+
export function planWorkerScaffold(models, workers = [], declaredTypeIds = []) {
|
|
65
|
+
const wired = new Set(workers
|
|
66
|
+
.map((w) => w?.taskType)
|
|
67
|
+
.filter((t) => typeof t === "string" && t.length > 0));
|
|
68
|
+
const declared = new Set(declaredTypeIds);
|
|
69
|
+
const plans = [];
|
|
70
|
+
const skipped = [];
|
|
71
|
+
const seenTaskTypes = new Set();
|
|
72
|
+
const usedSlugs = new Set();
|
|
73
|
+
for (const model of [...models].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
74
|
+
for (const io of scanModelWorkers(model.xml)) {
|
|
75
|
+
const taskType = io.taskType;
|
|
76
|
+
if (taskType.endsWith(ORCHESTRATOR_SUFFIX)) {
|
|
77
|
+
skipped.push({ taskType, reason: "orchestrator" });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (wired.has(taskType)) {
|
|
81
|
+
skipped.push({ taskType, reason: "already-wired" });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (seenTaskTypes.has(taskType)) {
|
|
85
|
+
skipped.push({ taskType, reason: "duplicate" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
seenTaskTypes.add(taskType);
|
|
89
|
+
let slug = slugifyTaskType(taskType);
|
|
90
|
+
if (usedSlugs.has(slug)) {
|
|
91
|
+
let n = 2;
|
|
92
|
+
while (usedSlugs.has(`${slug}-${n}`))
|
|
93
|
+
n++;
|
|
94
|
+
slug = `${slug}-${n}`;
|
|
95
|
+
}
|
|
96
|
+
usedSlugs.add(slug);
|
|
97
|
+
const typedIn = io.in != null && declared.has(io.in);
|
|
98
|
+
const typedOut = io.out != null && declared.has(io.out);
|
|
99
|
+
const handlerPath = `workers/${slug}/worker.ts`;
|
|
100
|
+
plans.push({
|
|
101
|
+
taskType,
|
|
102
|
+
process: io.process,
|
|
103
|
+
elementId: io.elementId,
|
|
104
|
+
slug,
|
|
105
|
+
handlerPath,
|
|
106
|
+
typedIn,
|
|
107
|
+
typedOut,
|
|
108
|
+
stub: renderWorkerStub(taskType, typedIn, typedOut),
|
|
109
|
+
manifestEntry: { taskType, handler: handlerPath },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { plans, skipped };
|
|
114
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { GenIO } from "./gen.ts";
|
|
2
|
+
import { type SkippedWorker, type StubManifestEntry } from "./scaffold/workers.ts";
|
|
3
|
+
export interface ScaffoldOptions {
|
|
4
|
+
root: string;
|
|
5
|
+
io: GenIO;
|
|
6
|
+
manifestFile?: string;
|
|
7
|
+
/** Apply changes (create files + patch manifest). Default false = dry-run. */
|
|
8
|
+
write?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type StubStatus = "created" | "would-create" | "kept";
|
|
11
|
+
export interface StubOutcome {
|
|
12
|
+
taskType: string;
|
|
13
|
+
handlerPath: string;
|
|
14
|
+
status: StubStatus;
|
|
15
|
+
typedIn: boolean;
|
|
16
|
+
typedOut: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ScaffoldRun {
|
|
19
|
+
outcomes: StubOutcome[];
|
|
20
|
+
skipped: SkippedWorker[];
|
|
21
|
+
/**
|
|
22
|
+
* Manifest entries that would be appended to `workers[]` — one per planned stub, so this is
|
|
23
|
+
* populated on a dry-run too (letting a caller preview the wiring). They are actually written
|
|
24
|
+
* to the manifest only when `write` is set (see `manifestPatched`).
|
|
25
|
+
*/
|
|
26
|
+
wired: StubManifestEntry[];
|
|
27
|
+
manifestPatched: boolean;
|
|
28
|
+
write: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
|
|
31
|
+
export declare function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRun>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The stub-scaffolder's one impure edge (ADR 0056): read the manifest + models, run the pure
|
|
2
|
+
// planner, and — write-if-absent only — create each handler stub and wire it into the manifest.
|
|
3
|
+
// Dry-run by default (`write: false`); nothing is touched unless `write` is set. Stubs are
|
|
4
|
+
// human-owned, so an existing file is KEPT verbatim, never clobbered (the opposite of `runGen`).
|
|
5
|
+
import { joinPath, readModels } from "./gen.js";
|
|
6
|
+
import { planWorkerScaffold, } from "./scaffold/workers.js";
|
|
7
|
+
/** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
|
|
8
|
+
export async function scaffoldWorkers(opts) {
|
|
9
|
+
const { root, io } = opts;
|
|
10
|
+
const write = opts.write ?? false;
|
|
11
|
+
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
12
|
+
const manifestPath = joinPath(root, manifestFile);
|
|
13
|
+
const manifest = JSON.parse(await io.readText(manifestPath));
|
|
14
|
+
const models = await readModels(root, io, manifest);
|
|
15
|
+
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
16
|
+
const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds);
|
|
17
|
+
const outcomes = [];
|
|
18
|
+
const wired = [];
|
|
19
|
+
for (const plan of plans) {
|
|
20
|
+
const abs = joinPath(root, plan.handlerPath);
|
|
21
|
+
const exists = await io.exists(abs);
|
|
22
|
+
let status;
|
|
23
|
+
if (exists) {
|
|
24
|
+
status = "kept"; // human-owned — never clobber
|
|
25
|
+
}
|
|
26
|
+
else if (write) {
|
|
27
|
+
await io.writeText(abs, plan.stub);
|
|
28
|
+
status = "created";
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
status = "would-create";
|
|
32
|
+
}
|
|
33
|
+
outcomes.push({
|
|
34
|
+
taskType: plan.taskType,
|
|
35
|
+
handlerPath: plan.handlerPath,
|
|
36
|
+
status,
|
|
37
|
+
typedIn: plan.typedIn,
|
|
38
|
+
typedOut: plan.typedOut,
|
|
39
|
+
});
|
|
40
|
+
// The planner already excluded already-wired task types, so every plan needs wiring —
|
|
41
|
+
// including an orphan stub file that exists on disk but isn't in the manifest yet.
|
|
42
|
+
wired.push(plan.manifestEntry);
|
|
43
|
+
}
|
|
44
|
+
let manifestPatched = false;
|
|
45
|
+
if (write && wired.length > 0) {
|
|
46
|
+
const withWorkers = {
|
|
47
|
+
...manifest,
|
|
48
|
+
workers: [...(manifest.workers ?? []), ...wired],
|
|
49
|
+
};
|
|
50
|
+
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, 2)}\n`);
|
|
51
|
+
manifestPatched = true;
|
|
52
|
+
}
|
|
53
|
+
return { outcomes, skipped, wired, manifestPatched, write };
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/cli.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
selectHost,
|
|
20
20
|
} from "./runtime/index.ts";
|
|
21
21
|
import { scaffold, slugify } from "create-urban-app";
|
|
22
|
-
import { createNodeGenIO, runGen } from "./toolkit/index.ts";
|
|
22
|
+
import { createNodeGenIO, runGen, scaffoldWorkers } from "./toolkit/index.ts";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
24
|
import { readFileSync } from "node:fs";
|
|
25
25
|
|
|
@@ -42,6 +42,7 @@ interface Flags {
|
|
|
42
42
|
manifest: string;
|
|
43
43
|
port?: number;
|
|
44
44
|
check: boolean;
|
|
45
|
+
write: boolean;
|
|
45
46
|
deno: boolean;
|
|
46
47
|
help: boolean;
|
|
47
48
|
version: boolean;
|
|
@@ -49,7 +50,7 @@ interface Flags {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function parse(argv: string[]): Flags {
|
|
52
|
-
const f: Flags = { root: ".", manifest: "nano.app.json", check: false, deno: false, help: false, version: false, _: [] };
|
|
53
|
+
const f: Flags = { root: ".", manifest: "nano.app.json", check: false, write: false, deno: false, help: false, version: false, _: [] };
|
|
53
54
|
const need = (i: number, flag: string): string => {
|
|
54
55
|
const v = argv[i];
|
|
55
56
|
if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
|
|
@@ -60,6 +61,7 @@ function parse(argv: string[]): Flags {
|
|
|
60
61
|
if (a === "-h" || a === "--help") f.help = true;
|
|
61
62
|
else if (a === "-v" || a === "--version") f.version = true;
|
|
62
63
|
else if (a === "--check") f.check = true;
|
|
64
|
+
else if (a === "--write") f.write = true;
|
|
63
65
|
else if (a === "--deno") f.deno = true;
|
|
64
66
|
else if (a === "--") continue; // tolerate a bare "--" some runners inject (not an option terminator here)
|
|
65
67
|
else if (a === "--root") f.root = need(++i, a);
|
|
@@ -83,6 +85,7 @@ Usage:
|
|
|
83
85
|
urban new <name> [--root <path>] [--deno] scaffold a new Urban app
|
|
84
86
|
urban check validate the manifest
|
|
85
87
|
urban gen [--check] derive artifacts (migrations, worker-io)
|
|
88
|
+
urban stubs [--write] scaffold write-once handler stubs from the model
|
|
86
89
|
urban run materialize + serve the app
|
|
87
90
|
urban dev run + watch sources, hot-reload on change
|
|
88
91
|
urban deploy deploy models only, then exit
|
|
@@ -129,6 +132,38 @@ async function cmdGen(f: Flags): Promise<number> {
|
|
|
129
132
|
return 0;
|
|
130
133
|
}
|
|
131
134
|
|
|
135
|
+
async function cmdStubs(f: Flags): Promise<number> {
|
|
136
|
+
const io = createNodeGenIO();
|
|
137
|
+
const run = await scaffoldWorkers({ root: f.root, io, manifestFile: f.manifest, write: f.write });
|
|
138
|
+
|
|
139
|
+
const created = run.outcomes.filter((o) => o.status === "created");
|
|
140
|
+
const would = run.outcomes.filter((o) => o.status === "would-create");
|
|
141
|
+
const kept = run.outcomes.filter((o) => o.status === "kept");
|
|
142
|
+
const typing = (o: { typedIn: boolean; typedOut: boolean }) =>
|
|
143
|
+
o.typedIn || o.typedOut ? ` (typed${o.typedIn ? " in" : ""}${o.typedOut ? " out" : ""})` : "";
|
|
144
|
+
|
|
145
|
+
if (run.write) {
|
|
146
|
+
console.log(`✔ scaffolded ${created.length} worker stub(s):`);
|
|
147
|
+
for (const o of created) console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
148
|
+
if (run.manifestPatched) {
|
|
149
|
+
console.log(`✔ wired ${run.wired.length} worker(s) into ${f.manifest}`);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
if (would.length === 0) {
|
|
153
|
+
console.log(`✔ no new worker stubs to scaffold`);
|
|
154
|
+
} else {
|
|
155
|
+
console.log(`Would scaffold ${would.length} worker stub(s) — run with --write to apply:`);
|
|
156
|
+
for (const o of would) console.log(` + ${o.handlerPath}${typing(o)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const o of kept) console.log(` = ${o.handlerPath} (kept — already exists)`);
|
|
160
|
+
if (run.skipped.length > 0) {
|
|
161
|
+
console.log(` skipped ${run.skipped.length}: ` +
|
|
162
|
+
run.skipped.map((s) => `${s.taskType} (${s.reason})`).join(", "));
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
132
167
|
async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number> {
|
|
133
168
|
const app = await runFromEnv({
|
|
134
169
|
root: f.root,
|
|
@@ -199,6 +234,8 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
199
234
|
return cmdCheck(f);
|
|
200
235
|
case "gen":
|
|
201
236
|
return cmdGen(f);
|
|
237
|
+
case "stubs":
|
|
238
|
+
return cmdStubs(f);
|
|
202
239
|
case "run":
|
|
203
240
|
return cmdRun(f);
|
|
204
241
|
case "dev":
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -83,13 +83,17 @@ export interface HostContext {
|
|
|
83
83
|
log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/** A job handed to a worker handler, plus the ack/fail callbacks.
|
|
87
|
-
|
|
86
|
+
/** A job handed to a worker handler, plus the ack/fail callbacks. `In` types the process
|
|
87
|
+
* variables carried on the job; it defaults to an open record when a handler doesn't
|
|
88
|
+
* declare a concrete input shape. The `object` bound (rather than `Record<string, unknown>`)
|
|
89
|
+
* lets plain `interface` shapes be used as `In` — interfaces lack the implicit index
|
|
90
|
+
* signature that a `Record` bound would demand. */
|
|
91
|
+
export interface EngineJob<In extends object = Record<string, unknown>> {
|
|
88
92
|
jobKey: string;
|
|
89
93
|
jobType: string;
|
|
90
94
|
processInstanceKey?: string;
|
|
91
95
|
elementId?: string;
|
|
92
|
-
variables:
|
|
96
|
+
variables: In;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
export type JobHandler = (
|
|
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
4
|
import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
|
|
5
5
|
import type { AppManifest } from "../manifest.ts";
|
|
6
|
-
import { mountWorkers, sdkDecisionEvaluator } from "./workers.ts";
|
|
6
|
+
import { mountWorkers, sdkDecisionEvaluator, type AppJobHandler } from "./workers.ts";
|
|
7
7
|
|
|
8
8
|
/** A tiny engine that records registrations and can deliver a job to a handler. */
|
|
9
9
|
class MiniEngine {
|
|
@@ -156,3 +156,55 @@ test("sdkDecisionEvaluator throws a clear error when the SDK lacks evaluateDecis
|
|
|
156
156
|
const evaluate = sdkDecisionEvaluator({});
|
|
157
157
|
await assert.rejects(evaluate("risk", {}), /does not support evaluateDecision.*"risk"/);
|
|
158
158
|
});
|
|
159
|
+
|
|
160
|
+
// ── AppJobHandler generics: optional In/Out type parameters ──────────────────────────────
|
|
161
|
+
// These are primarily compile-time assertions (the suite is typechecked by `tsc --noEmit`),
|
|
162
|
+
// with a runtime smoke that a typed handler still returns its output when delivered a job.
|
|
163
|
+
test("AppJobHandler carries optional In/Out variable types", async () => {
|
|
164
|
+
// `In` is a plain interface — the `object` bound (not `Record<string, unknown>`) makes this
|
|
165
|
+
// work, since interfaces have no implicit index signature.
|
|
166
|
+
interface In {
|
|
167
|
+
prKey: string;
|
|
168
|
+
round: number;
|
|
169
|
+
summary?: string;
|
|
170
|
+
}
|
|
171
|
+
interface Out {
|
|
172
|
+
ok: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// In + Out: job.variables is typed as In, the return must be Out (or void).
|
|
176
|
+
const typed: AppJobHandler<In, Out> = async (job) => {
|
|
177
|
+
const key: string = job.variables.prKey; // typed access compiles
|
|
178
|
+
const n: number = job.variables.round;
|
|
179
|
+
return { ok: key.length > 0 && n >= 0 };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// In only: Out defaults to an open variables map, so any plain object of completion
|
|
183
|
+
// variables may be returned.
|
|
184
|
+
const inOnly: AppJobHandler<In> = (job) => ({ echoed: job.variables.prKey });
|
|
185
|
+
|
|
186
|
+
// No parameters: fully open — the pre-generics behaviour.
|
|
187
|
+
const open: AppJobHandler = (job) => ({ count: Object.keys(job.variables).length });
|
|
188
|
+
|
|
189
|
+
// Returning nothing (void) is allowed under any parameterisation.
|
|
190
|
+
const voidReturn: AppJobHandler<In> = () => {};
|
|
191
|
+
|
|
192
|
+
const job: EngineJob<In> = {
|
|
193
|
+
jobKey: "1",
|
|
194
|
+
jobType: "pr.finalize",
|
|
195
|
+
variables: { prKey: "p1", round: 2 },
|
|
196
|
+
};
|
|
197
|
+
// The open handler defaults `In` to a record; interface-typed jobs aren't assignable to it,
|
|
198
|
+
// so hand it a plainly-typed job (the pre-generics shape).
|
|
199
|
+
const openJob: EngineJob = {
|
|
200
|
+
jobKey: "2",
|
|
201
|
+
jobType: "pr.finalize",
|
|
202
|
+
variables: { prKey: "p1", round: 2 },
|
|
203
|
+
};
|
|
204
|
+
const app = {} as AppApi;
|
|
205
|
+
|
|
206
|
+
assert.deepEqual(await typed(job, app), { ok: true });
|
|
207
|
+
assert.deepEqual(await inOnly(job, app), { echoed: "p1" });
|
|
208
|
+
assert.deepEqual(await open(openJob, app), { count: 2 });
|
|
209
|
+
assert.equal(await voidReturn(job, app), undefined);
|
|
210
|
+
});
|
|
@@ -42,11 +42,26 @@ export function sdkDecisionEvaluator(sdk: unknown): DecisionEvaluator {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
/**
|
|
46
|
+
* A handler as authored by an app: the job plus the injected app API.
|
|
47
|
+
*
|
|
48
|
+
* Both type parameters are optional:
|
|
49
|
+
* - `In` types `job.variables` (the process variables the job carries).
|
|
50
|
+
* - `Out` types the completion **variables map** the handler returns to the engine.
|
|
51
|
+
* Each defaults to an open `Record<string, unknown>`, so `AppJobHandler`,
|
|
52
|
+
* `AppJobHandler<In>`, and `AppJobHandler<In, Out>` are all valid.
|
|
53
|
+
*
|
|
54
|
+
* The bound is `extends object` (not `Record<string, unknown>`) so a plain `interface In {…}`
|
|
55
|
+
* / `interface Out {…}` can be supplied — interfaces have no implicit index signature and so
|
|
56
|
+
* don't satisfy a `Record` bound. The looser bound means `Out` is not *forced* to be a plain
|
|
57
|
+
* variables map at the type level; authors are expected to return a JSON-object of completion
|
|
58
|
+
* variables (not a `Date`, `Map`, array, etc.), which is what the engine sends on job
|
|
59
|
+
* completion. Both parameters are erased at runtime — they only shape authoring-time types.
|
|
60
|
+
*/
|
|
61
|
+
export type AppJobHandler<In extends object = Record<string, unknown>, Out extends object = Record<string, unknown>> = (
|
|
62
|
+
job: EngineJob<In>,
|
|
48
63
|
app: AppApi,
|
|
49
|
-
) => Promise<
|
|
64
|
+
) => Promise<Out | void> | Out | void;
|
|
50
65
|
|
|
51
66
|
/**
|
|
52
67
|
* Resolve a handler for `jobType` from a loaded module, in priority order:
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -57,6 +57,22 @@ async function expandPattern(root: string, io: GenIO, pattern: string): Promise<
|
|
|
57
57
|
.sort();
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Read + resolve the app's process models from the manifest's `models.processes` patterns. */
|
|
61
|
+
export async function readModels(
|
|
62
|
+
root: string,
|
|
63
|
+
io: GenIO,
|
|
64
|
+
manifest: { models?: { processes?: string[] } },
|
|
65
|
+
): Promise<ModelSource[]> {
|
|
66
|
+
const procPatterns = manifest.models?.processes ?? [];
|
|
67
|
+
const models: ModelSource[] = [];
|
|
68
|
+
for (const pat of procPatterns) {
|
|
69
|
+
for (const rel of await expandPattern(root, io, pat)) {
|
|
70
|
+
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return models;
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
/** Collect all artifacts a run would produce, without touching disk beyond reads. */
|
|
61
77
|
export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifact[]> {
|
|
62
78
|
const { root, io } = opts;
|
|
@@ -73,13 +89,7 @@ export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifac
|
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
// 2. models → worker I/O index
|
|
76
|
-
const
|
|
77
|
-
const models: ModelSource[] = [];
|
|
78
|
-
for (const pat of procPatterns) {
|
|
79
|
-
for (const rel of await expandPattern(root, io, pat)) {
|
|
80
|
-
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
81
|
-
}
|
|
82
|
-
}
|
|
92
|
+
const models = await readModels(root, io, manifest);
|
|
83
93
|
if (models.length > 0) {
|
|
84
94
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
85
95
|
artifacts.push(...deriveWorkerBindings(models, declaredTypeIds));
|
package/src/toolkit/index.ts
CHANGED
|
@@ -32,6 +32,23 @@ import { workerIoDeriver } from "./derivers/worker-io.ts";
|
|
|
32
32
|
export const DERIVERS = [migrationsDeriver, workerIoDeriver] as const;
|
|
33
33
|
|
|
34
34
|
// Gen orchestrator + IO
|
|
35
|
-
export { collectArtifacts, runGen, joinPath } from "./gen.ts";
|
|
35
|
+
export { collectArtifacts, runGen, joinPath, readModels } from "./gen.ts";
|
|
36
36
|
export type { GenIO, GenOptions, GenResult } from "./gen.ts";
|
|
37
37
|
export { createNodeGenIO } from "./fsio.ts";
|
|
38
|
+
|
|
39
|
+
// Worker-stub scaffolder (ADR 0056): write-once handler stubs from the model.
|
|
40
|
+
export {
|
|
41
|
+
planWorkerScaffold,
|
|
42
|
+
renderWorkerStub,
|
|
43
|
+
slugifyTaskType,
|
|
44
|
+
} from "./scaffold/workers.ts";
|
|
45
|
+
export type {
|
|
46
|
+
ScaffoldWorker,
|
|
47
|
+
SkippedWorker,
|
|
48
|
+
SkipReason,
|
|
49
|
+
StubManifestEntry,
|
|
50
|
+
WorkerScaffoldPlan,
|
|
51
|
+
WorkerStubPlan,
|
|
52
|
+
} from "./scaffold/workers.ts";
|
|
53
|
+
export { scaffoldWorkers } from "./scaffold.ts";
|
|
54
|
+
export type { ScaffoldOptions, ScaffoldRun, StubOutcome, StubStatus } from "./scaffold.ts";
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
planWorkerScaffold,
|
|
5
|
+
renderWorkerStub,
|
|
6
|
+
slugifyTaskType,
|
|
7
|
+
type ScaffoldWorker,
|
|
8
|
+
} from "./workers.ts";
|
|
9
|
+
import type { ModelSource } from "../derivers/worker-io.ts";
|
|
10
|
+
import { scaffoldWorkers } from "../scaffold.ts";
|
|
11
|
+
import type { GenIO } from "../gen.ts";
|
|
12
|
+
|
|
13
|
+
// A service task with a data-envelope in/out contract; `type` is the taskType, and the
|
|
14
|
+
// io.nanobpm.dataEnvelope.in/out zeebe:property carry the envelope type ids.
|
|
15
|
+
function serviceTask(id: string, taskType: string, inType?: string, outType?: string): string {
|
|
16
|
+
const props = [
|
|
17
|
+
inType ? `<zeebe:property name="io.nanobpm.dataEnvelope.in" value="${inType}" />` : "",
|
|
18
|
+
outType ? `<zeebe:property name="io.nanobpm.dataEnvelope.out" value="${outType}" />` : "",
|
|
19
|
+
].join("");
|
|
20
|
+
return `<bpmn:serviceTask id="${id}"><bpmn:extensionElements>
|
|
21
|
+
<zeebe:taskDefinition type="${taskType}" />
|
|
22
|
+
${props ? `<zeebe:taskHeaders>${props}</zeebe:taskHeaders>` : ""}
|
|
23
|
+
</bpmn:extensionElements></bpmn:serviceTask>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function model(path: string, procId: string, ...tasks: string[]): ModelSource {
|
|
27
|
+
return {
|
|
28
|
+
path,
|
|
29
|
+
xml: `<bpmn:process id="${procId}" xmlns:bpmn="x" xmlns:zeebe="y">
|
|
30
|
+
${tasks.join("\n")}
|
|
31
|
+
</bpmn:process>`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("slugifyTaskType makes a filesystem-safe slug", () => {
|
|
36
|
+
assert.equal(slugifyTaskType("pr.finalize"), "pr-finalize");
|
|
37
|
+
assert.equal(slugifyTaskType("Orders::Charge Card!"), "orders-charge-card");
|
|
38
|
+
assert.equal(slugifyTaskType("---"), "worker");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("typed in+out stub imports both symbols and parameterises the handler", () => {
|
|
42
|
+
const stub = renderWorkerStub("pr.finalize", true, true);
|
|
43
|
+
assert.match(stub, /import type \{ WorkerInputs, WorkerOutputs \} from "\.\.\/\.\.\/nano-generated\/worker-io\.d\.ts";/);
|
|
44
|
+
assert.match(stub, /AppJobHandler<WorkerInputs\["pr\.finalize"\], WorkerOutputs\["pr\.finalize"\]>/);
|
|
45
|
+
assert.match(stub, /export default handler;/);
|
|
46
|
+
assert.match(stub, /throw new Error\("worker not implemented: pr\.finalize"\)/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("untyped stub imports no generated types and uses bare AppJobHandler", () => {
|
|
50
|
+
const stub = renderWorkerStub("demo.do", false, false);
|
|
51
|
+
assert.doesNotMatch(stub, /worker-io\.d\.ts/);
|
|
52
|
+
assert.match(stub, /const handler: AppJobHandler = async \(job, app\) =>/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("typed-out-only stub fills In with the open default", () => {
|
|
56
|
+
const stub = renderWorkerStub("demo.out", false, true);
|
|
57
|
+
assert.match(stub, /import type \{ WorkerOutputs \} from/);
|
|
58
|
+
assert.match(stub, /AppJobHandler<Record<string, unknown>, WorkerOutputs\["demo\.out"\]>/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("planWorkerScaffold: typed vs untyped from declared types", () => {
|
|
62
|
+
const m = model(
|
|
63
|
+
"a.bpmn",
|
|
64
|
+
"orders",
|
|
65
|
+
serviceTask("A", "orders.charge", "ChargeIn", "ChargeOut"),
|
|
66
|
+
serviceTask("B", "orders.ship"),
|
|
67
|
+
);
|
|
68
|
+
const { plans } = planWorkerScaffold([m], [], ["ChargeIn", "ChargeOut"]);
|
|
69
|
+
assert.equal(plans.length, 2);
|
|
70
|
+
const charge = plans.find((p) => p.taskType === "orders.charge")!;
|
|
71
|
+
assert.equal(charge.typedIn, true);
|
|
72
|
+
assert.equal(charge.typedOut, true);
|
|
73
|
+
assert.equal(charge.handlerPath, "workers/orders-charge/worker.ts");
|
|
74
|
+
assert.deepEqual(charge.manifestEntry, { taskType: "orders.charge", handler: "workers/orders-charge/worker.ts" });
|
|
75
|
+
const ship = plans.find((p) => p.taskType === "orders.ship")!;
|
|
76
|
+
assert.equal(ship.typedIn, false);
|
|
77
|
+
assert.equal(ship.typedOut, false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("planWorkerScaffold: an in/out that is not a declared type stays untyped", () => {
|
|
81
|
+
const m = model("a.bpmn", "p", serviceTask("A", "t.a", "Undeclared", "AlsoUndeclared"));
|
|
82
|
+
const { plans } = planWorkerScaffold([m], [], ["SomethingElse"]);
|
|
83
|
+
assert.equal(plans[0].typedIn, false);
|
|
84
|
+
assert.equal(plans[0].typedOut, false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("planWorkerScaffold: skips already-wired, orchestrator, and duplicates", () => {
|
|
88
|
+
const wired: ScaffoldWorker[] = [{ taskType: "t.wired", handler: "workers/x/worker.ts" }];
|
|
89
|
+
const models = [
|
|
90
|
+
model("a.bpmn", "p1", serviceTask("A", "t.wired"), serviceTask("B", "t.new"), serviceTask("O", "wf:__orchestrate")),
|
|
91
|
+
model("b.bpmn", "p2", serviceTask("C", "t.new")), // duplicate taskType across models
|
|
92
|
+
];
|
|
93
|
+
const { plans, skipped } = planWorkerScaffold(models, wired, []);
|
|
94
|
+
assert.deepEqual(plans.map((p) => p.taskType), ["t.new"]);
|
|
95
|
+
const reasons = Object.fromEntries(skipped.map((s) => [s.taskType, s.reason]));
|
|
96
|
+
assert.equal(reasons["t.wired"], "already-wired");
|
|
97
|
+
assert.equal(reasons["wf:__orchestrate"], "orchestrator");
|
|
98
|
+
assert.equal(reasons["t.new"], "duplicate");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("planWorkerScaffold: colliding slugs get a numeric suffix", () => {
|
|
102
|
+
const m = model("a.bpmn", "p", serviceTask("A", "orders.charge"), serviceTask("B", "orders-charge"));
|
|
103
|
+
const { plans } = planWorkerScaffold([m], [], []);
|
|
104
|
+
const slugs = plans.map((p) => p.slug).sort();
|
|
105
|
+
assert.deepEqual(slugs, ["orders-charge", "orders-charge-2"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── runner (impure edge, in-memory IO) ──────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function memIO(files: Record<string, string>): GenIO & { files: Record<string, string> } {
|
|
111
|
+
return {
|
|
112
|
+
files,
|
|
113
|
+
async readText(p) {
|
|
114
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
115
|
+
return files[p];
|
|
116
|
+
},
|
|
117
|
+
async writeText(p, c) {
|
|
118
|
+
files[p] = c;
|
|
119
|
+
},
|
|
120
|
+
async listDir(p) {
|
|
121
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
122
|
+
const names = new Set<string>();
|
|
123
|
+
for (const f of Object.keys(files)) {
|
|
124
|
+
if (f.startsWith(prefix)) {
|
|
125
|
+
const rest = f.slice(prefix.length);
|
|
126
|
+
if (!rest.includes("/")) names.add(rest);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...names];
|
|
130
|
+
},
|
|
131
|
+
async exists(p) {
|
|
132
|
+
return p in files;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function appFixture(): Record<string, string> {
|
|
138
|
+
return {
|
|
139
|
+
"/app/nano.app.json": JSON.stringify({
|
|
140
|
+
id: "demo",
|
|
141
|
+
models: { processes: ["processes/*.bpmn"] },
|
|
142
|
+
workers: [{ taskType: "t.wired", handler: "workers/wired/worker.ts" }],
|
|
143
|
+
}),
|
|
144
|
+
"/app/processes/p.bpmn": model("p.bpmn", "p",
|
|
145
|
+
serviceTask("A", "t.new"),
|
|
146
|
+
serviceTask("B", "t.wired"),
|
|
147
|
+
).xml,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test("scaffoldWorkers dry-run writes nothing and reports would-create", async () => {
|
|
152
|
+
const io = memIO(appFixture());
|
|
153
|
+
const before = Object.keys(io.files).length;
|
|
154
|
+
const run = await scaffoldWorkers({ root: "/app", io });
|
|
155
|
+
assert.equal(run.write, false);
|
|
156
|
+
assert.equal(Object.keys(io.files).length, before, "dry-run must not write");
|
|
157
|
+
assert.deepEqual(run.outcomes.map((o) => o.status), ["would-create"]);
|
|
158
|
+
assert.equal(run.outcomes[0].taskType, "t.new");
|
|
159
|
+
assert.equal(run.manifestPatched, false);
|
|
160
|
+
assert.ok(run.skipped.some((s) => s.taskType === "t.wired" && s.reason === "already-wired"));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("scaffoldWorkers --write creates the stub and patches the manifest once", async () => {
|
|
164
|
+
const io = memIO(appFixture());
|
|
165
|
+
const run = await scaffoldWorkers({ root: "/app", io, write: true });
|
|
166
|
+
assert.equal(run.outcomes[0].status, "created");
|
|
167
|
+
const stub = io.files["/app/workers/t-new/worker.ts"];
|
|
168
|
+
assert.ok(stub, "stub file written");
|
|
169
|
+
assert.match(stub, /worker not implemented: t\.new/);
|
|
170
|
+
const manifest = JSON.parse(io.files["/app/nano.app.json"]);
|
|
171
|
+
assert.deepEqual(manifest.workers.map((w: { taskType: string }) => w.taskType), ["t.wired", "t.new"]);
|
|
172
|
+
assert.ok(io.files["/app/nano.app.json"].endsWith("\n"), "manifest ends with newline");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("scaffoldWorkers never clobbers an existing stub (keeps it), still wires it", async () => {
|
|
176
|
+
const files = appFixture();
|
|
177
|
+
files["/app/workers/t-new/worker.ts"] = "// hand-edited, do not touch\n";
|
|
178
|
+
const io = memIO(files);
|
|
179
|
+
const run = await scaffoldWorkers({ root: "/app", io, write: true });
|
|
180
|
+
assert.equal(run.outcomes[0].status, "kept");
|
|
181
|
+
assert.equal(io.files["/app/workers/t-new/worker.ts"], "// hand-edited, do not touch\n");
|
|
182
|
+
const manifest = JSON.parse(io.files["/app/nano.app.json"]);
|
|
183
|
+
assert.ok(manifest.workers.some((w: { taskType: string }) => w.taskType === "t.new"), "orphan stub gets wired");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("scaffoldWorkers is idempotent: a second --write run is a no-op", async () => {
|
|
187
|
+
const io = memIO(appFixture());
|
|
188
|
+
await scaffoldWorkers({ root: "/app", io, write: true });
|
|
189
|
+
const snapshot = JSON.stringify(io.files);
|
|
190
|
+
const run2 = await scaffoldWorkers({ root: "/app", io, write: true });
|
|
191
|
+
// t.new is now wired in the manifest, so the planner skips it entirely (nothing to do).
|
|
192
|
+
assert.deepEqual(run2.outcomes, []);
|
|
193
|
+
assert.ok(run2.skipped.some((s) => s.taskType === "t.new" && s.reason === "already-wired"));
|
|
194
|
+
assert.equal(run2.manifestPatched, false, "no new wiring on the second run");
|
|
195
|
+
assert.equal(JSON.stringify(io.files), snapshot, "files unchanged on re-run");
|
|
196
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Pure planner for the write-once worker-stub scaffolder (ADR 0056). Given the app's models,
|
|
2
|
+
// its manifest workers, and the set of declared domain type ids, it plans one typed handler
|
|
3
|
+
// stub per un-wired service task — the ONE part of an Urban app that cannot be derived from
|
|
4
|
+
// the model (the handler *body* is human logic). Everything here is pure and testable like a
|
|
5
|
+
// deriver; the impure write-if-absent edge lives in `../scaffold.ts`.
|
|
6
|
+
//
|
|
7
|
+
// scaffold ≠ derive: stubs are human-owned files under `workers/<slug>/worker.ts`, so they are
|
|
8
|
+
// planned once and never overwritten — unlike the derived `nano-generated/` tree.
|
|
9
|
+
|
|
10
|
+
import { scanModelWorkers, type ModelSource } from "../derivers/worker-io.ts";
|
|
11
|
+
|
|
12
|
+
/** A worker to plan against: the manifest's already-wired workers. */
|
|
13
|
+
export interface ScaffoldWorker {
|
|
14
|
+
taskType: string;
|
|
15
|
+
handler?: string;
|
|
16
|
+
llm?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The `workers[]` manifest entry a stub is wired in as. */
|
|
20
|
+
export interface StubManifestEntry {
|
|
21
|
+
taskType: string;
|
|
22
|
+
handler: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One planned handler stub. */
|
|
26
|
+
export interface WorkerStubPlan {
|
|
27
|
+
taskType: string;
|
|
28
|
+
process?: string;
|
|
29
|
+
elementId?: string;
|
|
30
|
+
/** Directory slug under `workers/` (unique across the plan). */
|
|
31
|
+
slug: string;
|
|
32
|
+
/** App-relative handler path: `workers/<slug>/worker.ts`. */
|
|
33
|
+
handlerPath: string;
|
|
34
|
+
/** True when the data-envelope `in`/`out` names a declared domain type (so it is typed). */
|
|
35
|
+
typedIn: boolean;
|
|
36
|
+
typedOut: boolean;
|
|
37
|
+
/** The stub file contents. */
|
|
38
|
+
stub: string;
|
|
39
|
+
manifestEntry: StubManifestEntry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type SkipReason = "already-wired" | "orchestrator" | "duplicate";
|
|
43
|
+
|
|
44
|
+
export interface SkippedWorker {
|
|
45
|
+
taskType: string;
|
|
46
|
+
reason: SkipReason;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkerScaffoldPlan {
|
|
50
|
+
plans: WorkerStubPlan[];
|
|
51
|
+
skipped: SkippedWorker[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Suffix the imperative orchestrator task type carries (ADR 0054 / defineWorkflow). */
|
|
55
|
+
const ORCHESTRATOR_SUFFIX = ":__orchestrate";
|
|
56
|
+
|
|
57
|
+
/** Relative import from `workers/<slug>/worker.ts` to the generated worker-io types. */
|
|
58
|
+
const GENERATED_IMPORT = "../../nano-generated/worker-io.d.ts";
|
|
59
|
+
|
|
60
|
+
/** Turn a task type into a filesystem-safe directory slug. */
|
|
61
|
+
export function slugifyTaskType(taskType: string): string {
|
|
62
|
+
const s = taskType
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
65
|
+
.replace(/^-+|-+$/g, "");
|
|
66
|
+
return s.length > 0 ? s : "worker";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Render a stub matching a real hand-authored worker (default-exported AppJobHandler). */
|
|
70
|
+
export function renderWorkerStub(
|
|
71
|
+
taskType: string,
|
|
72
|
+
typedIn: boolean,
|
|
73
|
+
typedOut: boolean,
|
|
74
|
+
): string {
|
|
75
|
+
const key = JSON.stringify(taskType); // e.g. "pr.finalize" — matches the deriver's property keys
|
|
76
|
+
const syms: string[] = [];
|
|
77
|
+
if (typedIn) syms.push("WorkerInputs");
|
|
78
|
+
if (typedOut) syms.push("WorkerOutputs");
|
|
79
|
+
|
|
80
|
+
const generatedImport = syms.length > 0
|
|
81
|
+
? `import type { ${syms.join(", ")} } from ${JSON.stringify(GENERATED_IMPORT)};\n`
|
|
82
|
+
: "";
|
|
83
|
+
|
|
84
|
+
let generic = "";
|
|
85
|
+
if (typedIn && typedOut) generic = `<WorkerInputs[${key}], WorkerOutputs[${key}]>`;
|
|
86
|
+
else if (typedIn) generic = `<WorkerInputs[${key}]>`;
|
|
87
|
+
else if (typedOut) generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
|
|
88
|
+
|
|
89
|
+
const varsNote = typedIn
|
|
90
|
+
? ` // \`job.variables\` is typed as WorkerInputs[${key}].`
|
|
91
|
+
: ` // \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
|
|
95
|
+
`// This file is yours to edit — \`urban stubs\` will never overwrite it. Implement the body\n` +
|
|
96
|
+
`// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
|
|
97
|
+
`import type { AppJobHandler } from "@nanobpm/urban";\n` +
|
|
98
|
+
generatedImport +
|
|
99
|
+
`\n` +
|
|
100
|
+
`const handler: AppJobHandler${generic} = async (job, app) => {\n` +
|
|
101
|
+
varsNote +
|
|
102
|
+
`\n` +
|
|
103
|
+
` // Reach app state and services through the injected \`app\` API.\n` +
|
|
104
|
+
` throw new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
|
|
105
|
+
`};\n` +
|
|
106
|
+
`\n` +
|
|
107
|
+
`export default handler;\n`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Plan write-once handler stubs from the models. A stub is planned for each service-task
|
|
113
|
+
* `taskType` that is NOT already wired in the manifest, NOT the imperative orchestrator, and
|
|
114
|
+
* not a duplicate of one already planned. `declaredTypeIds` are the manifest `types` ids — an
|
|
115
|
+
* envelope `in`/`out` is typed only when it names one (matching the worker-io deriver).
|
|
116
|
+
*/
|
|
117
|
+
export function planWorkerScaffold(
|
|
118
|
+
models: ModelSource[],
|
|
119
|
+
workers: ScaffoldWorker[] = [],
|
|
120
|
+
declaredTypeIds: Iterable<string> = [],
|
|
121
|
+
): WorkerScaffoldPlan {
|
|
122
|
+
const wired = new Set(
|
|
123
|
+
workers
|
|
124
|
+
.map((w) => w?.taskType)
|
|
125
|
+
.filter((t): t is string => typeof t === "string" && t.length > 0),
|
|
126
|
+
);
|
|
127
|
+
const declared = new Set(declaredTypeIds);
|
|
128
|
+
|
|
129
|
+
const plans: WorkerStubPlan[] = [];
|
|
130
|
+
const skipped: SkippedWorker[] = [];
|
|
131
|
+
const seenTaskTypes = new Set<string>();
|
|
132
|
+
const usedSlugs = new Set<string>();
|
|
133
|
+
|
|
134
|
+
for (const model of [...models].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
135
|
+
for (const io of scanModelWorkers(model.xml)) {
|
|
136
|
+
const taskType = io.taskType;
|
|
137
|
+
if (taskType.endsWith(ORCHESTRATOR_SUFFIX)) {
|
|
138
|
+
skipped.push({ taskType, reason: "orchestrator" });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (wired.has(taskType)) {
|
|
142
|
+
skipped.push({ taskType, reason: "already-wired" });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (seenTaskTypes.has(taskType)) {
|
|
146
|
+
skipped.push({ taskType, reason: "duplicate" });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
seenTaskTypes.add(taskType);
|
|
150
|
+
|
|
151
|
+
let slug = slugifyTaskType(taskType);
|
|
152
|
+
if (usedSlugs.has(slug)) {
|
|
153
|
+
let n = 2;
|
|
154
|
+
while (usedSlugs.has(`${slug}-${n}`)) n++;
|
|
155
|
+
slug = `${slug}-${n}`;
|
|
156
|
+
}
|
|
157
|
+
usedSlugs.add(slug);
|
|
158
|
+
|
|
159
|
+
const typedIn = io.in != null && declared.has(io.in);
|
|
160
|
+
const typedOut = io.out != null && declared.has(io.out);
|
|
161
|
+
const handlerPath = `workers/${slug}/worker.ts`;
|
|
162
|
+
|
|
163
|
+
plans.push({
|
|
164
|
+
taskType,
|
|
165
|
+
process: io.process,
|
|
166
|
+
elementId: io.elementId,
|
|
167
|
+
slug,
|
|
168
|
+
handlerPath,
|
|
169
|
+
typedIn,
|
|
170
|
+
typedOut,
|
|
171
|
+
stub: renderWorkerStub(taskType, typedIn, typedOut),
|
|
172
|
+
manifestEntry: { taskType, handler: handlerPath },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { plans, skipped };
|
|
178
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The stub-scaffolder's one impure edge (ADR 0056): read the manifest + models, run the pure
|
|
2
|
+
// planner, and — write-if-absent only — create each handler stub and wire it into the manifest.
|
|
3
|
+
// Dry-run by default (`write: false`); nothing is touched unless `write` is set. Stubs are
|
|
4
|
+
// human-owned, so an existing file is KEPT verbatim, never clobbered (the opposite of `runGen`).
|
|
5
|
+
|
|
6
|
+
import type { GenIO } from "./gen.ts";
|
|
7
|
+
import { joinPath, readModels } from "./gen.ts";
|
|
8
|
+
import {
|
|
9
|
+
planWorkerScaffold,
|
|
10
|
+
type ScaffoldWorker,
|
|
11
|
+
type SkippedWorker,
|
|
12
|
+
type StubManifestEntry,
|
|
13
|
+
} from "./scaffold/workers.ts";
|
|
14
|
+
|
|
15
|
+
/** The manifest fields the scaffolder reads. */
|
|
16
|
+
interface ScaffoldManifest {
|
|
17
|
+
types?: Record<string, unknown>;
|
|
18
|
+
models?: { processes?: string[] };
|
|
19
|
+
workers?: ScaffoldWorker[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScaffoldOptions {
|
|
23
|
+
root: string;
|
|
24
|
+
io: GenIO;
|
|
25
|
+
manifestFile?: string;
|
|
26
|
+
/** Apply changes (create files + patch manifest). Default false = dry-run. */
|
|
27
|
+
write?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type StubStatus = "created" | "would-create" | "kept";
|
|
31
|
+
|
|
32
|
+
export interface StubOutcome {
|
|
33
|
+
taskType: string;
|
|
34
|
+
handlerPath: string;
|
|
35
|
+
status: StubStatus;
|
|
36
|
+
typedIn: boolean;
|
|
37
|
+
typedOut: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ScaffoldRun {
|
|
41
|
+
outcomes: StubOutcome[];
|
|
42
|
+
skipped: SkippedWorker[];
|
|
43
|
+
/**
|
|
44
|
+
* Manifest entries that would be appended to `workers[]` — one per planned stub, so this is
|
|
45
|
+
* populated on a dry-run too (letting a caller preview the wiring). They are actually written
|
|
46
|
+
* to the manifest only when `write` is set (see `manifestPatched`).
|
|
47
|
+
*/
|
|
48
|
+
wired: StubManifestEntry[];
|
|
49
|
+
manifestPatched: boolean;
|
|
50
|
+
write: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
|
|
54
|
+
export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRun> {
|
|
55
|
+
const { root, io } = opts;
|
|
56
|
+
const write = opts.write ?? false;
|
|
57
|
+
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
58
|
+
const manifestPath = joinPath(root, manifestFile);
|
|
59
|
+
|
|
60
|
+
const manifest = JSON.parse(await io.readText(manifestPath)) as ScaffoldManifest;
|
|
61
|
+
const models = await readModels(root, io, manifest);
|
|
62
|
+
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
63
|
+
|
|
64
|
+
const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds);
|
|
65
|
+
|
|
66
|
+
const outcomes: StubOutcome[] = [];
|
|
67
|
+
const wired: StubManifestEntry[] = [];
|
|
68
|
+
|
|
69
|
+
for (const plan of plans) {
|
|
70
|
+
const abs = joinPath(root, plan.handlerPath);
|
|
71
|
+
const exists = await io.exists(abs);
|
|
72
|
+
let status: StubStatus;
|
|
73
|
+
if (exists) {
|
|
74
|
+
status = "kept"; // human-owned — never clobber
|
|
75
|
+
} else if (write) {
|
|
76
|
+
await io.writeText(abs, plan.stub);
|
|
77
|
+
status = "created";
|
|
78
|
+
} else {
|
|
79
|
+
status = "would-create";
|
|
80
|
+
}
|
|
81
|
+
outcomes.push({
|
|
82
|
+
taskType: plan.taskType,
|
|
83
|
+
handlerPath: plan.handlerPath,
|
|
84
|
+
status,
|
|
85
|
+
typedIn: plan.typedIn,
|
|
86
|
+
typedOut: plan.typedOut,
|
|
87
|
+
});
|
|
88
|
+
// The planner already excluded already-wired task types, so every plan needs wiring —
|
|
89
|
+
// including an orphan stub file that exists on disk but isn't in the manifest yet.
|
|
90
|
+
wired.push(plan.manifestEntry);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let manifestPatched = false;
|
|
94
|
+
if (write && wired.length > 0) {
|
|
95
|
+
const withWorkers = {
|
|
96
|
+
...manifest,
|
|
97
|
+
workers: [...(manifest.workers ?? []), ...wired],
|
|
98
|
+
};
|
|
99
|
+
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, 2)}\n`);
|
|
100
|
+
manifestPatched = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { outcomes, skipped, wired, manifestPatched, write };
|
|
104
|
+
}
|