Merge pull request 'refactor(daemon): workflows must be bundled to dist/, daemon only loads dist/index.js' (#221) from refactor/219-workflow-bundle-dist into main
This commit was merged in pull request #221.
This commit is contained in:
@@ -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)", () => {
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }; }",
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user