Compare commits

...

7 Commits

Author SHA1 Message Date
xingyue f93e6901ac fix(init): auto-verify and retry better-sqlite3 native build
After pnpm install, verify better-sqlite3 actually loads by spawning
a test process. If it fails, rebuild up to 2 times. On final failure,
print actionable fix commands instead of a vague warning.

Closes #44
2026-04-23 08:11:44 +08:00
xingyue 282a802f06 fix: address review feedback on PR #42
1. [BLOCKER] tsup.config.ts: resolve merge conflict — keep both banner
   (shebang) and external (daemon decoupling)

2. [SHOULD-FIX] assertWorkspaceDaemonInstalled: throw Error instead of
   process.exit(1) — callers decide error handling

3. [SHOULD-FIX] getDaemonEntryPath: read daemon's package.json 'main'
   field instead of hardcoding dist/index.js

4. [SHOULD-FIX] daemon startup check: replace sleep(1500) with IPC
   socket polling (200ms intervals, 5s timeout)

5. [SHOULD-FIX] daemon-types drift: add vitest type-level assertions
   that verify CLI mirror types stay assignable with daemon exports
2026-04-23 07:07:38 +08:00
xingyue c8e6409837 refactor(cli): decouple daemon native deps from CLI global install
- Move @uncaged/nerve-daemon from runtime to devDependencies
- Dynamic import daemon from workspace node_modules at runtime
- Add daemon-bootstrap.ts as separate entry for background daemon spawn
- Extract run-foreground-kernel.ts and workspace-daemon.ts modules
- Add daemon-types.ts for structural types (no runtime daemon import)
- Rebuild better-sqlite3 in workspace during nerve init
- Validate daemon process liveness after spawn in background mode
- Mark @uncaged/nerve-daemon as external in tsup config

Closes #41
2026-04-23 06:58:00 +08:00
xiaoju 877da470d7 fix: pre-approve build scripts in nerve init scaffold
Add pnpm.onlyBuiltDependencies for better-sqlite3 and esbuild
to suppress pnpm v10 approve-builds warnings.

小橘 <xiaoju@shazhou.work>
2026-04-22 15:49:13 +00:00
xiaoju 01f54d14c5 chore: bump to 0.1.1 for npm publish fix
小橘 <xiaoju@shazhou.work>
2026-04-22 15:37:30 +00:00
xiaoju 6a689c4094 feat: make nerve-cli and nerve-daemon publishable npm packages
- Remove private:true from cli and daemon package.json
- Add files and publishConfig fields
- Add shebang banner via tsup for CLI entry
- Add trigger-sense IPC support in daemon and client

Closes #40

小橘 <xiaoju@shazhou.work>
2026-04-22 15:28:05 +00:00
xiaomo e66a376a77 Merge pull request 'feat: add nerve logs command with AI-friendly pagination — closes #29' (#34) from feat/nerve-logs into main 2026-04-22 15:04:52 +00:00
20 changed files with 524 additions and 128 deletions
+8 -3
View File
@@ -1,23 +1,28 @@
{
"name": "@uncaged/nerve-cli",
"version": "0.0.1",
"private": true,
"version": "0.1.2",
"type": "module",
"bin": {
"nerve": "dist/cli.js"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsup",
"test": "vitest run"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-daemon": "workspace:*",
"citty": "^0.1.6"
},
"devDependencies": {
"@uncaged/nerve-daemon": "workspace:*",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0",
"vitest": "^4.1.5"
@@ -0,0 +1,47 @@
/**
* Compile-time check: daemon-types.ts stays in sync with @uncaged/nerve-daemon exports.
* If the daemon package changes its public API, this file will fail to compile.
*/
import type {
LogEntry as DaemonLogEntry,
LogQuery as DaemonLogQuery,
LogStore as DaemonLogStore,
WorkflowRun as DaemonWorkflowRun,
WorkflowRunStatus as DaemonWorkflowRunStatus,
} from "@uncaged/nerve-daemon";
import { describe, it, expectTypeOf } from "vitest";
import type {
LogEntry,
LogQuery,
LogStore,
WorkflowRun,
WorkflowRunStatus,
} from "../daemon-types.js";
describe("daemon-types drift guard", () => {
it("WorkflowRunStatus is assignable both ways", () => {
expectTypeOf<WorkflowRunStatus>().toMatchTypeOf<DaemonWorkflowRunStatus>();
expectTypeOf<DaemonWorkflowRunStatus>().toMatchTypeOf<WorkflowRunStatus>();
});
it("WorkflowRun is assignable both ways", () => {
expectTypeOf<WorkflowRun>().toMatchTypeOf<DaemonWorkflowRun>();
expectTypeOf<DaemonWorkflowRun>().toMatchTypeOf<WorkflowRun>();
});
it("LogEntry is assignable both ways", () => {
expectTypeOf<LogEntry>().toMatchTypeOf<DaemonLogEntry>();
expectTypeOf<DaemonLogEntry>().toMatchTypeOf<LogEntry>();
});
it("LogQuery is assignable both ways", () => {
expectTypeOf<LogQuery>().toMatchTypeOf<DaemonLogQuery>();
expectTypeOf<DaemonLogQuery>().toMatchTypeOf<LogQuery>();
});
it("LogStore has all required methods", () => {
expectTypeOf<LogStore>().toMatchTypeOf<Pick<DaemonLogStore, "query" | "getWorkflowRun" | "getActiveWorkflowRuns" | "getAllWorkflowRuns" | "upsertWorkflowRun" | "close">>();
});
});
+1 -1
View File
@@ -13,7 +13,6 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createLogStore } from "@uncaged/nerve-daemon";
import type { LogStore, WorkflowRun } from "@uncaged/nerve-daemon";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
@@ -25,6 +24,7 @@ import {
statusIcon,
} from "../commands/workflow.js";
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
import type { LogStore, WorkflowRun } from "../daemon-types.js";
// ---------------------------------------------------------------------------
// Test helpers
+2 -2
View File
@@ -1,9 +1,8 @@
#!/usr/bin/env node
import { defineCommand, runMain } from "citty";
import { initCommand } from "./commands/init.js";
import { logsCommand } from "./commands/logs.js";
import { senseCommand } from "./commands/sense.js";
import { startCommand } from "./commands/start.js";
import { statusCommand } from "./commands/status.js";
import { stopCommand } from "./commands/stop.js";
@@ -22,6 +21,7 @@ const main = defineCommand({
status: statusCommand,
logs: logsCommand,
validate: validateCommand,
sense: senseCommand,
workflow: workflowCommand,
},
});
+53 -7
View File
@@ -26,10 +26,14 @@ const PACKAGE_JSON = `{
"type": "module",
"dependencies": {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest",
"drizzle-orm": "latest"
},
"devDependencies": {
"drizzle-kit": "latest"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
}
}
`;
@@ -97,7 +101,7 @@ async function runCommand(cmd: string, args: string[], cwd: string): Promise<voi
});
}
async function detectPackageManager(): Promise<{ cmd: string; args: string[] }> {
async function detectPackageManager(): Promise<{ cmd: string; installArgs: string[] }> {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
@@ -105,13 +109,13 @@ async function detectPackageManager(): Promise<{ cmd: string; args: string[] }>
for (const pm of ["pnpm", "yarn", "npm"]) {
try {
await execFileAsync(pm, ["--version"]);
const args = pm === "pnpm" ? ["install", "--no-cache"] : ["install"];
return { cmd: pm, args };
const installArgs = pm === "pnpm" ? ["install", "--no-cache"] : ["install"];
return { cmd: pm, installArgs };
} catch {
// not available, try next
}
}
return { cmd: "npm", args: ["install"] };
return { cmd: "npm", installArgs: ["install"] };
}
export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
@@ -215,6 +219,23 @@ const initWorkspaceCommand = defineCommand({
},
});
async function tryRequireSqlite(nerveRoot: string): Promise<boolean> {
try {
const modulePath = join(nerveRoot, "node_modules", "better-sqlite3");
// Use a child process to test if the native module loads
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
await execFileAsync("node", ["-e", `require(${JSON.stringify(modulePath)})`], {
cwd: nerveRoot,
timeout: 10_000,
});
return true;
} catch {
return false;
}
}
async function runInitWorkspace(force: boolean): Promise<void> {
const nerveRoot = getNerveRoot();
@@ -238,11 +259,36 @@ async function runInitWorkspace(force: boolean): Promise<void> {
);
process.stdout.write("Installing dependencies…\n");
const { cmd, installArgs } = await detectPackageManager();
try {
const { cmd, args } = await detectPackageManager();
await runCommand(cmd, args, nerveRoot);
await runCommand(cmd, installArgs, nerveRoot);
} catch {
process.stdout.write("⚠️ Install failed — you may need to install dependencies manually.\n");
process.stdout.write(
`⚠️ Install failed. Try manually:\n cd ${nerveRoot} && ${cmd} ${installArgs.join(" ")}\n`,
);
}
// Verify better-sqlite3 native module — rebuild up to 2 times if broken
const sqlitePath = join(nerveRoot, "node_modules", "better-sqlite3");
if (existsSync(sqlitePath)) {
for (let attempt = 1; attempt <= 2; attempt++) {
if (await tryRequireSqlite(nerveRoot)) break;
process.stdout.write(
`${attempt === 1 ? "Building" : "Retrying build of"} native module better-sqlite3 (attempt ${attempt}/2)…\n`,
);
try {
await runCommand(cmd, ["rebuild", "better-sqlite3"], nerveRoot);
} catch {
// will be caught by the verify below
}
}
if (!(await tryRequireSqlite(nerveRoot))) {
process.stdout.write(
`⚠️ better-sqlite3 native module is not working. The daemon will fail to start.\n` +
` Fix: cd ${nerveRoot} && ${cmd} rebuild better-sqlite3\n` +
` Or: npm install --build-from-source better-sqlite3\n`,
);
}
}
if (!existsSync(join(nerveRoot, ".git"))) {
+58
View File
@@ -0,0 +1,58 @@
import { defineCommand } from "citty";
import { triggerSenseViaDaemon } from "../daemon-client.js";
import { getSocketPath, isRunning } from "../workspace.js";
// ---------------------------------------------------------------------------
// nerve sense trigger <name>
// ---------------------------------------------------------------------------
const senseTriggerCommand = defineCommand({
meta: {
name: "trigger",
description: "Manually trigger a sense compute by sending an IPC message to the running daemon",
},
args: {
name: {
type: "positional",
description: "The sense name to trigger",
},
},
async run({ args }) {
if (!isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
process.exit(1);
}
const socketPath = getSocketPath();
let response: { ok: true } | { ok: false; error: string };
try {
response = await triggerSenseViaDaemon(socketPath, args.name);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Daemon rejected trigger: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(`✅ Triggered sense "${args.name}" via daemon.\n`);
},
});
// ---------------------------------------------------------------------------
// nerve sense (parent command)
// ---------------------------------------------------------------------------
export const senseCommand = defineCommand({
meta: {
name: "sense",
description: "Interact with sense computes",
},
subCommands: {
trigger: senseTriggerCommand,
},
});
+53 -86
View File
@@ -1,99 +1,42 @@
import { createWriteStream, existsSync, readFileSync } from "node:fs";
import { createWriteStream, existsSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { createKernel } from "@uncaged/nerve-daemon";
import { defineCommand } from "citty";
import { runForegroundKernelSession } from "../run-foreground-kernel.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import {
getLogPath,
getNerveRoot,
getSocketPath,
isRunning,
readPidFile,
removePidFile,
writePidFile,
} from "../workspace.js";
function readConfig(nerveRoot: string): ReturnType<typeof parseNerveConfig> {
const configPath = join(nerveRoot, "nerve.yaml");
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, error: new Error(`❌ Cannot read ${configPath}: ${msg}`) };
}
return parseNerveConfig(raw);
function waitForSocket(socketPath: string, timeoutMs = 5000, intervalMs = 200): Promise<boolean> {
return new Promise((resolve) => {
const deadline = Date.now() + timeoutMs;
const check = (): void => {
if (existsSync(socketPath)) {
resolve(true);
} else if (Date.now() >= deadline) {
resolve(false);
} else {
setTimeout(check, intervalMs);
}
};
check();
});
}
async function runForeground(nerveRoot: string): Promise<void> {
const configResult = readConfig(nerveRoot);
if (!configResult.ok) {
process.stderr.write(`${configResult.error.message}\n`);
process.exit(1);
}
const config = configResult.value;
const kernel = createKernel(config, nerveRoot, {
enableFileWatcher: true,
ipcSocketPath: getSocketPath(),
});
const senseNames = Object.keys(config.senses);
const groups = [...kernel.groups];
process.stdout.write(
`✅ Nerve starting — ${senseNames.length} sense(s), ${groups.length} group(s)\n`,
);
for (const group of groups) {
const groupSenses = Object.entries(config.senses)
.filter(([, sc]) => sc.group === group)
.map(([name]) => name);
process.stdout.write(` group "${group}": ${groupSenses.join(", ")}\n`);
}
process.stdout.write(" Press Ctrl+C to stop.\n");
let shuttingDown = false;
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
process.stdout.write("\n[nerve] Shutting down…\n");
await kernel.stop();
process.exit(0);
}
process.on("SIGINT", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
process.on("SIGTERM", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
await kernel.ready;
}
/** Path to the CLI entry script (for spawning `start` without `-d`). */
/** Path to the CLI entry script (used to locate dist/ next to bundled assets). */
function cliEntryScript(): string {
const here = fileURLToPath(import.meta.url);
const ext = here.endsWith(".ts") ? ".ts" : ".js";
// When bundled, `here` is already the CLI entry (e.g. dist/cli.js).
// When running from source, `here` is src/commands/start.ts → go up to src/cli.ts.
const candidates = [
join(dirname(here), `cli${ext}`), // bundled: dist/cli.js
join(dirname(here), "..", `cli${ext}`), // source: src/commands/start.ts → src/cli.ts
];
const candidates = [join(dirname(here), `cli${ext}`), join(dirname(here), "..", `cli${ext}`)];
const cliPath = candidates.find((p) => existsSync(p));
if (!cliPath) {
throw new Error(`CLI entry not found (searched: ${candidates.join(", ")})`);
@@ -101,6 +44,23 @@ function cliEntryScript(): string {
return cliPath;
}
function daemonBootstrapScript(): string {
const cliPath = cliEntryScript();
const dir = dirname(cliPath);
const bootstrapJs = join(dir, "daemon-bootstrap.js");
if (existsSync(bootstrapJs)) {
return bootstrapJs;
}
throw new Error(
`daemon-bootstrap.js not found next to CLI at ${bootstrapJs}. Build the CLI package (e.g. \`pnpm --filter @uncaged/nerve-cli build\`) before using background mode (\`nerve start -d\`).`,
);
}
async function runForeground(nerveRoot: string): Promise<void> {
const { createKernel } = await loadDaemonModule(nerveRoot);
await runForegroundKernelSession(nerveRoot, createKernel);
}
async function runDaemon(nerveRoot: string): Promise<void> {
if (isRunning()) {
const pid = readPidFile();
@@ -108,12 +68,6 @@ async function runDaemon(nerveRoot: string): Promise<void> {
process.exit(1);
}
const configResult = readConfig(nerveRoot);
if (!configResult.ok) {
process.stderr.write(`${configResult.error.message}\n`);
process.exit(1);
}
const logPath = getLogPath();
await mkdir(join(nerveRoot, "logs"), { recursive: true });
@@ -124,12 +78,13 @@ async function runDaemon(nerveRoot: string): Promise<void> {
else resolve();
});
const cliPath = cliEntryScript();
const bootstrapPath = daemonBootstrapScript();
const child = spawn(process.execPath, [cliPath, "start"], {
const child = spawn(process.execPath, [bootstrapPath], {
detached: true,
stdio: ["ignore", logStream.fd, logStream.fd],
env: { ...process.env, NERVE_DAEMON_MODE: "1" },
env: { ...process.env, NERVE_ROOT: nerveRoot },
cwd: nerveRoot,
});
child.unref();
@@ -141,6 +96,18 @@ async function runDaemon(nerveRoot: string): Promise<void> {
}
writePidFile(pid);
const { getSocketPath } = await import("../workspace.js");
const ready = await waitForSocket(getSocketPath(), 5000);
if (!ready || !isRunning()) {
removePidFile();
process.stderr.write(
`❌ Daemon process exited shortly after start. Check logs at:\n ${logPath}\n`,
);
process.exit(1);
}
process.stdout.write(`✅ Nerve daemon started (pid ${pid}).\n`);
process.stdout.write(` Logs: ${logPath}\n`);
process.stdout.write(" Run `nerve stop` to stop.\n");
+7 -5
View File
@@ -1,11 +1,11 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createLogStore } from "@uncaged/nerve-daemon";
import type { LogStore, WorkflowRun } from "@uncaged/nerve-daemon";
import { defineCommand } from "citty";
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
import type { LogStore, WorkflowRun } from "../daemon-types.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
export const DEFAULT_PAGE_SIZE = 20;
@@ -23,12 +23,14 @@ export function formatTs(ts: number): string {
return new Date(ts).toISOString();
}
function openStore(): LogStore {
async function openStore(): Promise<LogStore> {
const nerveRoot = getNerveRoot();
const dbPath = getDbPath();
if (!existsSync(dbPath)) {
process.stderr.write("❌ No logs.db found — has the daemon run yet?\n");
process.exit(1);
}
const { createLogStore } = await loadDaemonModule(nerveRoot);
return createLogStore(dbPath);
}
@@ -202,7 +204,7 @@ const workflowListCommand = defineCommand({
},
},
async run({ args }) {
const store = openStore();
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
@@ -259,7 +261,7 @@ const workflowInspectCommand = defineCommand({
},
},
async run({ args }) {
const store = openStore();
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env node
import { runForegroundKernelSession } from "./run-foreground-kernel.js";
import { loadDaemonModule } from "./workspace-daemon.js";
const nerveRoot = process.env.NERVE_ROOT;
if (nerveRoot === undefined || nerveRoot.length === 0) {
process.stderr.write("[nerve] NERVE_ROOT environment variable is required.\n");
process.exit(1);
}
const { createKernel } = await loadDaemonModule(nerveRoot);
await runForegroundKernelSession(nerveRoot, createKernel);
+26 -11
View File
@@ -1,6 +1,6 @@
/**
* Daemon IPC client — connects to the daemon's Unix socket and sends
* a trigger-workflow request.
* trigger-workflow or trigger-sense requests.
*
* Protocol: newline-delimited JSON (same as daemon-ipc.ts server side).
*/
@@ -27,15 +27,7 @@ function parseDaemonResponse(line: string): TriggerResponse {
return { ok: false, error: `Unexpected daemon response: ${line}` };
}
/**
* Send a trigger-workflow message to the running daemon via its Unix socket.
* Resolves with the daemon's response or rejects on connection/timeout errors.
*/
export function triggerWorkflowViaDaemon(
socketPath: string,
workflow: string,
payload: unknown,
): Promise<TriggerResponse> {
function sendAndReceive(socketPath: string, message: object): Promise<TriggerResponse> {
return new Promise((resolve, reject) => {
let socket: Socket | null = null;
let settled = false;
@@ -79,7 +71,7 @@ export function triggerWorkflowViaDaemon(
}
});
const msg = `${JSON.stringify({ type: "trigger-workflow", workflow, payload })}\n`;
const msg = `${JSON.stringify(message)}\n`;
socket?.write(msg);
});
@@ -89,3 +81,26 @@ export function triggerWorkflowViaDaemon(
});
});
}
/**
* Send a trigger-workflow message to the running daemon via its Unix socket.
* Resolves with the daemon's response or rejects on connection/timeout errors.
*/
export function triggerWorkflowViaDaemon(
socketPath: string,
workflow: string,
payload: unknown,
): Promise<TriggerResponse> {
return sendAndReceive(socketPath, { type: "trigger-workflow", workflow, payload });
}
/**
* Send a trigger-sense message to the running daemon via its Unix socket.
* Resolves with the daemon's response or rejects on connection/timeout errors.
*/
export function triggerSenseViaDaemon(
socketPath: string,
sense: string,
): Promise<TriggerResponse> {
return sendAndReceive(socketPath, { type: "trigger-sense", sense });
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Structural types for workflow CLI — mirrors @uncaged/nerve-daemon log-store
* public API so the CLI runtime does not statically depend on the daemon package.
*
* ⚠️ Keep in sync with @uncaged/nerve-daemon exports.
* Run `pnpm --filter @uncaged/nerve-cli test` to catch drift via satisfies assertions.
*/
export type WorkflowRunStatus =
| "queued"
| "started"
| "completed"
| "failed"
| "crashed"
| "dropped"
| "interrupted";
export type WorkflowRun = {
runId: string;
workflow: string;
status: WorkflowRunStatus;
ts: number;
};
export type LogEntry = {
id?: number;
source: string;
type: string;
refId: string | null;
payload: string | null;
ts: number;
};
export type LogQuery = {
source?: string;
type?: string;
refId?: string;
since?: number;
until?: number;
limit?: number;
};
/** Subset of daemon LogStore used by the CLI workflow commands. */
export type LogStore = {
query: (filter?: LogQuery) => LogEntry[];
getWorkflowRun: (runId: string) => WorkflowRun | null;
getActiveWorkflowRuns: (workflowName?: string) => WorkflowRun[];
getAllWorkflowRuns: (workflowName: string | null) => WorkflowRun[];
upsertWorkflowRun: (entry: Omit<LogEntry, "id">, run: WorkflowRun) => LogEntry;
close: () => void;
};
+5 -2
View File
@@ -8,5 +8,8 @@ export {
isRunning,
} from "./workspace.js";
export { createKernel } from "@uncaged/nerve-daemon";
export type { Kernel } from "@uncaged/nerve-daemon";
export {
assertWorkspaceDaemonInstalled,
getDaemonEntryPath,
loadDaemonModule,
} from "./workspace-daemon.js";
+88
View File
@@ -0,0 +1,88 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { getSocketPath } from "./workspace.js";
export type CreateKernelFn = (
config: NerveConfig,
nerveRoot: string,
opts: { enableFileWatcher: boolean; ipcSocketPath: string },
) => {
groups: Set<string>;
ready: Promise<void>;
stop: () => Promise<void>;
};
function readConfig(nerveRoot: string): ReturnType<typeof parseNerveConfig> {
const configPath = join(nerveRoot, "nerve.yaml");
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, error: new Error(`❌ Cannot read ${configPath}: ${msg}`) };
}
return parseNerveConfig(raw);
}
export async function runForegroundKernelSession(
nerveRoot: string,
createKernel: CreateKernelFn,
): Promise<void> {
const configResult = readConfig(nerveRoot);
if (!configResult.ok) {
process.stderr.write(`${configResult.error.message}\n`);
process.exit(1);
}
const config = configResult.value;
const kernel = createKernel(config, nerveRoot, {
enableFileWatcher: true,
ipcSocketPath: getSocketPath(),
});
const senseNames = Object.keys(config.senses);
const groups = [...kernel.groups];
process.stdout.write(
`✅ Nerve starting — ${senseNames.length} sense(s), ${groups.length} group(s)\n`,
);
for (const group of groups) {
const groupSenses = Object.entries(config.senses)
.filter(([, sc]) => sc.group === group)
.map(([name]) => name);
process.stdout.write(` group "${group}": ${groupSenses.join(", ")}\n`);
}
process.stdout.write(" Press Ctrl+C to stop.\n");
let shuttingDown = false;
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
process.stdout.write("\n[nerve] Shutting down…\n");
await kernel.stop();
process.exit(0);
}
process.on("SIGINT", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
process.on("SIGTERM", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
await kernel.ready;
}
+50
View File
@@ -0,0 +1,50 @@
import { existsSync } from "node:fs";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { NerveConfig } from "@uncaged/nerve-core";
import type { LogStore } from "./daemon-types.js";
export function getDaemonEntryPath(nerveRoot: string): string | undefined {
const pkgPath = join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", "package.json");
if (!existsSync(pkgPath)) return undefined;
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { main?: string };
const main = pkg.main ?? "dist/index.js";
return join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", main);
} catch {
return join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", "dist", "index.js");
}
}
export function assertWorkspaceDaemonInstalled(nerveRoot: string): string {
const entry = getDaemonEntryPath(nerveRoot);
if (!entry || !existsSync(entry)) {
throw new Error(
`@uncaged/nerve-daemon is not installed under ${nerveRoot}/node_modules/. Run \`nerve init\` (or \`nerve init --force\`) to install workspace dependencies.`,
);
}
return entry;
}
/** Loaded from ~/.uncaged-nerve/node_modules at runtime — keep types structural only. */
export type DaemonModule = {
createKernel: (
config: NerveConfig,
nerveRoot: string,
options: { enableFileWatcher: boolean; ipcSocketPath: string },
) => {
groups: Set<string>;
ready: Promise<void>;
stop: () => Promise<void>;
};
createLogStore: (dbPath: string) => LogStore;
};
export async function loadDaemonModule(nerveRoot: string): Promise<DaemonModule> {
const entry = assertWorkspaceDaemonInstalled(nerveRoot);
const url = pathToFileURL(entry).href;
return import(url) as Promise<DaemonModule>;
}
+6 -1
View File
@@ -1,8 +1,13 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/cli.ts"],
entry: ["src/index.ts", "src/cli.ts", "src/daemon-bootstrap.ts"],
format: ["esm"],
dts: true,
clean: true,
banner: {
js: "#!/usr/bin/env node",
},
/** Daemon is loaded from workspace node_modules at runtime — never bundle it. */
external: ["@uncaged/nerve-daemon"],
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/nerve-core",
"version": "0.0.1",
"version": "0.1.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+7 -2
View File
@@ -1,10 +1,15 @@
{
"name": "@uncaged/nerve-daemon",
"version": "0.0.1",
"private": true,
"version": "0.1.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsup",
"test": "vitest run"
+24 -3
View File
@@ -1,9 +1,10 @@
/**
* Daemon IPC server — listens on a Unix domain socket so that the CLI
* can send commands (e.g. trigger-workflow) to the running daemon process.
* can send commands (e.g. trigger-workflow, trigger-sense) to the running daemon process.
*
* Protocol: newline-delimited JSON messages.
* Each request: { type: "trigger-workflow"; workflow: string; payload: unknown }
* | { type: "trigger-sense"; sense: string }
* Each response: { ok: true } | { ok: false; error: string }
*/
@@ -19,7 +20,13 @@ export type TriggerWorkflowRequest = {
payload: unknown;
};
type DaemonRequest = TriggerWorkflowRequest;
/** JSON message sent by the CLI to trigger a sense compute on-demand. */
export type TriggerSenseRequest = {
type: "trigger-sense";
sense: string;
};
type DaemonRequest = TriggerWorkflowRequest | TriggerSenseRequest;
type DaemonResponse = { ok: true } | { ok: false; error: string };
@@ -36,15 +43,25 @@ function parseRequest(line: string): DaemonRequest | null {
if (typeof req.workflow !== "string" || req.workflow.length === 0) return null;
return { type: "trigger-workflow", workflow: req.workflow, payload: req.payload ?? {} };
}
if (req.type === "trigger-sense") {
if (typeof req.sense !== "string" || req.sense.length === 0) return null;
return { type: "trigger-sense", sense: req.sense };
}
return null;
} catch {
return null;
}
}
export type DaemonIpcServerOptions = {
/** Called when a trigger-sense request arrives. Should throw if the sense is unknown. */
triggerSense: (senseName: string) => void;
};
export function createDaemonIpcServer(
socketPath: string,
workflowManager: WorkflowManager,
opts: DaemonIpcServerOptions,
): DaemonIpcServer {
// Remove stale socket file if it exists
try {
@@ -65,7 +82,11 @@ export function createDaemonIpcServer(
}
try {
workflowManager.startWorkflow(req.workflow, req.payload);
if (req.type === "trigger-workflow") {
workflowManager.startWorkflow(req.workflow, req.payload);
} else if (req.type === "trigger-sense") {
opts.triggerSense(req.sense);
}
const resp: DaemonResponse = { ok: true };
socket.write(`${JSON.stringify(resp)}\n`);
} catch (err) {
+21 -1
View File
@@ -58,6 +58,11 @@ export type Kernel = {
getWorkerPid: (group: string) => number | null;
/** Sends a compute message to the worker responsible for the given sense. */
triggerCompute: (senseName: string) => void;
/**
* On-demand sense trigger — looks up the group for `senseName`, finds its worker,
* and sends a compute message. Throws if the sense is unknown.
*/
triggerSense: (senseName: string) => void;
/** Gracefully restart a group worker (wait for exit, then respawn). */
restartGroup: (group: string) => Promise<void>;
/** Reload config from a new NerveConfig, incrementally updating scheduler and workers.
@@ -280,6 +285,18 @@ export function createKernel(
sendCompute(entry.process, senseName);
}
function triggerSense(senseName: string): void {
const group = groupForSense(config, senseName);
if (group === null) {
throw new Error(`Unknown sense: "${senseName}"`);
}
const entry = workers.get(group);
if (entry === undefined) {
throw new Error(`No worker running for group "${group}" (sense: "${senseName}")`);
}
sendCompute(entry.process, senseName);
}
scheduler = createReflexScheduler(config, bus, triggerFn, {
logStore,
workflowTriggerFn: (workflowName, payload) => {
@@ -499,7 +516,9 @@ export function createKernel(
let ipcServer: DaemonIpcServer | null = null;
if (options.ipcSocketPath != null) {
ipcServer = createDaemonIpcServer(options.ipcSocketPath, workflowManager);
ipcServer = createDaemonIpcServer(options.ipcSocketPath, workflowManager, {
triggerSense,
});
}
async function stop(): Promise<void> {
@@ -546,6 +565,7 @@ export function createKernel(
ready,
getWorkerPid,
triggerCompute: triggerFn,
triggerSense,
restartGroup,
reloadConfig,
getHealth,
+3 -3
View File
@@ -23,9 +23,6 @@ importers:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
'@uncaged/nerve-daemon':
specifier: workspace:*
version: link:../daemon
citty:
specifier: ^0.1.6
version: 0.1.6
@@ -36,6 +33,9 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.19.17
'@uncaged/nerve-daemon':
specifier: workspace:*
version: link:../daemon
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3))