diff --git a/examples/cpu-usage/index.ts b/examples/cpu-usage/index.ts index 690781c..510dc12 100644 --- a/examples/cpu-usage/index.ts +++ b/examples/cpu-usage/index.ts @@ -1,22 +1,17 @@ import { loadavg } from "node:os"; -import type { DrizzleDB, PeerMap } from "@uncaged/nerve-daemon"; +type CpuState = { + samples: Array<{ ts: number; value: number }>; +}; -import { samples } from "./schema.js"; +export const initialState: CpuState = { samples: [] }; -/** - * Read the 1-minute CPU load average, persist it, and emit a Signal. - * - * Returns `null` only if `loadavg` is unavailable (non-POSIX platforms). - * On every successful read a row is inserted and a Signal is emitted with the load value. - */ -export async function compute(db: DrizzleDB, _peers: PeerMap) { +export async function compute(state: CpuState): Promise<{ + state: CpuState; + workflow: null; +}> { const [oneMin] = loadavg(); - - if (typeof oneMin !== "number" || Number.isNaN(oneMin)) { - return null; - } - - await db.insert(samples).values({ ts: Date.now(), value: oneMin }); - return { signal: oneMin, workflow: null }; + const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0; + const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }]; + return { state: { samples: newSamples }, workflow: null }; } diff --git a/examples/cpu-usage/migrations/0001_init.sql b/examples/cpu-usage/migrations/0001_init.sql deleted file mode 100644 index 13f1821..0000000 --- a/examples/cpu-usage/migrations/0001_init.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Migration: 0001_init --- Creates the samples table for the cpu-usage sense. - -CREATE TABLE IF NOT EXISTS samples ( - ts INTEGER PRIMARY KEY, - value REAL NOT NULL -); diff --git a/examples/cpu-usage/schema.ts b/examples/cpu-usage/schema.ts deleted file mode 100644 index 49525e0..0000000 --- a/examples/cpu-usage/schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core"; - -/** - * Each row records one CPU load sample. - * `ts` is the Unix timestamp in milliseconds (primary key, append-only). - * `value` is the 1-minute load average from os.loadavg()[0]. - */ -export const samples = sqliteTable("samples", { - ts: integer("ts").primaryKey(), - value: real("value").notNull(), -}); diff --git a/examples/senses/nerve-health.ts b/examples/senses/nerve-health.ts index 07dd3cd..34b7523 100644 --- a/examples/senses/nerve-health.ts +++ b/examples/senses/nerve-health.ts @@ -2,8 +2,7 @@ * nerve-health — built-in sense that reports daemon health via IPC. * * When running inside a sense worker, this compute function sends a - * "health-request" to the parent kernel process and returns the - * health-response payload as its signal. + * "health-request" to the parent kernel process and merges the response into state. * * Usage in nerve.yaml: * senses: @@ -22,9 +21,23 @@ export type NerveHealth = { workerUptime: number; }; -export async function compute() { +type HealthState = { + lastCheck: number | null; + lastHealth: NerveHealth | null; +}; + +export const initialState: HealthState = { lastCheck: null, lastHealth: null }; + +export async function compute(_state: HealthState): Promise<{ + state: HealthState; + workflow: null; +}> { + void _state; const health = await requestHealthFromKernel(); - return { signal: health, workflow: null }; + return { + state: { lastCheck: Date.now(), lastHealth: health }, + workflow: null, + }; } function requestHealthFromKernel(): Promise { diff --git a/packages/cli/src/__tests__/create-sense.test.ts b/packages/cli/src/__tests__/create-sense.test.ts index 5c4c425..93e0a61 100644 --- a/packages/cli/src/__tests__/create-sense.test.ts +++ b/packages/cli/src/__tests__/create-sense.test.ts @@ -4,12 +4,7 @@ import { describe, expect, it } from "vitest"; -import { - buildSenseIndexTs, - buildSenseMigrationSql, - buildSenseSchemaTs, - validateResourceName, -} from "../commands/create.js"; +import { buildSenseIndexTs, validateResourceName } from "../commands/create.js"; describe("validateSenseName", () => { it("accepts valid ids", () => { @@ -25,39 +20,13 @@ describe("validateSenseName", () => { }); }); -describe("buildSenseSchemaTs", () => { - it("maps kebab-case id to snake table and camel export", () => { - const src = buildSenseSchemaTs("my-sense"); - expect(src).toContain('sqliteTable("my_sense"'); - expect(src).toContain("export const mySense = "); - }); - - it("handles single-segment id", () => { - const src = buildSenseSchemaTs("metrics"); - expect(src).toContain('sqliteTable("metrics"'); - expect(src).toContain("export const metrics = "); - }); -}); - -describe("buildSenseMigrationSql", () => { - it("uses snake_case table name", () => { - expect(buildSenseMigrationSql("disk-io")).toContain("CREATE TABLE IF NOT EXISTS disk_io"); - }); -}); - describe("buildSenseIndexTs", () => { - it("embeds sense id in stub with TypeScript types", () => { + it("generates a stateful sense stub with TypeScript types", () => { const ts = buildSenseIndexTs("my-sense"); - expect(ts).toContain("my-sense"); - expect(ts).toContain("export { mySense as table }"); + expect(ts).toContain("type SenseState"); + expect(ts).toContain("export const initialState"); expect(ts).toContain("export async function compute"); - expect(ts).toContain("LibSQLDatabase"); - expect(ts).toContain("Promise"); - expect(ts).toContain('from "./schema.js"'); - }); - - it("imports the correct schema export", () => { - const ts = buildSenseIndexTs("cpu-usage"); - expect(ts).toContain("cpuUsage"); + expect(ts).toContain("workflow: null"); + expect(ts).toContain("lastRun"); }); }); diff --git a/packages/cli/src/__tests__/e2e-create.test.ts b/packages/cli/src/__tests__/e2e-create.test.ts index 6553351..e82de11 100644 --- a/packages/cli/src/__tests__/e2e-create.test.ts +++ b/packages/cli/src/__tests__/e2e-create.test.ts @@ -147,7 +147,7 @@ describe("e2e create", () => { ); it( - "create sense scaffolds src/, migration, and root build emits dist/senses//index.js", + "create sense scaffolds src/ and root build emits dist/senses//index.js", { timeout: 120_000 }, async () => { fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-")); @@ -162,8 +162,6 @@ describe("e2e create", () => { const base = join(nerveRoot, "senses", "e2e-sense"); expect(existsSync(join(base, "package.json"))).toBe(false); expect(existsSync(join(base, "src", "index.ts"))).toBe(true); - expect(existsSync(join(base, "src", "schema.ts"))).toBe(true); - expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true); expect(existsSync(join(nerveRoot, "dist", "senses", "e2e-sense", "index.js"))).toBe(true); }, ); diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index 58e3536..8c5ddea 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -2,14 +2,7 @@ * Shared E2E harness: temp HOME layout (`.uncaged-nerve`), real kernel + sense worker, * IPC socket for CLI, and `runCommand` helpers with captured stdio. * - * ## Signal persistence (CLI `nerve sense list`) - * - * The kernel appends a `source: "sense", type: "signal"` row to `data/logs.db` when a - * worker emits a signal (see `packages/daemon/src/kernel.ts`). The daemon also - * auto-persists each signal into a `_signals` table in the per-sense SQLite DB - * (see `runtime.persistSignal` in `packages/daemon/src/sense-runtime.ts`). - * `listSenses()` reads `lastSignalTimestamp` from the kernel's in-memory state, - * while `sense query` reads from the `_signals` table (or a user-defined preview table). + * Stateful senses persist JSON under `data/senses/.json` (see sense-worker). * * ## Timeout guard (vitest) * @@ -37,15 +30,7 @@ * ``` */ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -69,27 +54,6 @@ const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.j const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js"); const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js"); -function resolveDrizzleOrmPackageRoot(): string { - const requireFromDaemon = createRequire(join(nerveDaemonRoot, "package.json")); - const entry = requireFromDaemon.resolve("drizzle-orm"); - let dir = dirname(entry); - for (let i = 0; i < 12; i += 1) { - const pkgPath = join(dir, "package.json"); - if (existsSync(pkgPath)) { - try { - const name = (JSON.parse(readFileSync(pkgPath, "utf8")) as { name: string }).name; - if (name === "drizzle-orm") return dir; - } catch { - // keep walking - } - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - throw new Error("Could not resolve drizzle-orm package root for e2e harness"); -} - const nerveYamlTemplate = `senses: counter: group: e2e @@ -150,52 +114,24 @@ api: host: 127.0.0.1 `; -/** Schema for sense signal rows persisted via \`db.insert(table)\` (see sense-runtime). */ -const counterMigration = `CREATE TABLE IF NOT EXISTS counter_signals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - count INTEGER, - launched INTEGER, - idle INTEGER -); -`; +/** Minimal counter sense — each compute increments \`count\` in persisted state. */ +const counterIndexJs = `export const initialState = { count: 0 }; -/** - * Minimal counter sense — each compute returns an incrementing count. - * Does NOT touch the DB directly in compute(); the daemon inserts into \`table\` - * and persistSignal handles \`_signals\`. - */ -const counterIndexJs = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core"; - -export const table = sqliteTable("counter_signals", { - id: integer("id").primaryKey({ autoIncrement: true }), - count: integer("count"), - launched: integer("launched"), - idle: integer("idle"), -}); - -let _count = 0; -export async function compute(_db, _peers, _options) { - _count += 1; - return { signal: { count: _count }, workflow: null }; +export async function compute(state) { + return { + state: { count: state.count + 1 }, + workflow: null, + }; } `; -/** First trigger launches local noop workflow; later triggers emit a plain signal. */ -const counterIndexJsWithNoopWorkflow = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core"; +/** First trigger launches local noop workflow; later triggers only advance idleTicks. */ +const counterIndexJsWithNoopWorkflow = `export const initialState = { launched: false, idleTicks: 0 }; -export const table = sqliteTable("counter_signals", { - id: integer("id").primaryKey({ autoIncrement: true }), - count: integer("count"), - launched: integer("launched"), - idle: integer("idle"), -}); - -let _launched = false; -export async function compute(_db, _peers, _options) { - if (!_launched) { - _launched = true; +export async function compute(state) { + if (!state.launched) { return { - signal: { launched: 1 }, + state: { launched: true, idleTicks: state.idleTicks }, workflow: { name: "noop", maxRounds: 3, @@ -204,7 +140,10 @@ export async function compute(_db, _peers, _options) { }, }; } - return { signal: { idle: 1 }, workflow: null }; + return { + state: { launched: state.launched, idleTicks: state.idleTicks + 1 }, + workflow: null, + }; } `; @@ -241,7 +180,6 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -259,7 +197,6 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig { function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): void { 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, "dist", "senses", "counter"), { recursive: true }); mkdirSync(join(nerveRoot, "dist", "workflows", "echo"), { recursive: true }); writeFileSync( @@ -267,11 +204,6 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate, "utf8", ); - writeFileSync( - join(nerveRoot, "senses", "counter", "migrations", "001.sql"), - counterMigration, - "utf8", - ); writeFileSync( join(nerveRoot, "dist", "senses", "counter", "index.js"), withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs, @@ -325,10 +257,6 @@ export function linkWorkspaceDaemonIntoNerveRoot(nerveRoot: string): void { mkdirSync(linkDir, { recursive: true }); const linkPath = join(linkDir, "nerve-daemon"); if (!existsSync(linkPath)) symlinkSync(daemonPkgRoot, linkPath); - - const drizzlePkgRoot = resolveDrizzleOrmPackageRoot(); - const drizzleLink = join(nm, "drizzle-orm"); - if (!existsSync(drizzleLink)) symlinkSync(drizzlePkgRoot, drizzleLink); } /** diff --git a/packages/cli/src/__tests__/e2e-sense-query.test.ts b/packages/cli/src/__tests__/e2e-sense-query.test.ts deleted file mode 100644 index 0e4f29e..0000000 --- a/packages/cli/src/__tests__/e2e-sense-query.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * E2E: `nerve sense query` against a real daemon + persisted `_signals` (issue #156). - */ - -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { - type TestDaemonHandle, - pollUntil, - runCli, - startTestDaemon, - stopTestDaemon, -} from "./e2e-harness.js"; - -describe("e2e sense query", () => { - let daemon: TestDaemonHandle | null = null; - - afterEach(async () => { - const h = daemon; - daemon = null; - if (h === null) return; - await Promise.race([ - stopTestDaemon(h), - new Promise((_, reject) => - setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000), - ), - ]); - }); - - async function waitForSignalsPersisted(handle: TestDaemonHandle): Promise { - const dbPath = join(handle.nerveRoot, "data", "senses", "counter.db"); - await pollUntil(() => { - if (!existsSync(dbPath)) return false; - try { - const db = new DatabaseSync(dbPath, { readOnly: true }); - const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as - | { cnt: number } - | undefined; - db.close(); - return row !== undefined && row.cnt > 0; - } catch { - return false; - } - }, 10_000); - } - - /** Start daemon, trigger counter, wait until `_signals` has a row. */ - async function startDaemonWithPersistedSignal(): Promise { - const handle = await startTestDaemon(); - const triggerResult = await runCli(handle, ["sense", "trigger", "counter"]); - expect(triggerResult.exitCode).toBe(0); - expect(triggerResult.stdout).toContain("Triggered"); - await waitForSignalsPersisted(handle); - return handle; - } - - it( - "after trigger, persisted _signals and sense query counter returns at least one row", - { timeout: 30_000 }, - async () => { - daemon = await startDaemonWithPersistedSignal(); - - const queryResult = await runCli(daemon, ["sense", "query", "counter"]); - expect(queryResult.exitCode).toBe(0); - expect(queryResult.stdout).not.toContain("(0 rows)"); - }, - ); - - it( - "default sense query output lists payload column and counter count in payload", - { timeout: 30_000 }, - async () => { - daemon = await startDaemonWithPersistedSignal(); - - const queryResult = await runCli(daemon, ["sense", "query", "counter"]); - expect(queryResult.exitCode).toBe(0); - expect(queryResult.stdout).toContain("payload"); - expect(queryResult.stdout).toMatch(/count/); - }, - ); - - it( - "nerve sense query counter --json prints a JSON array with payload on each row", - { timeout: 30_000 }, - async () => { - daemon = await startDaemonWithPersistedSignal(); - - const jsonResult = await runCli(daemon, ["sense", "query", "counter", "--json"]); - expect(jsonResult.exitCode).toBe(0); - const rows = JSON.parse(jsonResult.stdout.trim()) as unknown; - expect(Array.isArray(rows)).toBe(true); - expect(rows.length).toBeGreaterThanOrEqual(1); - for (const row of rows as Record[]) { - expect(Object.keys(row)).toContain("payload"); - } - }, - ); - - it( - "nerve sense query counter --sql runs custom read-only SQL and prints total column", - { timeout: 30_000 }, - async () => { - daemon = await startDaemonWithPersistedSignal(); - - const sqlResult = await runCli(daemon, [ - "sense", - "query", - "counter", - "--sql", - "SELECT count(*) as total FROM _signals", - ]); - expect(sqlResult.exitCode).toBe(0); - expect(sqlResult.stdout).toContain("total"); - expect(sqlResult.stdout).not.toContain("(0 rows)"); - }, - ); -}); diff --git a/packages/cli/src/__tests__/e2e-smoke.test.ts b/packages/cli/src/__tests__/e2e-smoke.test.ts index c00c21c..a0bbbb8 100644 --- a/packages/cli/src/__tests__/e2e-smoke.test.ts +++ b/packages/cli/src/__tests__/e2e-smoke.test.ts @@ -1,8 +1,11 @@ /** * Smoke test: start a real daemon with a counter sense, trigger it, - * then verify CLI commands can list and query the persisted signal. + * then verify CLI lists the sense and state file is written. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + import { afterEach, describe, expect, it } from "vitest"; import { @@ -28,46 +31,27 @@ describe("e2e smoke", () => { ]); }); - it("sense list + sense query after trigger", { timeout: 30_000 }, async () => { + it("sense list after trigger and persisted counter state", { timeout: 30_000 }, async () => { daemon = await startTestDaemon(); - // Trigger counter sense via IPC const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]); expect(triggerResult.exitCode).toBe(0); expect(triggerResult.stdout).toContain("Triggered"); - // Wait for signal to be persisted (_signals table in the sense DB) - const { existsSync } = await import("node:fs"); - const { join } = await import("node:path"); - const { DatabaseSync } = await import("node:sqlite"); - - const dbPath = join(daemon.nerveRoot, "data", "senses", "counter.db"); + const statePath = join(daemon.nerveRoot, "data", "senses", "counter.json"); await pollUntil(() => { - if (!existsSync(dbPath)) return false; try { - const db = new DatabaseSync(dbPath, { readOnly: true }); - const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as - | { cnt: number } - | undefined; - db.close(); - return row !== undefined && row.cnt > 0; + const raw = readFileSync(statePath, "utf8"); + const parsed = JSON.parse(raw) as { count?: number }; + return typeof parsed.count === "number" && parsed.count >= 1; } catch { return false; } }, 10_000); - // nerve sense list — should show counter with a last signal timestamp const listResult = await runCli(daemon, ["sense", "list"]); expect(listResult.exitCode).toBe(0); expect(listResult.stdout).toContain("counter"); - expect(listResult.stdout).toContain("last signal:"); - // Should NOT say "(never)" since we triggered and persisted - expect(listResult.stdout).not.toContain("(never)"); - - // nerve sense query counter — should return rows from _signals - const queryResult = await runCli(daemon, ["sense", "query", "counter"]); - expect(queryResult.exitCode).toBe(0); - // Should have actual data rows (not "(0 rows)") - expect(queryResult.stdout).not.toContain("(0 rows)"); + expect(listResult.stdout).not.toContain("last signal"); }); }); diff --git a/packages/cli/src/__tests__/e2e-validate-init.test.ts b/packages/cli/src/__tests__/e2e-validate-init.test.ts index a9fa58e..4197efd 100644 --- a/packages/cli/src/__tests__/e2e-validate-init.test.ts +++ b/packages/cli/src/__tests__/e2e-validate-init.test.ts @@ -210,10 +210,6 @@ describe("e2e init", () => { expect(agentMd).toContain("verb-first"); expect(agentMd).toContain("createRole"); expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"))).toBe(true); - expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"))).toBe(true); - expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"))).toBe( - true, - ); expect(existsSync(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"))).toBe(true); const pkgJson = readFileSync(join(nerveRoot, "package.json"), "utf8"); diff --git a/packages/cli/src/__tests__/sense-list-e2e.test.ts b/packages/cli/src/__tests__/sense-list-e2e.test.ts index 196d978..5538a39 100644 --- a/packages/cli/src/__tests__/sense-list-e2e.test.ts +++ b/packages/cli/src/__tests__/sense-list-e2e.test.ts @@ -27,15 +27,12 @@ describe("nerve sense list CLI (runCommand + temp HOME + mock IPC socket)", () = let stdoutSpy: ReturnType> | null; let listSensesRequests: unknown[]; - const LAST_SIGNAL_TS = 1_714_521_600_000; // fixed wall time for stable ISO in assertions - const daemonSenseRow: SenseInfo = { name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, triggers: ["every 5s"], - lastSignalTimestamp: LAST_SIGNAL_TS, }; function nerveYamlFixture(): string { @@ -119,9 +116,7 @@ senses: rmSync(sockDir, { recursive: true, force: true }); }); - it("prints sense list from daemon path with name, group, throttle, trigger schedule, and last signal time", async () => { - // With a real daemon, we would wait for a compute cycle; the mock server - // returns SenseInfo as if one already produced lastSignalTimestamp. + it("prints sense list from daemon path with name, group, throttle, and trigger schedule", async () => { await runCommand(senseCommand, { rawArgs: ["list"] }); expect(listSensesRequests).toHaveLength(1); @@ -133,7 +128,6 @@ senses: expect(out).toContain("throttle: 5s"); expect(out).toContain("timeout: 3s"); expect(out).toContain("trigger schedule: every 5s"); - expect(out).not.toContain("(never)"); - expect(out).toContain(new Date(LAST_SIGNAL_TS).toISOString()); + expect(out).not.toContain("last signal"); }); }); diff --git a/packages/cli/src/__tests__/sense-list.test.ts b/packages/cli/src/__tests__/sense-list.test.ts index 22f5c20..d5f1217 100644 --- a/packages/cli/src/__tests__/sense-list.test.ts +++ b/packages/cli/src/__tests__/sense-list.test.ts @@ -30,7 +30,6 @@ const SAMPLE_SENSES: SenseInfo[] = [ throttle: 5000, timeout: 3000, triggers: ["every 30s", "on: cpu-threshold"], - lastSignalTimestamp: 1_700_000_000_000, }, { name: "disk-usage", @@ -38,7 +37,6 @@ const SAMPLE_SENSES: SenseInfo[] = [ throttle: 30000, timeout: null, triggers: [], - lastSignalTimestamp: null, }, { name: "active-tasks", @@ -46,7 +44,6 @@ const SAMPLE_SENSES: SenseInfo[] = [ throttle: 10000, timeout: 30000, triggers: ["every 1m"], - lastSignalTimestamp: null, }, ]; @@ -122,15 +119,9 @@ describe("formatSenseList", () => { expect(output).toContain("(none)"); }); - it("shows '(never)' when lastSignalTimestamp is null", () => { + it("does not include last signal line", () => { const output = formatSenseList(SAMPLE_SENSES); - expect(output).toContain("(never)"); - }); - - it("shows ISO timestamp when lastSignalTimestamp is set", () => { - const output = formatSenseList(SAMPLE_SENSES); - // cpu-usage has lastSignalTimestamp = 1_700_000_000_000 - expect(output).toContain(new Date(1_700_000_000_000).toISOString()); + expect(output).not.toContain("last signal"); }); }); @@ -181,29 +172,13 @@ senses: expect(result[0]).toMatchObject({ name: "cpu-usage", group: "system", - lastSignalTimestamp: null, }); expect(result[1]).toMatchObject({ name: "disk-usage", group: "system", - lastSignalTimestamp: null, }); }); - it("always sets lastSignalTimestamp to null (static fallback)", () => { - const path = join(tmpDir, "nerve.yaml"); - writeFileSync( - path, - ` -senses: - my-sense: - group: default -`.trim(), - ); - const result = sensesFromConfig(path); - expect(result[0].lastSignalTimestamp).toBeNull(); - }); - it("populates throttle and timeout from config", () => { const path = join(tmpDir, "nerve.yaml"); writeFileSync( @@ -292,7 +267,6 @@ describe("listSensesViaDaemon", () => { throttle: 5000, timeout: 3000, triggers: [], - lastSignalTimestamp: 12345, }, ]; const server = createServer((s) => { diff --git a/packages/cli/src/__tests__/sense-schema-e2e.test.ts b/packages/cli/src/__tests__/sense-schema-e2e.test.ts deleted file mode 100644 index c5df305..0000000 --- a/packages/cli/src/__tests__/sense-schema-e2e.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * E2E-style tests for `nerve sense schema` with a temp HOME and a real sense SQLite file. - * `getNerveRoot()` uses `os.homedir()`, which respects `process.env.HOME` on POSIX. - */ - -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { runCommand } from "citty"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { senseCommand } from "../commands/sense.js"; - -const SENSE_NAME = "e2e-schema-sense"; - -function createFakeSenseDb(nerveRoot: string): void { - const sensesDir = join(nerveRoot, "data", "senses"); - mkdirSync(sensesDir, { recursive: true }); - const dbPath = join(sensesDir, `${SENSE_NAME}.db`); - const db = new DatabaseSync(dbPath); - db.exec( - "CREATE TABLE _signals(id INTEGER PRIMARY KEY, sense TEXT, timestamp INTEGER, payload TEXT)", - ); - db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)"); - db.close(); -} - -describe("nerve sense schema CLI (runCommand + temp HOME)", () => { - let prevHome: string | undefined; - let fakeHome: string; - let stdoutSpy: ReturnType | null; - let capturedStdout: string; - - beforeEach(() => { - capturedStdout = ""; - stdoutSpy = vi - .spyOn(process.stdout, "write") - .mockImplementation( - (chunk: string | Uint8Array, enc?: BufferEncoding, cb?: (err?: Error | null) => void) => { - if (typeof chunk === "string") { - capturedStdout += chunk; - } else { - capturedStdout += Buffer.from(chunk).toString(typeof enc === "string" ? enc : "utf8"); - } - if (typeof cb === "function") { - cb(); - } - return true; - }, - ); - - prevHome = process.env.HOME; - fakeHome = mkdtempSync(join(tmpdir(), "nerve-sense-schema-e2e-")); - process.env.HOME = fakeHome; - - const nerveRoot = join(fakeHome, ".uncaged-nerve"); - createFakeSenseDb(nerveRoot); - }); - - afterEach(() => { - stdoutSpy?.mockRestore(); - stdoutSpy = null; - if (prevHome === undefined) { - // biome-ignore lint/performance/noDelete: semantically correct for env cleanup - delete process.env.HOME; - } else { - process.env.HOME = prevHome; - } - rmSync(fakeHome, { recursive: true, force: true }); - }); - - it("prints CREATE TABLE statements for the sense database", async () => { - await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] }); - expect(capturedStdout).toMatch(/CREATE TABLE/i); - }); - - it("includes the _signals table in output", async () => { - await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] }); - expect(capturedStdout).toContain("_signals"); - }); - - it("with --json prints a valid JSON array of SQL strings", async () => { - await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME, "--json"] }); - const parsed: unknown = JSON.parse(capturedStdout.trim()); - expect(Array.isArray(parsed)).toBe(true); - const arr = parsed as unknown[]; - expect(arr.length).toBeGreaterThanOrEqual(1); - for (const item of arr) { - expect(typeof item).toBe("string"); - expect(item).toMatch(/CREATE TABLE/i); - } - const joined = arr.join("\n"); - expect(joined).toContain("_signals"); - }); -}); diff --git a/packages/cli/src/__tests__/sense-sqlite.test.ts b/packages/cli/src/__tests__/sense-sqlite.test.ts deleted file mode 100644 index 4825b9b..0000000 --- a/packages/cli/src/__tests__/sense-sqlite.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`. - */ - -import { mkdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { - assertSenseDbExists, - collectColumnKeys, - defaultPreviewSql, - formatRowsAsAlignedTable, - listTableSqlStatements, - parseSenseQueryArgs, - pickDefaultPreviewTable, - senseDbPath, -} from "../sense-sqlite.js"; - -let tmpDir: string; - -beforeEach(() => { - tmpDir = join( - tmpdir(), - `nerve-sense-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`, - ); - mkdirSync(join(tmpDir, "data", "senses"), { recursive: true }); -}); - -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe("senseDbPath", () => { - it("points at data/senses/.db under the given root", () => { - expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db")); - }); -}); - -describe("assertSenseDbExists", () => { - it("throws when the file is missing", () => { - expect(() => assertSenseDbExists(tmpDir, "nope")).toThrow(/No database at/); - }); - - it("returns the path when the file exists", () => { - const p = join(tmpDir, "data", "senses", "x.db"); - new DatabaseSync(p).close(); - expect(assertSenseDbExists(tmpDir, "x")).toBe(p); - }); -}); - -describe("listTableSqlStatements", () => { - it("returns CREATE statements ordered by tbl_name", () => { - const p = join(tmpDir, "data", "senses", "t.db"); - const db = new DatabaseSync(p); - db.exec("CREATE TABLE zebra (id INTEGER)"); - db.exec("CREATE TABLE alpha (id INTEGER)"); - const stmts = listTableSqlStatements(db); - db.close(); - expect(stmts).toHaveLength(2); - expect(stmts[0]).toMatch(/^CREATE TABLE alpha/i); - expect(stmts[1]).toMatch(/^CREATE TABLE zebra/i); - }); -}); - -describe("pickDefaultPreviewTable", () => { - it("prefers non-_migrations tables when both exist", () => { - const p = join(tmpDir, "data", "senses", "t.db"); - const db = new DatabaseSync(p); - db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)"); - db.exec("CREATE TABLE readings (id INTEGER)"); - expect(pickDefaultPreviewTable(db)).toBe("readings"); - db.close(); - }); - - it("uses _migrations when it is the only table", () => { - const p = join(tmpDir, "data", "senses", "t.db"); - const db = new DatabaseSync(p); - db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)"); - expect(pickDefaultPreviewTable(db)).toBe("_migrations"); - db.close(); - }); -}); - -describe("defaultPreviewSql", () => { - it("quotes identifiers for SQL safety", () => { - expect(defaultPreviewSql(`weird"name`)).toContain(`weird""name`); - }); -}); - -describe("parseSenseQueryArgs", () => { - it("parses sense name only", () => { - expect(parseSenseQueryArgs(["cpu"])).toEqual({ name: "cpu", sql: undefined }); - }); - - it("strips --json", () => { - expect(parseSenseQueryArgs(["cpu", "--json"])).toEqual({ name: "cpu", sql: undefined }); - expect(parseSenseQueryArgs(["--json", "cpu"])).toEqual({ name: "cpu", sql: undefined }); - }); - - it("joins remaining tokens into SQL", () => { - expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" }); - }); - - it("uses --sql value instead of positional SQL", () => { - expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT 2"])).toEqual({ - name: "cpu", - sql: "SELECT 2", - }); - expect(parseSenseQueryArgs(["cpu", "--sql=SELECT 3"])).toEqual({ - name: "cpu", - sql: "SELECT 3", - }); - }); - - it("prefers --sql over trailing positional SQL", () => { - expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT a", "SELECT b"])).toEqual({ - name: "cpu", - sql: "SELECT a", - }); - }); - - it("throws when --sql has no value", () => { - expect(() => parseSenseQueryArgs(["cpu", "--sql"])).toThrow(/Missing value for --sql/); - }); - - it("throws when name is missing", () => { - expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/); - }); -}); - -describe("formatRowsAsAlignedTable", () => { - it("shows empty marker for no rows", () => { - expect(formatRowsAsAlignedTable([])).toContain("(0 rows)"); - }); - - it("aligns columns from row data", () => { - const out = formatRowsAsAlignedTable([ - { a: 1, b: "x" }, - { a: 22, b: "yy" }, - ]); - expect(out).toContain("a"); - expect(out).toContain("b"); - expect(out).toContain("22"); - }); -}); - -describe("collectColumnKeys", () => { - it("preserves key order from first row then appends new keys", () => { - expect( - collectColumnKeys([ - { z: 1, a: 2 }, - { a: 3, b: 4 }, - ]), - ).toEqual(["z", "a", "b"]); - }); -}); - -describe("readonly query integration", () => { - it("runs default preview SQL on a real db", () => { - const p = join(tmpDir, "data", "senses", "demo.db"); - const rw = new DatabaseSync(p); - rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)"); - rw.exec("INSERT INTO items (v) VALUES ('a'), ('b')"); - rw.close(); - - const db = new DatabaseSync(p, { readOnly: true }); - const table = pickDefaultPreviewTable(db); - expect(table).toBe("items"); - if (table === null) { - throw new Error("expected items table"); - } - const sql = defaultPreviewSql(table); - const rows = db.prepare(sql).all() as Record[]; - db.close(); - expect(rows.length).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 6480331..48a6179 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -85,76 +85,26 @@ and optionally load this file at runtime if you keep prompts outside code. `; } -function senseIdToSqlTableName(id: string): string { - return id.replaceAll("-", "_"); -} +export function buildSenseIndexTs(_senseId: string): string { + return `type SenseState = { + lastRun: number | null; +}; -function senseIdToSchemaExportName(id: string): string { - const parts = id.split("-"); - return parts - .map((part, index) => - index === 0 ? part : part.length === 0 ? "" : part.charAt(0).toUpperCase() + part.slice(1), - ) - .join(""); -} +export const initialState: SenseState = { lastRun: null }; -export function buildSenseSchemaTs(senseId: string): string { - const table = senseIdToSqlTableName(senseId); - const exportName = senseIdToSchemaExportName(senseId); - return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; - -export const ${exportName} = sqliteTable("${table}", { - id: integer("id").primaryKey({ autoIncrement: true }), - ts: integer("ts").notNull(), - label: text("label").notNull(), -}); -`; -} - -export function buildSenseIndexTs(senseId: string): string { - const exportName = senseIdToSchemaExportName(senseId); - return `import type { LibSQLDatabase } from "drizzle-orm/libsql"; - -import { ${exportName} } from "./schema.js"; - -export { ${exportName} as table } from "./schema.js"; - -type SenseResult = { - signal: { label: string; ts: number }; +export async function compute(state: SenseState): Promise<{ + state: SenseState; workflow: null; -} | null; - -/** - * ${senseId} — replace this stub with your sampling logic. - * Returns non-null to emit a signal, null to stay silent. - */ -export async function compute( - db: LibSQLDatabase, - _peers: Record, - _options: { signal: AbortSignal }, -): Promise { - void ${exportName}; +}> { + // TODO: implement sense logic return { - signal: { - label: "${senseId}", - ts: Date.now(), - }, + state: { lastRun: Date.now() }, workflow: null, }; } `; } -export function buildSenseMigrationSql(senseId: string): string { - const table = senseIdToSqlTableName(senseId); - return `CREATE TABLE IF NOT EXISTS ${table} ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts INTEGER NOT NULL, - label TEXT NOT NULL -); -`; -} - function writeFile(filePath: string, content: string): void { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, content, "utf8"); @@ -278,15 +228,10 @@ const createSenseCommand = defineCommand({ } mkdirSync(join(senseDir, "src"), { recursive: true }); - mkdirSync(join(senseDir, "migrations"), { recursive: true }); writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name)); - writeFile(join(senseDir, "src", "schema.ts"), buildSenseSchemaTs(args.name)); - writeFile(join(senseDir, "migrations", "0001_init.sql"), buildSenseMigrationSql(args.name)); process.stdout.write("✅ Sense scaffolded:\n"); process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`); - process.stdout.write(` ${join(senseDir, "src", "schema.ts")}\n`); - process.stdout.write(` ${join(senseDir, "migrations", "0001_init.sql")}\n`); process.stdout.write("\nBuilding workspace (senses + workflows)…\n"); try { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 4c114f3..c00914c 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -54,13 +54,11 @@ const PACKAGE_JSON = `${JSON.stringify( dependencies: { "@uncaged/nerve-core": "latest", "@uncaged/nerve-daemon": "latest", - "drizzle-orm": "latest", zod: "^4.3.6", }, devDependencies: { "@biomejs/biome": "latest", "@types/node": "^22.0.0", - "drizzle-kit": "latest", esbuild: "^0.27.0", typescript: "^5.7.0", }, @@ -139,9 +137,8 @@ This file is created by \`nerve init\`. Read it before implementing senses or wo | \`nerve.yaml\` | Senses, workflows, intervals, groups | | \`package.json\` | Single root package — no per-sense/per-workflow packages | | \`scripts/build.mjs\` | Root esbuild step; output under \`dist/\` | -| \`senses//src/index.ts\` | Sense \`compute()\` entry | -| \`senses//src/schema.ts\` | Drizzle SQLite schema (TypeScript) | -| \`senses//migrations/*.sql\` | SQL migrations (next to \`src/\`, not inside it) | +| \`senses//src/index.ts\` | Sense \`compute()\` + \`initialState\` | +| \`data/senses/.json\` | Persisted sense state (written by the daemon) | | \`workflows//index.ts\` | Default export: \`WorkflowDefinition\` | | \`workflows//roles/.ts\` | One TypeScript file per role | | \`dist/senses//index.js\` | Bundled sense (after build) | @@ -217,58 +214,25 @@ There is no separate npm package for skills in the default workspace. To align w const execFileAsync = promisify(execFile); -const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +const CPU_INDEX_TS = `import { loadavg } from "node:os"; -export const cpuUsage = sqliteTable("cpu_usage", { - id: integer("id").primaryKey({ autoIncrement: true }), - ts: integer("ts").notNull(), - model: text("model").notNull(), - loadPercent: real("load_percent").notNull(), -}); -`; - -const CPU_INDEX_TS = `import { cpus } from "node:os"; - -export { cpuUsage as table } from "./schema.js"; - -type SenseResult = { - signal: { model: string; loadPercent: number; ts: number }; - workflow: null; +type CpuState = { + samples: Array<{ ts: number; value: number }>; }; -export async function compute(): Promise { - const cpuList = cpus(); +export const initialState: CpuState = { samples: [] }; - let totalIdle = 0; - let totalTick = 0; - for (const cpu of cpuList) { - for (const [, time] of Object.entries(cpu.times)) { - totalTick += time; - } - totalIdle += cpu.times.idle; - } - - const loadPercent = totalTick === 0 ? 0 : ((totalTick - totalIdle) / totalTick) * 100; - - return { - signal: { - model: cpuList[0]?.model ?? "unknown", - loadPercent: Math.round(loadPercent * 100) / 100, - ts: Date.now(), - }, - workflow: null, - }; +export async function compute(state: CpuState): Promise<{ + state: CpuState; + workflow: null; +}> { + const [oneMin] = loadavg(); + const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0; + const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }]; + return { state: { samples: newSamples }, workflow: null }; } `; -const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts INTEGER NOT NULL, - model TEXT NOT NULL, - load_percent REAL NOT NULL -); -`; - function writeFile(filePath: string, content: string): void { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, content, "utf8"); @@ -419,7 +383,6 @@ async function runInitWorkspace(force: boolean, skipInstall = false): Promise 0 ? s.triggers.join("; ") : "(none)"}\n`, ); - const lastSignal = - s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)"; - lines.push(` last signal: ${lastSignal}\n`); } return lines.join(""); } @@ -76,7 +59,6 @@ export function sensesFromConfig(configPath: string): SenseInfo[] { throttle: cfg.throttle, timeout: cfg.timeout, triggers: senseTriggerLabels(name, senses), - lastSignalTimestamp: null, })); } @@ -92,7 +74,7 @@ const senseListCommand = defineCommand({ async run() { if (!isRemoteDaemonCli() && !isRunning()) { process.stderr.write( - "⚠️ Daemon is not running — showing static config only (no last signal time).\n\n", + "⚠️ Daemon is not running — showing static config from nerve.yaml only.\n\n", ); const configPath = join(getNerveRoot(), "nerve.yaml"); const senses = sensesFromConfig(configPath); @@ -154,116 +136,6 @@ const senseTriggerCommand = defineCommand({ }, }); -// --------------------------------------------------------------------------- -// nerve sense schema -// --------------------------------------------------------------------------- - -const senseSchemaCommand = defineCommand({ - meta: { - name: "schema", - description: "Print CREATE TABLE statements from a sense SQLite database", - }, - args: { - name: { - type: "positional", - description: "Sense name (data/senses/.db under the nerve workspace)", - }, - json: { - type: "boolean", - description: "Print JSON array of CREATE TABLE SQL strings", - default: false, - }, - }, - async run({ args }) { - const nerveRoot = getNerveRoot(); - let db: DatabaseSync | undefined; - try { - db = openSenseDb(nerveRoot, args.name); - const statements = listTableSqlStatements(db); - if (args.json) { - process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`); - } else if (statements.length === 0) { - process.stdout.write("(no tables)\n"); - } else { - for (const sql of statements) { - process.stdout.write(`${sql};\n\n`); - } - } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`❌ ${msg}\n`); - process.exit(1); - } finally { - db?.close(); - } - }, -}); - -// --------------------------------------------------------------------------- -// nerve sense query [sql...] -// --------------------------------------------------------------------------- - -const senseQueryCommand = defineCommand({ - meta: { - name: "query", - description: - 'Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name, or use --sql "…".', - }, - args: { - name: { - type: "positional", - description: "Sense name (data/senses/.db under the nerve workspace)", - }, - json: { - type: "boolean", - description: "Print result rows as JSON", - default: false, - }, - }, - async run({ args, rawArgs }) { - const nerveRoot = getNerveRoot(); - let db: DatabaseSync | undefined; - try { - let parsed: { name: string; sql: string | undefined }; - try { - parsed = parseSenseQueryArgs(rawArgs); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`❌ ${msg}\n`); - process.exit(1); - } - - db = openSenseDb(nerveRoot, args.name); - - let sql = parsed.sql?.trim(); - if (!sql) { - const table = pickDefaultPreviewTable(db); - if (table === null) { - process.stderr.write("❌ No tables found in database.\n"); - process.exit(1); - } else { - sql = defaultPreviewSql(table); - } - } - - const rawRows: unknown[] = db.prepare(sql).all(); - const rows: Record[] = rawRows.filter(isPlainRecord); - - if (args.json) { - process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); - } else { - process.stdout.write(formatRowsAsAlignedTable(rows)); - } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`❌ ${msg}\n`); - process.exit(1); - } finally { - db?.close(); - } - }, -}); - // --------------------------------------------------------------------------- // nerve sense (parent command) // --------------------------------------------------------------------------- @@ -276,7 +148,5 @@ export const senseCommand = defineCommand({ subCommands: { list: senseListCommand, trigger: senseTriggerCommand, - schema: senseSchemaCommand, - query: senseQueryCommand, }, }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 0c4109f..b935a43 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -97,6 +97,5 @@ export const statusCommand = defineCommand({ process.stdout.write( ` workers: ${workerGroups.length > 0 ? workerGroups.join(", ") : "(none)"}\n`, ); - process.stdout.write(" signals: (pending SignalBus persistence)\n"); }, }); diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index a0229b0..4c7cc31 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -8,7 +8,7 @@ import { stringify } from "yaml"; import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store"; import { isRemoteDaemonCli } from "../cli-global.js"; import { resolveDaemonTransport } from "../daemon-client.js"; -import { formatRowsAsAlignedTable } from "../sense-sqlite.js"; +import { formatRowsAsAlignedTable } from "../table-format.js"; import { loadDaemonModule } from "../workspace-daemon.js"; import { getNerveRoot, isRunning } from "../workspace.js"; diff --git a/packages/cli/src/sense-sqlite.ts b/packages/cli/src/sense-sqlite.ts deleted file mode 100644 index 3e09afd..0000000 --- a/packages/cli/src/sense-sqlite.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -/** SQLite path for a sense under the nerve workspace root. */ -export function senseDbPath(nerveRoot: string, senseName: string): string { - return join(nerveRoot, "data", "senses", `${senseName}.db`); -} - -export function assertSenseDbExists(nerveRoot: string, senseName: string): string { - const path = senseDbPath(nerveRoot, senseName); - if (!existsSync(path)) { - throw new Error(`No database at ${path}`); - } - return path; -} - -/** Open a sense SQLite database in readonly mode using node:sqlite. */ -export function openSenseDb(nerveRoot: string, senseName: string): DatabaseSync { - const path = assertSenseDbExists(nerveRoot, senseName); - return new DatabaseSync(path, { readOnly: true }); -} - -/** `SELECT sql FROM sqlite_master WHERE type='table'` (non-null sql only). */ -export function listTableSqlStatements(db: DatabaseSync): string[] { - const rows = db - .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`, - ) - .all() as { sql: string }[]; - return rows.map((r) => r.sql); -} - -/** - * Table used for `nerve sense query ` with no SQL. - * Prefers real data tables over `_migrations`, then lexicographic by name. - */ -export function pickDefaultPreviewTable(db: DatabaseSync): string | null { - const row = db - .prepare( - `SELECT name FROM sqlite_master - WHERE type = 'table' AND sql IS NOT NULL - AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' - ORDER BY - CASE WHEN name = '_signals' THEN 0 - WHEN name = '_migrations' THEN 2 - ELSE 1 END, - name - LIMIT 1`, - ) - .get() as { name: string } | undefined; - return row?.name ?? null; -} - -export function defaultPreviewSql(table: string): string { - return `SELECT * FROM "${table.replace(/"/g, '""')}" ORDER BY rowid DESC LIMIT 10`; -} - -function endIndexAfterGenericDashArg(rawArgs: string[], i: number): number { - const a = rawArgs[i]; - const eq = a.indexOf("="); - if (eq === -1 && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) { - return i + 1; - } - return i; -} - -type SenseQueryArgStep = { - nextIndex: number; - flagSql: string | undefined; - positionalToken: string | null; -}; - -function nextSenseQueryArgStep(rawArgs: string[], i: number): SenseQueryArgStep { - const a = rawArgs[i]; - if (a === "--json" || a === "--no-json") { - return { nextIndex: i, flagSql: undefined, positionalToken: null }; - } - if (a.startsWith("--sql=")) { - return { nextIndex: i, flagSql: a.slice("--sql=".length), positionalToken: null }; - } - if (a === "--sql") { - if (i + 1 >= rawArgs.length || rawArgs[i + 1].startsWith("-")) { - throw new Error("Missing value for --sql"); - } - return { nextIndex: i + 1, flagSql: rawArgs[i + 1], positionalToken: null }; - } - if (a.startsWith("-")) { - return { - nextIndex: endIndexAfterGenericDashArg(rawArgs, i), - flagSql: undefined, - positionalToken: null, - }; - } - return { nextIndex: i, flagSql: undefined, positionalToken: a }; -} - -/** Parse sense name and optional SQL from subcommand raw argv (flags stripped). */ -export function parseSenseQueryArgs(rawArgs: string[]): { name: string; sql: string | undefined } { - let flagSql: string | undefined; - const pos: string[] = []; - let i = 0; - while (i < rawArgs.length) { - const step = nextSenseQueryArgStep(rawArgs, i); - if (step.flagSql !== undefined) { - flagSql = step.flagSql; - } - if (step.positionalToken !== null) { - pos.push(step.positionalToken); - } - i = step.nextIndex + 1; - } - if (pos.length < 1) { - throw new Error("Missing sense name"); - } - const name = pos[0]; - const positionalSql = pos.length > 1 ? pos.slice(1).join(" ") : undefined; - const trimmedFlag = flagSql?.trim(); - const sql = trimmedFlag !== undefined && trimmedFlag.length > 0 ? trimmedFlag : positionalSql; - return { name, sql }; -} - -function stringifyCell(value: unknown): string { - if (value === null || value === undefined) return ""; - if (typeof value === "bigint") return value.toString(); - if (typeof value === "number" || typeof value === "boolean") return String(value); - if (typeof value === "string") return value; - if (Buffer.isBuffer(value)) return value.toString("hex"); - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -/** Collect column keys in stable order (first row keys, then any extras). */ -export function collectColumnKeys(rows: Record[]): string[] { - const keys: string[] = []; - const seen = new Set(); - for (const row of rows) { - for (const k of Object.keys(row)) { - if (!seen.has(k)) { - seen.add(k); - keys.push(k); - } - } - } - return keys; -} - -const MAX_CELL = 64; - -function truncate(s: string): string { - if (s.length <= MAX_CELL) return s; - return `${s.slice(0, MAX_CELL - 1)}…`; -} - -/** Plain aligned table for terminal output. */ -export function formatRowsAsAlignedTable(rows: Record[]): string { - if (rows.length === 0) { - return "(0 rows)\n"; - } - const cols = collectColumnKeys(rows); - const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c])))); - const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length))); - const sep = widths.map((w) => "-".repeat(w)).join("-+-"); - const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | "); - const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n"); - return `${header}\n${sep}\n${body}\n`; -} diff --git a/packages/cli/src/table-format.ts b/packages/cli/src/table-format.ts new file mode 100644 index 0000000..af3ef34 --- /dev/null +++ b/packages/cli/src/table-format.ts @@ -0,0 +1,48 @@ +function stringifyCell(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString("hex"); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** Collect column keys in stable order (first row keys, then any extras). */ +export function collectColumnKeys(rows: Record[]): string[] { + const keys: string[] = []; + const seen = new Set(); + for (const row of rows) { + for (const k of Object.keys(row)) { + if (!seen.has(k)) { + seen.add(k); + keys.push(k); + } + } + } + return keys; +} + +const MAX_CELL = 64; + +function truncate(s: string): string { + if (s.length <= MAX_CELL) return s; + return `${s.slice(0, MAX_CELL - 1)}…`; +} + +/** Plain aligned table for terminal output. */ +export function formatRowsAsAlignedTable(rows: Record[]): string { + if (rows.length === 0) { + return "(0 rows)\n"; + } + const cols = collectColumnKeys(rows); + const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c])))); + const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length))); + const sep = widths.map((w) => "-".repeat(w)).join("-+-"); + const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | "); + const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n"); + return `${header}\n${sep}\n${body}\n`; +}