Compare commits

...

15 Commits

Author SHA1 Message Date
xiaoju 10f942b577 fix: address PR #34 review — SIGINT leak, negative offset, follow race conditions
- SIGINT: use process.once instead of process.on
- Negative offset: validate and exit(1) with error to stderr
- Follow mode: sequential while loop replaces setInterval (no async race)
- Log rotation: reset size when newSize < size
- TODO: readAllLines large file optimization note
- 2 new tests for negative offset validation

小橘 <xiaoju@shazhou.work>
2026-04-22 15:00:24 +00:00
xiaoju 76b547d37a feat: add nerve logs command with AI-friendly pagination — closes #29
- nerve logs: tail last 50 lines by default
- -n <lines>: specify line count
- --offset <n>: pagination from line n (1-based)
- -f/--follow: real-time tail with 300ms polling
- Footer with stats + next-page command hint for AI agents
- No ANSI colors, emoji only, data→stdout, errors→stderr
- 19 new tests covering pagination, footer, edge cases

小橘 <xiaoju@shazhou.work>
2026-04-22 14:52:17 +00:00
xiaoju 1b2ff37097 chore: publish @uncaged/nerve-core@0.0.1 to npm — closes #28
Removed 'private: true' to allow npm publish. Package is now available
at https://www.npmjs.com/package/@uncaged/nerve-core

小橘 <xiaoju@shazhou.work>
2026-04-22 14:37:07 +00:00
xiaoju 4add0d88c6 Revert "Merge pull request 'fix: remove unpublished @uncaged/nerve-core from init template — closes #28' (#33) from fix/remove-unpublished-dep into main"
This reverts commit a8404dc096, reversing
changes made to 569c034b49.
2026-04-22 14:36:24 +00:00
xiaoju a8404dc096 Merge pull request 'fix: remove unpublished @uncaged/nerve-core from init template — closes #28' (#33) from fix/remove-unpublished-dep into main 2026-04-22 14:35:24 +00:00
xiaoju 891db36152 fix: remove unpublished @uncaged/nerve-core from init template — closes #28
The workspace package.json template listed @uncaged/nerve-core as a
dependency, but this package is not published to npm. Since the generated
workflow code only imports from @uncaged/nerve-daemon (which is also not
yet published but will be), remove the unnecessary dependency to unblock
`nerve init`.

小橘 <xiaoju@shazhou.work>
2026-04-22 14:35:03 +00:00
xiaoju 569c034b49 Merge pull request 'fix: daemon mode spawn path — closes #27' (#30) from fix/daemon-spawn-path into main 2026-04-22 14:21:33 +00:00
xingyue 85fa282d2e fix(cli): create initial git commit after workspace init
git init without add+commit leaves the workspace in a dirty state
with no baseline to diff against.
2026-04-22 22:16:41 +08:00
xiaomo b75a112c95 Merge pull request 'fix: IPC trigger try/catch + test import cleanup' (#32) from fix/phase4-followup into main 2026-04-22 14:16:10 +00:00
xingyue 606eff6d70 fix(cli): remove self-fallback in cliEntryScript candidates
Per review: third candidate (here) is wrong — if bundled and source
candidates both miss, falling back to self reproduces the original bug.
Keep only the two valid candidates and throw on miss.
2026-04-22 22:15:53 +08:00
xingyue 97305bd9af fix(cli): resolve CLI entry path for bundled dist output
cliEntryScript() assumed source directory structure (src/commands/start.ts → ../cli.ts),
but after tsup bundles everything into dist/cli.js, import.meta.url points to dist/cli.js
and the '../cli.js' path resolves to a non-existent file.

Use candidate-based lookup: try same-dir, parent-dir, then self (bundled case).
2026-04-22 22:15:53 +08:00
xingyue 3f2c9df75d refactor: simplify cliEntryScript() — remove multi-level fallback
Per review feedback from xiaoju: the three-level fallback was over-defensive.
Since start.ts and cli.ts have a fixed relative position (commands/start.ts → ../cli.ts),
we can derive the path directly from import.meta.url with an existsSync guard.

This makes path errors explicit (throw) instead of silently falling back to
a potentially wrong path.
2026-04-22 22:15:53 +08:00
xingyue 1511cfd595 fix: daemon spawn uses CLI entry path instead of command module
The runDaemon function was using import.meta.url (pointing to start.js)
as the script for the spawned child process. This meant the child ran
`node start.js start` which has no CLI entry logic and exits immediately.

Added cliEntryScript() that resolves to the correct CLI entry (cli.js)
regardless of whether the code is bundled or split into separate files.

Closes #27
2026-04-22 22:15:53 +08:00
xiaoju 362dc94582 fix: add try/catch to IPC trigger handler & import real buildWorkflowTemplate in test
- daemon-ipc: wrap startWorkflow() in try/catch so errors are sent back
  as {ok:false, error:msg} instead of silently dropping the socket
- init-workflow.test: import buildWorkflowTemplate from init.ts instead
  of maintaining an inline copy

Addresses review follow-up suggestions from PR #31.

小橘 <xiaoju@shazhou.work>
2026-04-22 14:15:19 +00:00
xiaomo 9e7de3b4e0 Merge pull request 'feat: Workflow Engine Phase 4 — CLI & User Experience' (#31) from feat/workflow-engine-phase4 into main 2026-04-22 14:12:14 +00:00
8 changed files with 483 additions and 37 deletions
@@ -9,33 +9,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// Inline the template builder (same logic as in init.ts) for isolated testing
function buildWorkflowTemplate(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
const workflow: WorkflowDefinition = {
roles: {
main: {
async execute(prompt, ctx) {
ctx.log("${name} started");
// TODO: implement your role logic here
return { type: "done" };
},
},
},
moderate(thread, event) {
if (event.type === "thread_start") {
return { role: "main", prompt: {} };
}
return null; // workflow complete
},
};
export default workflow;
`;
}
import { buildWorkflowTemplate } from "../commands/init.js";
let tmpDir: string;
+250
View File
@@ -0,0 +1,250 @@
/**
* Tests for nerve logs command — pure helper functions only.
*
* We test sliceLogs and buildLogFooter without touching the filesystem or
* spawning a real process.
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_LOG_LINES, buildLogFooter, readAllLines, sliceLogs } from "../commands/logs.js";
import { logsCommand } from "../commands/logs.js";
// ---------------------------------------------------------------------------
// sliceLogs
// ---------------------------------------------------------------------------
describe("sliceLogs", () => {
const make = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
it("returns empty result for empty array", () => {
const r = sliceLogs([], 0, 50);
expect(r.lines).toHaveLength(0);
expect(r.total).toBe(0);
expect(r.nextOffset).toBeNull();
});
it("tail mode (offset=0): returns last N lines", () => {
const lines = make(100);
const r = sliceLogs(lines, 0, 10);
expect(r.lines).toHaveLength(10);
expect(r.lines[0]).toBe("line 91");
expect(r.lines[9]).toBe("line 100");
expect(r.startLine).toBe(91);
expect(r.endLine).toBe(100);
});
it("tail mode: when file shorter than limit, returns all", () => {
const lines = make(20);
const r = sliceLogs(lines, 0, 50);
expect(r.lines).toHaveLength(20);
expect(r.startLine).toBe(1);
expect(r.endLine).toBe(20);
expect(r.nextOffset).toBeNull();
});
it("tail mode: provides nextOffset when earlier lines exist", () => {
const lines = make(200);
const r = sliceLogs(lines, 0, 50);
expect(r.nextOffset).not.toBeNull();
expect(r.nextOffset).toBe(151 - 50); // startLine=151, prev page starts at 101
});
it("tail mode: nextOffset is null when showing from line 1", () => {
const lines = make(40);
const r = sliceLogs(lines, 0, 50);
expect(r.nextOffset).toBeNull();
});
it("offset mode: starts at given 1-based line number", () => {
const lines = make(100);
const r = sliceLogs(lines, 10, 5);
expect(r.lines[0]).toBe("line 10");
expect(r.startLine).toBe(10);
expect(r.endLine).toBe(14);
});
it("offset mode: clamps start to 0 for offset=1", () => {
const lines = make(50);
const r = sliceLogs(lines, 1, 10);
expect(r.startLine).toBe(1);
});
it("offset mode: nextOffset is null when slice starts at line 1", () => {
const lines = make(50);
const r = sliceLogs(lines, 1, 20);
expect(r.nextOffset).toBeNull();
});
it("offset mode: nextOffset points to previous page", () => {
const lines = make(100);
const r = sliceLogs(lines, 51, 50); // lines 51-100
expect(r.nextOffset).toBe(1); // previous page starts at line 1
});
});
// ---------------------------------------------------------------------------
// buildLogFooter
// ---------------------------------------------------------------------------
describe("buildLogFooter", () => {
it("returns empty-file message when total=0", () => {
const slice = { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
expect(buildLogFooter(slice, 50, "/path/to/nerve.log")).toContain("empty");
});
it("includes range and path in footer", () => {
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
const footer = buildLogFooter(slice, 50, "/var/log/nerve.log");
expect(footer).toContain("lines 151-200 of 200");
expect(footer).toContain("/var/log/nerve.log");
});
it("includes pagination hint when nextOffset is set", () => {
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
expect(footer).toContain("nerve logs --offset 101 -n 50");
});
it("no pagination hint when nextOffset is null", () => {
const slice = { lines: ["x"], total: 20, startLine: 1, endLine: 20, nextOffset: null };
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
expect(footer).not.toContain("nerve logs --offset");
});
});
// ---------------------------------------------------------------------------
// DEFAULT_LOG_LINES constant
// ---------------------------------------------------------------------------
describe("DEFAULT_LOG_LINES", () => {
it("is 50", () => {
expect(DEFAULT_LOG_LINES).toBe(50);
});
});
// ---------------------------------------------------------------------------
// readAllLines
// ---------------------------------------------------------------------------
describe("readAllLines", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("returns empty array for nonexistent file", async () => {
const result = await readAllLines(join(tmpDir, "missing.log"));
expect(result).toHaveLength(0);
});
it("reads all lines from a file", async () => {
const logFile = join(tmpDir, "test.log");
writeFileSync(logFile, "line1\nline2\nline3\n");
const result = await readAllLines(logFile);
expect(result).toEqual(["line1", "line2", "line3"]);
});
it("handles file with no trailing newline", async () => {
const logFile = join(tmpDir, "test.log");
writeFileSync(logFile, "a\nb\nc");
const result = await readAllLines(logFile);
expect(result).toEqual(["a", "b", "c"]);
});
it("returns empty array for empty file", async () => {
const logFile = join(tmpDir, "empty.log");
writeFileSync(logFile, "");
const result = await readAllLines(logFile);
expect(result).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Integration: readAllLines + sliceLogs end-to-end
// ---------------------------------------------------------------------------
describe("readAllLines + sliceLogs integration", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-int-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("tail-paginates a large log file correctly", async () => {
const logFile = join(tmpDir, "big.log");
const content = Array.from({ length: 120 }, (_, i) => `entry ${i + 1}`).join("\n");
writeFileSync(logFile, content);
const all = await readAllLines(logFile);
const page1 = sliceLogs(all, 0, 50); // last 50: lines 71-120
expect(page1.startLine).toBe(71);
expect(page1.endLine).toBe(120);
expect(page1.nextOffset).toBe(21); // max(1, 71-50)
const page2 = sliceLogs(all, page1.nextOffset!, 50); // lines 21-70
expect(page2.startLine).toBe(21);
expect(page2.endLine).toBe(70);
expect(page2.nextOffset).toBe(1); // max(1, 21-50) = 1
const page3 = sliceLogs(all, page2.nextOffset!, 50); // lines 1-50
expect(page3.startLine).toBe(1);
expect(page3.endLine).toBe(50);
expect(page3.nextOffset).toBeNull();
});
});
// ---------------------------------------------------------------------------
// logsCommand: negative offset validation
// ---------------------------------------------------------------------------
describe("logsCommand negative offset", () => {
let stderrOutput: string;
let exitCode: number | undefined;
beforeEach(() => {
stderrOutput = "";
exitCode = undefined;
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
stderrOutput += typeof chunk === "string" ? chunk : chunk.toString();
return true;
});
vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
exitCode = typeof code === "number" ? code : 1;
throw new Error(`process.exit(${exitCode})`);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("exits with code 1 and writes to stderr when offset is negative", async () => {
await expect(
logsCommand.run!({ args: { n: "50", offset: "-5", follow: false }, rawArgs: [], cmd: logsCommand as never }),
).rejects.toThrow("process.exit(1)");
expect(exitCode).toBe(1);
expect(stderrOutput).toContain("--offset must be a non-negative integer");
expect(stderrOutput).toContain("-5");
});
it("exits with code 1 for offset=-1", async () => {
await expect(
logsCommand.run!({ args: { n: "10", offset: "-1", follow: false }, rawArgs: [], cmd: logsCommand as never }),
).rejects.toThrow("process.exit(1)");
expect(exitCode).toBe(1);
});
});
+2
View File
@@ -3,6 +3,7 @@
import { defineCommand, runMain } from "citty";
import { initCommand } from "./commands/init.js";
import { logsCommand } from "./commands/logs.js";
import { startCommand } from "./commands/start.js";
import { statusCommand } from "./commands/status.js";
import { stopCommand } from "./commands/stop.js";
@@ -19,6 +20,7 @@ const main = defineCommand({
start: startCommand,
stop: stopCommand,
status: statusCommand,
logs: logsCommand,
validate: validateCommand,
workflow: workflowCommand,
},
+3 -1
View File
@@ -124,7 +124,7 @@ export function validateWorkflowName(name: string): string | null {
return null;
}
function buildWorkflowTemplate(name: string): string {
export function buildWorkflowTemplate(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
const workflow: WorkflowDefinition = {
@@ -248,6 +248,8 @@ async function runInitWorkspace(force: boolean): Promise<void> {
if (!existsSync(join(nerveRoot, ".git"))) {
try {
await runCommand("git", ["init"], nerveRoot);
await runCommand("git", ["add", "."], nerveRoot);
await runCommand("git", ["commit", "-m", "Initial nerve workspace"], nerveRoot);
} catch {
process.stdout.write("⚠️ git init failed — skipping.\n");
}
+197
View File
@@ -0,0 +1,197 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { createInterface } from "node:readline";
import { defineCommand } from "citty";
import { getLogPath } from "../workspace.js";
export const DEFAULT_LOG_LINES = 50;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* Read all lines from a file. Returns empty array if file does not exist.
*
* TODO: For tail mode (offset=0), avoid reading the whole file into memory by
* seeking to the last N bytes via createReadStream({ start: max(0, size - CHUNK) }).
*/
export async function readAllLines(filePath: string): Promise<string[]> {
if (!existsSync(filePath)) return [];
const lines: string[] = [];
const rl = createInterface({
input: createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const line of rl) {
lines.push(line);
}
return lines;
}
/**
* Slice a log line array respecting offset + limit semantics.
*
* When offset is 0 the function returns the *last* `limit` lines (tail mode).
* When offset > 0 it is treated as a 1-based line number and the slice starts
* there (for pagination of earlier pages from the tail).
*
* Returns the selected lines plus metadata used to build the footer.
*/
export type LogSlice = {
lines: string[];
total: number;
startLine: number; // 1-based, inclusive
endLine: number; // 1-based, inclusive
nextOffset: number | null; // null when no previous page exists
};
export function sliceLogs(allLines: string[], offset: number, limit: number): LogSlice {
const total = allLines.length;
if (total === 0) {
return { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
}
let start: number;
if (offset === 0) {
// Tail mode: last `limit` lines
start = Math.max(0, total - limit);
} else {
// offset is 1-based line number
start = Math.max(0, offset - 1);
}
const end = Math.min(start + limit, total);
const lines = allLines.slice(start, end);
const startLine = start + 1;
const endLine = end;
// nextOffset points to lines *before* current slice (earlier in file)
const nextOffset = start > 0 ? Math.max(1, startLine - limit) : null;
return { lines, total, startLine, endLine, nextOffset };
}
/**
* Build the footer string shown after the log lines.
*/
export function buildLogFooter(slice: LogSlice, nArg: number, logPath: string): string {
if (slice.total === 0) {
return "📭 Log file is empty.\n";
}
const rangeStr = `lines ${slice.startLine}-${slice.endLine} of ${slice.total}`;
let footer = `\n📄 ${rangeStr} | ${logPath}\n`;
if (slice.nextOffset !== null) {
footer += `⏩ Earlier lines available. Fetch previous page:\n`;
footer += ` nerve logs --offset ${slice.nextOffset} -n ${nArg}\n`;
}
return footer;
}
// ---------------------------------------------------------------------------
// nerve logs
// ---------------------------------------------------------------------------
export const logsCommand = defineCommand({
meta: {
name: "logs",
description: "Show daemon log output",
},
args: {
n: {
type: "string",
description: `Number of lines to show (default: ${DEFAULT_LOG_LINES})`,
default: String(DEFAULT_LOG_LINES),
},
offset: {
type: "string",
description: "Start from line N (1-based, for pagination)",
default: "0",
},
follow: {
type: "boolean",
alias: "f",
description: "Stream new log lines in real time",
default: false,
},
},
async run({ args }) {
const logPath = getLogPath();
const nLines = Math.max(1, Number.parseInt(args.n, 10) || DEFAULT_LOG_LINES);
const rawOffset = Number.parseInt(args.offset, 10) || 0;
if (rawOffset < 0) {
process.stderr.write(`❌ --offset must be a non-negative integer, got: ${args.offset}\n`);
process.exit(1);
}
const offset = rawOffset;
if (!existsSync(logPath)) {
process.stderr.write(`❌ Log file not found: ${logPath}\n`);
process.stderr.write(" Has the daemon been started? Try: nerve start\n");
process.exit(1);
}
if (args.follow) {
await followLog(logPath, nLines);
return;
}
const allLines = await readAllLines(logPath);
const slice = sliceLogs(allLines, offset, nLines);
for (const line of slice.lines) {
process.stdout.write(`${line}\n`);
}
process.stdout.write(buildLogFooter(slice, nLines, logPath));
},
});
/**
* Stream new lines from a log file as they are appended.
* Shows the last `tailLines` lines first, then watches for new content.
*/
async function followLog(logPath: string, tailLines: number): Promise<void> {
const allLines = await readAllLines(logPath);
const initial = allLines.slice(Math.max(0, allLines.length - tailLines));
for (const line of initial) {
process.stdout.write(`${line}\n`);
}
let size = statSync(logPath).size;
process.stdout.write(`\n👁 Following ${logPath} — press Ctrl+C to stop\n`);
let stopped = false;
process.once("SIGINT", () => {
stopped = true;
});
while (!stopped) {
await sleep(300);
if (stopped) break;
try {
const newSize = statSync(logPath).size;
if (newSize < size) {
// Log rotation: file was truncated or replaced, read from the beginning
size = 0;
}
if (newSize <= size) continue;
const stream = createReadStream(logPath, { start: size, encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
for await (const line of rl) {
process.stdout.write(`${line}\n`);
}
size = newSize;
} catch {
stopped = true;
}
}
}
+21 -5
View File
@@ -1,7 +1,6 @@
import { createWriteStream } from "node:fs";
import { readFileSync } from "node:fs";
import { createWriteStream, existsSync, readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseNerveConfig } from "@uncaged/nerve-core";
@@ -85,6 +84,23 @@ async function runForeground(nerveRoot: string): Promise<void> {
await kernel.ready;
}
/** Path to the CLI entry script (for spawning `start` without `-d`). */
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 cliPath = candidates.find((p) => existsSync(p));
if (!cliPath) {
throw new Error(`CLI entry not found (searched: ${candidates.join(", ")})`);
}
return cliPath;
}
async function runDaemon(nerveRoot: string): Promise<void> {
if (isRunning()) {
const pid = readPidFile();
@@ -108,9 +124,9 @@ async function runDaemon(nerveRoot: string): Promise<void> {
else resolve();
});
const selfPath = fileURLToPath(import.meta.url);
const cliPath = cliEntryScript();
const child = spawn(process.execPath, [selfPath, "start"], {
const child = spawn(process.execPath, [cliPath, "start"], {
detached: true,
stdio: ["ignore", logStream.fd, logStream.fd],
env: { ...process.env, NERVE_DAEMON_MODE: "1" },
-1
View File
@@ -1,7 +1,6 @@
{
"name": "@uncaged/nerve-core",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+9 -3
View File
@@ -64,9 +64,15 @@ export function createDaemonIpcServer(
return;
}
workflowManager.startWorkflow(req.workflow, req.payload);
const resp: DaemonResponse = { ok: true };
socket.write(`${JSON.stringify(resp)}\n`);
try {
workflowManager.startWorkflow(req.workflow, req.payload);
const resp: DaemonResponse = { ok: true };
socket.write(`${JSON.stringify(resp)}\n`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const resp: DaemonResponse = { ok: false, error: msg };
socket.write(`${JSON.stringify(resp)}\n`);
}
}
const server: Server = createServer((socket) => {