diff --git a/packages/cli/src/__tests__/create-workflow.test.ts b/packages/cli/src/__tests__/create-workflow.test.ts index fef5dcd..d1593b1 100644 --- a/packages/cli/src/__tests__/create-workflow.test.ts +++ b/packages/cli/src/__tests__/create-workflow.test.ts @@ -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)", () => { diff --git a/packages/cli/src/__tests__/e2e-create.test.ts b/packages/cli/src/__tests__/e2e-create.test.ts index f71802a..f7432ff 100644 --- a/packages/cli/src/__tests__/e2e-create.test.ts +++ b/packages/cli/src/__tests__/e2e-create.test.ts @@ -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-")); diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index 2425ca6..e0ff2f8 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -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); } diff --git a/packages/cli/src/__tests__/e2e-store-archive.test.ts b/packages/cli/src/__tests__/e2e-store-archive.test.ts index c1ce2dd..f93edce 100644 --- a/packages/cli/src/__tests__/e2e-store-archive.test.ts +++ b/packages/cli/src/__tests__/e2e-store-archive.test.ts @@ -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); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index d5dacbd..16bcd40 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -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"); }, }); diff --git a/packages/daemon/src/__tests__/file-watcher-workflow.test.ts b/packages/daemon/src/__tests__/file-watcher-workflow.test.ts index 60f4d71..c32a958 100644 --- a/packages/daemon/src/__tests__/file-watcher-workflow.test.ts +++ b/packages/daemon/src/__tests__/file-watcher-workflow.test.ts @@ -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//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}`, ); } diff --git a/packages/daemon/src/__tests__/file-watcher.test.ts b/packages/daemon/src/__tests__/file-watcher.test.ts index 1da98a6..c957586 100644 --- a/packages/daemon/src/__tests__/file-watcher.test.ts +++ b/packages/daemon/src/__tests__/file-watcher.test.ts @@ -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 }; }", ); diff --git a/packages/daemon/src/file-watcher.ts b/packages/daemon/src/file-watcher.ts index 6721b2e..62b8ce8 100644 --- a/packages/daemon/src/file-watcher.ts +++ b/packages/daemon/src/file-watcher.ts @@ -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//.../*.js`): emits a sense change for hot reload. + * - Path matches workflow bundle output (`workflows//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///file.js — runtime output the sense worker loads (see sense-worker layout). + const senseJsPattern = /^senses\/([^/]+)\/.+\.js$/; + + // workflows//dist//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 { diff --git a/packages/daemon/src/workflow-manager.ts b/packages/daemon/src/workflow-manager.ts index b76e090..ffd8ce9 100644 --- a/packages/daemon/src/workflow-manager.ts +++ b/packages/daemon/src/workflow-manager.ts @@ -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//dist/ changes. * Waits up to `drainTimeoutMs` for threads to complete before force-killing. */ drainAndRespawn: (workflowName: string, drainTimeoutMs?: number) => Promise; diff --git a/packages/daemon/src/workflow-worker.ts b/packages/daemon/src/workflow-worker.ts index d040448..17e8d93 100644 --- a/packages/daemon/src/workflow-worker.ts +++ b/packages/daemon/src/workflow-worker.ts @@ -6,7 +6,7 @@ * the user's WorkflowDefinition, then signals ready and enters the IPC event loop. * * Layout assumptions (nerve user config at `/`): - * workflows//index.ts (or .js) ← user workflow definition + * workflows//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> { - 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}`, ); }