refactor(daemon): workflows must be bundled to dist/, daemon only loads dist/index.js

- workflow-worker: loadWorkflowDefinition only looks for dist/index.js
- file-watcher: watch workflows/*/dist/**/*.js instead of *.ts
- file-watcher: sense watch uses .js regex pattern
- nerve create workflow: scaffold includes package.json with esbuild build script
- Updated all related tests

Fixes #219
This commit is contained in:
2026-04-28 05:25:38 +00:00
parent bda0c69261
commit e56a01d88a
10 changed files with 119 additions and 62 deletions
@@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildWorkflowScaffold } from "../commands/create.js";
import { buildWorkflowPackageJson, buildWorkflowScaffold } from "../commands/create.js";
let tmpDir: string;
@@ -75,6 +75,21 @@ describe("buildWorkflowScaffold", () => {
const { roleMainPromptMd } = buildWorkflowScaffold("my-flow");
expect(roleMainPromptMd).toContain("# my-flow — main role");
});
it("package.json defines esbuild bundling to dist/", () => {
const pkg = JSON.parse(buildWorkflowPackageJson("my-flow")) as {
scripts: { build: string };
devDependencies: { esbuild: string };
};
expect(pkg.scripts.build).toContain("esbuild");
expect(pkg.scripts.build).toContain("--outdir=dist");
expect(pkg.devDependencies.esbuild).toBeTruthy();
});
it("buildWorkflowScaffold includes package.json body", () => {
const { packageJson } = buildWorkflowScaffold("wf");
expect(JSON.parse(packageJson).scripts.build).toContain("esbuild");
});
});
describe("workflow scaffold file writing (simulated)", () => {
+21 -14
View File
@@ -121,23 +121,30 @@ describe("e2e create", () => {
}
});
it("create workflow scaffolds index.ts", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
it(
"create workflow scaffolds sources and package.json with esbuild build",
{ timeout: 10_000 },
async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
expect(wf.exitCode).toBe(0);
expect(wf.stdout).toContain("✅");
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
expect(wf.exitCode).toBe(0);
expect(wf.stdout).toContain("✅");
const indexPath = join(nerveRoot, "workflows", "e2e-flow", "index.ts");
const mainRolePath = join(nerveRoot, "workflows", "e2e-flow", "roles", "main", "index.ts");
expect(existsSync(indexPath)).toBe(true);
expect(existsSync(mainRolePath)).toBe(true);
expect(readFileSync(indexPath, "utf8")).toContain('name: "e2e-flow"');
expect(readFileSync(mainRolePath, "utf8")).toContain("e2e-flow started");
});
const pkgPath = join(nerveRoot, "workflows", "e2e-flow", "package.json");
const indexPath = join(nerveRoot, "workflows", "e2e-flow", "index.ts");
const mainRolePath = join(nerveRoot, "workflows", "e2e-flow", "roles", "main", "index.ts");
expect(existsSync(pkgPath)).toBe(true);
expect(JSON.parse(readFileSync(pkgPath, "utf8")).scripts.build).toContain("esbuild");
expect(existsSync(indexPath)).toBe(true);
expect(existsSync(mainRolePath)).toBe(true);
expect(readFileSync(indexPath, "utf8")).toContain('name: "e2e-flow"');
expect(readFileSync(mainRolePath, "utf8")).toContain("e2e-flow started");
},
);
it("create sense scaffolds index.js, schema.ts, and migration", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
+12 -3
View File
@@ -208,7 +208,7 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
mkdirSync(join(nerveRoot, "data", "blobs"), { recursive: true });
mkdirSync(join(nerveRoot, "senses", "counter", "migrations"), { recursive: true });
mkdirSync(join(nerveRoot, "workflows", "echo"), { recursive: true });
mkdirSync(join(nerveRoot, "workflows", "echo", "dist"), { recursive: true });
writeFileSync(
join(nerveRoot, "nerve.yaml"),
withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate,
@@ -224,10 +224,19 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
"utf8",
);
writeFileSync(join(nerveRoot, "workflows", "echo", "index.js"), echoWorkflowIndexJs, "utf8");
writeFileSync(
join(nerveRoot, "workflows", "echo", "dist", "index.js"),
echoWorkflowIndexJs,
"utf8",
);
if (withNoopWorkflow) {
mkdirSync(join(nerveRoot, "workflows", "noop", "dist"), { recursive: true });
mkdirSync(join(nerveRoot, "workflows", "noop", "migrations"), { recursive: true });
writeFileSync(join(nerveRoot, "workflows", "noop", "index.js"), noopWorkflowIndexJs, "utf8");
writeFileSync(
join(nerveRoot, "workflows", "noop", "dist", "index.js"),
noopWorkflowIndexJs,
"utf8",
);
}
linkWorkspaceDaemonIntoNerveRoot(nerveRoot);
}
@@ -41,7 +41,7 @@ describe("e2e store archive", () => {
it(
"archives old workflow logs to JSONL, removes rows from logs, thread list still reads workflow_runs",
{ timeout: 30_000 },
{ timeout: 60_000 },
async () => {
daemon = await startTestDaemon({ withNoopWorkflow: true });
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
@@ -97,7 +97,7 @@ describe("e2e store archive", () => {
},
);
it("store archive --vacuum completes VACUUM after archiving", { timeout: 30_000 }, async () => {
it("store archive --vacuum completes VACUUM after archiving", { timeout: 60_000 }, async () => {
daemon = await startTestDaemon({ withNoopWorkflow: true });
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
+30 -4
View File
@@ -19,13 +19,34 @@ export type WorkflowScaffoldFiles = {
indexTs: string;
roleMainIndexTs: string;
roleMainPromptMd: string;
packageJson: string;
};
export function buildWorkflowPackageJson(name: string): string {
return `${JSON.stringify(
{
name: `nerve-workflow-${name}`,
private: true,
type: "module",
scripts: {
build:
"esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external",
},
devDependencies: {
esbuild: "^0.27.0",
},
},
null,
2,
)}\n`;
}
export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
return {
indexTs: buildWorkflowIndexTs(name),
roleMainIndexTs: buildWorkflowMainRoleIndexTs(name),
roleMainPromptMd: buildWorkflowMainRolePromptMd(name),
packageJson: buildWorkflowPackageJson(name),
};
}
@@ -179,27 +200,32 @@ const createWorkflowCommand = defineCommand({
mkdirSync(workflowDir, { recursive: true });
const scaffold = buildWorkflowScaffold(args.name);
writeFile(join(workflowDir, "package.json"), scaffold.packageJson);
writeFile(join(workflowDir, "index.ts"), scaffold.indexTs);
writeFile(join(workflowDir, "roles", "main", "index.ts"), scaffold.roleMainIndexTs);
writeFile(join(workflowDir, "roles", "main", "prompt.md"), scaffold.roleMainPromptMd);
process.stdout.write("✅ Workflow scaffolded:\n");
process.stdout.write(` ${join(workflowDir, "package.json")}\n`);
process.stdout.write(` ${join(workflowDir, "index.ts")}\n`);
process.stdout.write(` ${join(workflowDir, "roles", "main", "index.ts")}\n`);
process.stdout.write(` ${join(workflowDir, "roles", "main", "prompt.md")}\n`);
process.stdout.write("\n💡 Next steps:\n");
process.stdout.write(" 1. Add to nerve.yaml:\n");
process.stdout.write(
` 1. In ${workflowDir}, run \`npm install\` then \`npm run build\` (bundles to dist/index.js).\n`,
);
process.stdout.write(" 2. Add to nerve.yaml:\n");
process.stdout.write(" workflows:\n");
process.stdout.write(` ${args.name}:\n`);
process.stdout.write(" concurrency: 1\n");
process.stdout.write(" overflow: drop\n");
process.stdout.write(
` 2. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
` 3. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
);
process.stdout.write(
` 3. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\n`,
` 4. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\n`,
);
process.stdout.write(" 4. Run `nerve start` to launch the daemon.\n");
process.stdout.write(" 5. Run `nerve start` to launch the daemon.\n");
},
});
@@ -1,7 +1,7 @@
/**
* Phase 3 — FileWatcher workflow change detection tests.
*
* Verifies that file-watcher.ts detects .ts file changes under workflows/.
* Verifies that file-watcher.ts detects .js changes under workflows/<name>/dist/.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -15,10 +15,10 @@ import type { FileChange, FileWatcher } from "../file-watcher.js";
function makeTempNerveRoot(): string {
const dir = mkdtempSync(join(tmpdir(), "nerve-fw-wf-test-"));
mkdirSync(join(dir, "workflows", "my-workflow"), { recursive: true });
mkdirSync(join(dir, "workflows", "my-workflow", "dist"), { recursive: true });
writeFileSync(join(dir, "nerve.yaml"), "senses: {}\n");
writeFileSync(
join(dir, "workflows", "my-workflow", "index.ts"),
join(dir, "workflows", "my-workflow", "dist", "index.js"),
"export default { roles: {}, moderate: () => null };",
);
return dir;
@@ -54,7 +54,7 @@ describe("createFileWatcher — workflow file changes (Phase 3)", () => {
}
});
it("detects workflow .ts file changes and emits kind=workflow", async () => {
it("detects workflow dist .js file changes and emits kind=workflow", async () => {
const root = makeTempNerveRoot();
const changes: FileChange[] = [];
@@ -62,7 +62,7 @@ describe("createFileWatcher — workflow file changes (Phase 3)", () => {
await new Promise((r) => setTimeout(r, 100));
writeFileSync(
join(root, "workflows", "my-workflow", "index.ts"),
join(root, "workflows", "my-workflow", "dist", "index.js"),
"export default { roles: {}, moderate: () => null }; // updated",
);
@@ -108,7 +108,7 @@ describe("createFileWatcher — workflow file changes (Phase 3)", () => {
for (let i = 0; i < 5; i++) {
writeFileSync(
join(root, "workflows", "my-workflow", "index.ts"),
join(root, "workflows", "my-workflow", "dist", "index.js"),
`export default {}; // v${i}`,
);
}
@@ -16,7 +16,7 @@ function makeTempNerveRoot(): string {
mkdirSync(join(dir, "senses", "cpu-usage"), { recursive: true });
writeFileSync(join(dir, "nerve.yaml"), "senses: {}\n");
writeFileSync(
join(dir, "senses", "cpu-usage", "index.ts"),
join(dir, "senses", "cpu-usage", "index.js"),
"export async function compute() { return null; }",
);
return dir;
@@ -70,7 +70,7 @@ describe("createFileWatcher", () => {
expect(changes.some((c) => c.kind === "config")).toBe(true);
}, 10_000);
it("detects sense .ts file changes", async () => {
it("detects sense .js file changes", async () => {
const root = makeTempNerveRoot();
const changes: FileChange[] = [];
@@ -78,7 +78,7 @@ describe("createFileWatcher", () => {
await new Promise((r) => setTimeout(r, 100));
writeFileSync(
join(root, "senses", "cpu-usage", "index.ts"),
join(root, "senses", "cpu-usage", "index.js"),
"export async function compute() { return { signal: 42, workflow: null }; }",
);
+24 -19
View File
@@ -4,8 +4,11 @@
* Uses Node.js fs.watch (no external dependencies).
*
* Watched events:
* - .ts file under senses/ modified → callback with { kind: "sense", senseName, filePath }
* - nerve.yaml modified → callback with { kind: "config", filePath }
* - Path matches sense bundled output (`senses/<name>/.../*.js`): emits a sense change for hot reload.
* - Path matches workflow bundle output (`workflows/<name>/dist/.../*.js`): emits a workflow change for hot reload.
* - `nerve.yaml`: emits a config change.
*
* Sense and workflow paths intentionally track built/runtime artifacts (for example `index.js` under a sense, or files under `dist/`), not TypeScript sources.
*
* Debounces rapid changes (e.g. editor save flicker) with a configurable delay.
*
@@ -18,7 +21,7 @@
import { watch } from "node:fs";
import type { FSWatcher } from "node:fs";
import { join, relative, sep } from "node:path";
import { join, sep } from "node:path";
export type SenseFileChange = {
kind: "sense";
@@ -67,26 +70,28 @@ export function createFileWatcher(
);
}
// senses/<senseName>/<any>/file.js — runtime output the sense worker loads (see sense-worker layout).
const senseJsPattern = /^senses\/([^/]+)\/.+\.js$/;
// workflows/<workflowName>/dist/<any>/file.js — bundled workflow (see workflow-worker loader).
const workflowDistJsPattern = /^workflows\/([^/]+)\/dist\/.+\.js$/;
function handleSenseChange(normalized: string, filename: string): void {
if (!(normalized.startsWith("senses/") && normalized.endsWith(".ts"))) return;
const rel = relative("senses", normalized);
const senseName = rel.split("/")[0];
if (senseName) {
debounced(`sense:${senseName}`, () => {
handler({ kind: "sense", senseName, filePath: join(nerveRoot, filename) });
});
}
const m = senseJsPattern.exec(normalized);
if (m === null) return;
const senseName = m[1];
debounced(`sense:${senseName}`, () => {
handler({ kind: "sense", senseName, filePath: join(nerveRoot, filename) });
});
}
function handleWorkflowChange(normalized: string, filename: string): void {
if (!(normalized.startsWith("workflows/") && normalized.endsWith(".ts"))) return;
const rel = relative("workflows", normalized);
const workflowName = rel.split("/")[0];
if (workflowName) {
debounced(`workflow:${workflowName}`, () => {
handler({ kind: "workflow", workflowName, filePath: join(nerveRoot, filename) });
});
}
const m = workflowDistJsPattern.exec(normalized);
if (m === null) return;
const workflowName = m[1];
debounced(`workflow:${workflowName}`, () => {
handler({ kind: "workflow", workflowName, filePath: join(nerveRoot, filename) });
});
}
function handleFsEvent(_eventType: string, filename: string | null): void {
+1 -1
View File
@@ -60,7 +60,7 @@ export type WorkflowManager = {
updateConfig: (newConfig: NerveConfig) => void;
/**
* Drain active threads for a workflow, then respawn its worker process.
* Used for hot reload when the workflow .ts file changes.
* Used for hot reload when bundled workflow output under workflows/<name>/dist/ changes.
* Waits up to `drainTimeoutMs` for threads to complete before force-killing.
*/
drainAndRespawn: (workflowName: string, drainTimeoutMs?: number) => Promise<void>;
+4 -9
View File
@@ -6,7 +6,7 @@
* the user's WorkflowDefinition, then signals ready and enters the IPC event loop.
*
* Layout assumptions (nerve user config at `<nerveRoot>/`):
* workflows/<name>/index.ts (or .js) ← user workflow definition
* workflows/<name>/dist/index.js ← bundled user workflow definition (e.g. esbuild output)
*/
import "./experimental-warning-suppression.js";
@@ -298,15 +298,10 @@ async function loadWorkflowDefinition(
nerveRoot: string,
workflowName: string,
): Promise<WorkflowDefinition<RoleMeta>> {
const candidates = [
resolve(join(nerveRoot, "workflows", workflowName, "index.ts")),
resolve(join(nerveRoot, "workflows", workflowName, "index.js")),
];
const indexPath = candidates.find((p) => existsSync(p));
if (!indexPath) {
const indexPath = resolve(join(nerveRoot, "workflows", workflowName, "dist", "index.js"));
if (!existsSync(indexPath)) {
throw new Error(
`Workflow definition not found for "${workflowName}". Tried:\n${candidates.map((p) => ` ${p}`).join("\n")}`,
`Workflow definition not found for "${workflowName}". Expected:\n ${indexPath}`,
);
}