diff --git a/CLAUDE.md b/CLAUDE.md index 3fab793..de139cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,9 @@ The agentage CLI. Versioning RESTARTED at 0.0.x (old npm versions were unpublished; earliest burned slot is 0.1.19 - stay below it until the line naturally -passes). Commands: `setup` (OAuth sign-in) + `status` only. The old -agent-runtime CLI (daemon, run/agents/machines/...) lives in git history only - do not -resurrect patterns from it. +passes). Commands: `setup` (OAuth sign-in), `status`, `vault`, `memory`, `daemon`. The old +agent-runtime CLI (run/agents/machines/...) lives in git history only - do not resurrect +its agent-runtime patterns (the local memory daemon was deliberately ported from it). ## Layout - `src/cli.ts` - commander entry (excluded from coverage; keep logic out of it) @@ -14,8 +14,14 @@ resurrect patterns from it. status-info - `src/lib/memory-client.ts` - the memory-verb seam; DirectClient wraps `@agentage/memory-core` (the one local engine: git-per-vault backends + federation router). No FTS5/SQLite. -- `src/package-guard.test.ts` - CI guard: no daemon/agent-runtime remnants, runtime deps - stay exactly `@agentage/memory-core + chalk + commander + open` +- `src/lib/daemon-client.ts` - DaemonClient (MemoryClient over loopback HTTP) + `ensureDaemon` + autostart; verbs default to the daemon, DirectClient fallback (`--no-daemon`, + `AGENTAGE_NO_DAEMON=1`, or fork blocked) +- `src/daemon/` + `src/daemon-entry.ts` - the local daemon (node:http, 127.0.0.1 only): one + in-process engine serialises vault mutations; `agentage daemon start|stop|status` +- `src/package-guard.test.ts` - CI guard: no agent-runtime remnants (express/ws/sqlite/ + core/platform/supabase), runtime deps stay exactly `@agentage/memory-core + chalk + + commander + open` ## Auth model Fresh DCR public client per `setup` run (the redirect URI binds the ephemeral callback diff --git a/e2e/daemon.test.ts b/e2e/daemon.test.ts new file mode 100644 index 0000000..8e82cb0 --- /dev/null +++ b/e2e/daemon.test.ts @@ -0,0 +1,127 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { assertCliBuilt, createCliMachine, freePort, type CliMachine } from './helpers.js'; + +// M2.5 daemon tier: an explicitly managed daemon on an ephemeral port + isolated config dir owns +// the engine; the memory verbs route through it (proven by its served counter). Never touches a +// real daemon: the OS-picked port + AGENTAGE_CONFIG_DIR isolate everything, and stop only +// signals the pid this test started. @p0 + +const pidOf = (m: CliMachine): number | null => { + const p = join(m.configDir, 'daemon.pid'); + return existsSync(p) ? Number.parseInt(readFileSync(p, 'utf-8').trim(), 10) : null; +}; + +const alive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const servedCount = (statusOut: string): number => { + const m = statusOut.match(/served\s+(\d+)/); + return m ? Number.parseInt(m[1], 10) : -1; +}; + +test.describe('daemon owns the engine @p0', () => { + test.beforeAll(() => assertCliBuilt()); + + test('start -> six verbs through the daemon -> stop', async () => { + const port = await freePort(); + // Re-enable the daemon path (helpers default it off) on the isolated port. + const m = createCliMachine({ AGENTAGE_NO_DAEMON: '', AGENTAGE_DAEMON_PORT: String(port) }); + let daemonPid: number | null = null; + try { + const add = await m.exec(['vault', 'add', 'main', '--local', join(m.configDir, 'main')]); + expect(add.code, add.stderr).toBe(0); + + const start = await m.exec(['daemon', 'start']); + expect(start.code, start.stderr).toBe(0); + expect(start.stdout).toContain('started'); + daemonPid = pidOf(m); + expect(daemonPid, 'daemon.pid written').not.toBeNull(); + expect(alive(daemonPid!)).toBe(true); + + const before = await m.exec(['daemon', 'status']); + expect(before.code, before.stderr).toBe(0); + expect(before.stdout).toContain(`port ${port}`); + const served0 = servedCount(before.stdout); + expect(served0).toBeGreaterThanOrEqual(0); + + const write = await m.exec(['memory', 'write', 'notes/d.md', '--body', 'daemon quokka']); + expect(write.code, write.stderr).toBe(0); + + const search = await m.exec(['memory', 'search', 'quokka', '--json']); + expect(search.code, search.stderr).toBe(0); + expect(JSON.parse(search.stdout).results.map((r: { path: string }) => r.path)).toEqual([ + 'notes/d.md', + ]); + + const read = await m.exec(['memory', 'read', 'notes/d.md']); + expect(read.code, read.stderr).toBe(0); + expect(read.stdout).toContain('daemon quokka'); + + const edit = await m.exec([ + 'memory', + 'edit', + 'notes/d.md', + '--old', + 'quokka', + '--new', + 'wombat', + ]); + expect(edit.code, edit.stderr).toBe(0); + + const list = await m.exec(['memory', 'list', '--json']); + expect(list.code, list.stderr).toBe(0); + + const del = await m.exec(['memory', 'delete', 'notes/d.md']); + expect(del.code, del.stderr).toBe(0); + + // The daemon-side marker: its request counter advanced by the six verbs. + const after = await m.exec(['daemon', 'status']); + expect(after.code, after.stderr).toBe(0); + expect(servedCount(after.stdout)).toBeGreaterThanOrEqual(served0 + 6); + expect(after.stdout).toContain(`pid ${daemonPid}`); + + const stop = await m.exec(['daemon', 'stop']); + expect(stop.code, stop.stderr).toBe(0); + expect(stop.stdout).toContain('stopped'); + expect(existsSync(join(m.configDir, 'daemon.pid'))).toBe(false); + expect(existsSync(join(m.configDir, 'daemon.port'))).toBe(false); + + // SIGTERM is asynchronous - poll until the process exits and the port frees. + const deadline = Date.now() + 5_000; + while (alive(daemonPid!) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + } + expect(alive(daemonPid!), 'daemon process terminated').toBe(false); + + const status = await m.exec(['daemon', 'status']); + expect(status.stdout).toContain('not running'); + } finally { + // Kill only the pid this test started, never a real daemon. + if (daemonPid !== null && alive(daemonPid)) process.kill(daemonPid, 'SIGKILL'); + m.cleanup(); + } + }); + + test('--no-daemon runs the verbs without spawning a daemon', async () => { + const port = await freePort(); + const m = createCliMachine({ AGENTAGE_NO_DAEMON: '', AGENTAGE_DAEMON_PORT: String(port) }); + try { + await m.exec(['vault', 'add', 'main', '--local', join(m.configDir, 'main')]); + const write = await m.exec(['--no-daemon', 'memory', 'write', 'a.md', '--body', 'direct']); + expect(write.code, write.stderr).toBe(0); + expect(existsSync(join(m.configDir, 'daemon.pid')), 'no daemon spawned').toBe(false); + const read = await m.exec(['--no-daemon', 'memory', 'read', 'a.md']); + expect(read.stdout).toContain('direct'); + } finally { + m.cleanup(); + } + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index b7de347..90627c4 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -10,6 +10,7 @@ import { execFile, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, request as apiRequest, type APIRequestContext } from '@playwright/test'; @@ -48,6 +49,10 @@ export const createCliMachine = (extraEnv: NodeJS.ProcessEnv = {}): CliMachine = AGENTAGE_CONFIG_DIR: configDir, AGENTAGE_SITE_FQDN: TARGET_FQDN, NO_COLOR: '1', + // Default the offline tiers to the in-process engine: deterministic, never forks a daemon + // (the sandbox kills forked children), never probes the real :4243. The daemon tier opts + // back in with AGENTAGE_NO_DAEMON='' plus its own ephemeral AGENTAGE_DAEMON_PORT. + AGENTAGE_NO_DAEMON: '1', ...extraEnv, }; @@ -85,6 +90,19 @@ export const createCliMachine = (extraEnv: NodeJS.ProcessEnv = {}): CliMachine = }; }; +// An ephemeral loopback port for the daemon tier - the OS picks it, so tests never touch a +// real daemon's port. +export const freePort = (): Promise => + new Promise((resolve, reject) => { + const srv = createServer(); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + srv.close(() => (port > 0 ? resolve(port) : reject(new Error('no free port')))); + }); + srv.on('error', reject); + }); + // Poll the CLI's streamed output until the OAuth authorize URL is printed. export const waitForAuthorizeUrl = async ( setup: SetupProcess, diff --git a/src/cli.ts b/src/cli.ts index 27c8e4f..e6b30b3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,21 +1,32 @@ #!/usr/bin/env node import { Command } from 'commander'; +import { registerDaemon } from './commands/daemon-cmd.js'; import { registerMemory } from './commands/memory.js'; import { registerSetup } from './commands/setup.js'; import { registerStatus } from './commands/status.js'; import { registerUpdate } from './commands/update.js'; import { registerVault } from './commands/vault.js'; +import { disableDaemon } from './lib/daemon-pref.js'; import { VERSION } from './utils/version.js'; const program = new Command(); -program.name('agentage').description('The agentage CLI').version(VERSION); +program + .name('agentage') + .description('The agentage CLI') + .version(VERSION) + .option('--no-daemon', 'run memory verbs in-process instead of via the daemon'); + +program.hook('preAction', () => { + if (program.opts().daemon === false) disableDaemon(); +}); registerSetup(program); registerStatus(program); registerVault(program); registerMemory(program); +registerDaemon(program); registerUpdate(program); program.parseAsync().catch((err: unknown) => { diff --git a/src/commands/daemon-cmd.test.ts b/src/commands/daemon-cmd.test.ts new file mode 100644 index 0000000..45ba1da --- /dev/null +++ b/src/commands/daemon-cmd.test.ts @@ -0,0 +1,117 @@ +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as lifecycle from '../daemon/lifecycle.js'; +import * as dc from '../lib/daemon-client.js'; +import { registerDaemon } from './daemon-cmd.js'; + +vi.mock('../daemon/lifecycle.js'); +vi.mock('../lib/daemon-client.js'); + +const health = (over: Partial = {}): dc.Health => ({ + ok: true, + version: '1.2.3', + pid: 4242, + uptime: 12, + served: 7, + ...over, +}); + +let logs: string[]; + +const run = async (args: string[]): Promise => { + const program = new Command(); + program.exitOverride(); + registerDaemon(program); + await program.parseAsync(['node', 'agentage', ...args]); +}; + +beforeEach(() => { + vi.clearAllMocks(); + logs = []; + vi.spyOn(console, 'log').mockImplementation((m: unknown) => void logs.push(String(m))); + vi.spyOn(console, 'error').mockImplementation((m: unknown) => void logs.push(String(m))); + vi.mocked(lifecycle.resolvePort).mockReturnValue(45000); + vi.mocked(dc.mismatchNotice).mockReturnValue(null); + process.exitCode = 0; +}); +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = 0; +}); + +describe('daemon status', () => { + it('reports not running when no pidfile is live', async () => { + vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(false); + await run(['daemon', 'status']); + expect(logs.join()).toContain('not running'); + }); + + it('prints pid, port, uptime, served, and version', async () => { + vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true); + vi.mocked(dc.health).mockResolvedValue(health()); + await run(['daemon', 'status']); + const out = logs.join('\n'); + expect(out).toContain('4242'); + expect(out).toContain('45000'); + expect(out).toContain('served 7'); + expect(out).toContain('1.2.3'); + }); + + it('appends a restart hint on a version mismatch', async () => { + vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true); + vi.mocked(dc.health).mockResolvedValue(health()); + vi.mocked(dc.mismatchNotice).mockReturnValue('daemon version 1.2.3 != cli 9; restart'); + await run(['daemon', 'status']); + expect(logs.join('\n')).toContain('restart'); + }); + + it('flags a live pidfile whose daemon is unreachable', async () => { + vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true); + vi.mocked(dc.health).mockResolvedValue(null); + await run(['daemon', 'status']); + expect(logs.join()).toContain('unreachable'); + }); +}); + +describe('daemon start', () => { + it('is idempotent when one is already running', async () => { + vi.mocked(dc.health).mockResolvedValue(health()); + await run(['daemon', 'start']); + expect(logs.join()).toContain('already running'); + expect(dc.spawnDaemon).not.toHaveBeenCalled(); + }); + + it('spawns and confirms readiness', async () => { + vi.mocked(dc.health) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(health({ pid: 99 })); + vi.mocked(dc.spawnDaemon).mockResolvedValue(true); + await run(['daemon', 'start']); + expect(logs.join()).toContain('started'); + expect(logs.join()).toContain('99'); + }); + + it('reports a failed spawn and sets a non-zero exit code', async () => { + vi.mocked(dc.health).mockResolvedValue(null); + vi.mocked(dc.spawnDaemon).mockResolvedValue(false); + await run(['daemon', 'start']); + expect(logs.join()).toContain('failed'); + expect(process.exitCode).toBe(1); + }); +}); + +describe('daemon stop', () => { + it('stops a running daemon', async () => { + vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true); + await run(['daemon', 'stop']); + expect(lifecycle.stopDaemon).toHaveBeenCalled(); + expect(logs.join()).toContain('stopped'); + }); + + it('is a no-op when nothing is running', async () => { + vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(false); + await run(['daemon', 'stop']); + expect(lifecycle.stopDaemon).not.toHaveBeenCalled(); + expect(logs.join()).toContain('not running'); + }); +}); diff --git a/src/commands/daemon-cmd.ts b/src/commands/daemon-cmd.ts new file mode 100644 index 0000000..5ffda3a --- /dev/null +++ b/src/commands/daemon-cmd.ts @@ -0,0 +1,62 @@ +import chalk from 'chalk'; +import { type Command } from 'commander'; +import { isDaemonRunning, resolvePort, stopDaemon } from '../daemon/lifecycle.js'; +import { health, mismatchNotice, spawnDaemon } from '../lib/daemon-client.js'; + +const startAction = async (): Promise => { + const port = resolvePort(); + const existing = await health(port); + if (existing) { + console.log(chalk.gray(`Daemon already running (pid ${existing.pid}, port ${port}).`)); + return; + } + if (!(await spawnDaemon(port))) { + console.error(chalk.red('Daemon failed to start.')); + process.exitCode = 1; + return; + } + const h = await health(port); + console.log(chalk.green(`Daemon started (pid ${h?.pid ?? '?'}, port ${port}).`)); +}; + +const stopAction = (): void => { + if (!isDaemonRunning()) { + console.log(chalk.gray('Daemon is not running.')); + return; + } + stopDaemon(); + console.log(chalk.green('Daemon stopped.')); +}; + +const statusAction = async (): Promise => { + if (!isDaemonRunning()) { + console.log(chalk.gray('Daemon is not running.')); + return; + } + const port = resolvePort(); + const h = await health(port); + if (!h) { + console.log(chalk.yellow(`Daemon pid file present but unreachable on port ${port}.`)); + return; + } + console.log(`pid ${h.pid}`); + console.log(`port ${port}`); + console.log(`uptime ${h.uptime}s`); + console.log(`served ${h.served}`); + console.log(`version ${h.version}`); + const notice = mismatchNotice(h.version); + if (notice) console.log(chalk.yellow(notice)); +}; + +export const registerDaemon = (program: Command): void => { + const daemon = program.command('daemon').description('Manage the local engine daemon'); + daemon + .command('start') + .description('Start the daemon (idempotent)') + .action(() => startAction()); + daemon.command('stop').description('Stop the daemon').action(stopAction); + daemon + .command('status') + .description('Show the daemon pid, uptime, and version') + .action(() => statusAction()); +}; diff --git a/src/commands/memory.ts b/src/commands/memory.ts index c767486..015f89b 100644 --- a/src/commands/memory.ts +++ b/src/commands/memory.ts @@ -1,10 +1,19 @@ import chalk from 'chalk'; import { type Command } from 'commander'; import { type TreeEntry } from '@agentage/memory-core'; +import { ensureDaemon } from '../lib/daemon-client.js'; +import { daemonDisabled } from '../lib/daemon-pref.js'; import { createDirectClient, type MemoryClient } from '../lib/memory-client.js'; import { loadVaultsConfig } from '../lib/vaults.js'; -const defaultClient = (): MemoryClient => createDirectClient(loadVaultsConfig().config); +// Default engine path (DO3/DO4): the daemon - single writer, autostarted - when reachable; the +// in-process DirectClient as a seamless fallback (--no-daemon, sandbox/CI, or fork blocked). +const resolveClient = async (): Promise => { + const direct = (): MemoryClient => createDirectClient(loadVaultsConfig().config); + if (daemonDisabled()) return direct(); + const daemon = await ensureDaemon(); + return daemon ?? direct(); +}; const readStdin = async (): Promise => { const chunks: Buffer[] = []; @@ -29,9 +38,10 @@ interface CommonOpts { export const runSearch = async ( query: string, opts: CommonOpts & { limit?: string }, - client: MemoryClient = defaultClient() + client?: MemoryClient ): Promise => { - const out = await client.search(query, { + const c = client ?? (await resolveClient()); + const out = await c.search(query, { vault: opts.vault, limit: opts.limit ? Number.parseInt(opts.limit, 10) : undefined, }); @@ -44,44 +54,48 @@ export const runSearch = async ( export const runRead = async ( ref: string, opts: CommonOpts, - client: MemoryClient = defaultClient() + client?: MemoryClient ): Promise => { - const doc = await client.read(ref, { vault: opts.vault }); + const c = client ?? (await resolveClient()); + const doc = await c.read(ref, { vault: opts.vault }); emit(opts.json ?? false, doc, () => console.log(doc.body)); }; export const runWrite = async ( ref: string, opts: CommonOpts & { body?: string; frontmatter?: string }, - client: MemoryClient = defaultClient() + client?: MemoryClient ): Promise => { + const c = client ?? (await resolveClient()); const body = opts.body !== undefined && opts.body !== '-' ? opts.body : await readStdin(); const frontmatter = opts.frontmatter ? (JSON.parse(opts.frontmatter) as Record) : undefined; - const out = await client.write(ref, body, { vault: opts.vault, frontmatter }); + const out = await c.write(ref, body, { vault: opts.vault, frontmatter }); emit(opts.json ?? false, out, () => console.log(chalk.green(`Wrote ${out.path}`))); }; export const runEdit = async ( ref: string, opts: CommonOpts & { old?: string; new?: string; body?: string; append?: boolean }, - client: MemoryClient = defaultClient() + client?: MemoryClient ): Promise => { + const c = client ?? (await resolveClient()); const op = opts.old !== undefined ? { mode: 'str_replace' as const, old_str: opts.old, new_str: opts.new ?? '' } : { mode: opts.append ? ('append' as const) : ('replace' as const), body: opts.body }; - const out = await client.edit(ref, op, { vault: opts.vault }); + const out = await c.edit(ref, op, { vault: opts.vault }); emit(opts.json ?? false, out, () => console.log(chalk.green(`Edited ${out.path}`))); }; export const runList = async ( folder: string | undefined, opts: CommonOpts, - client: MemoryClient = defaultClient() + client?: MemoryClient ): Promise => { - const out = await client.list(folder, { vault: opts.vault }); + const c = client ?? (await resolveClient()); + const out = await c.list(folder, { vault: opts.vault }); emit(opts.json ?? false, out, () => { const files = treeFiles(out.entries); if (files.length === 0) console.log('No documents.'); @@ -92,9 +106,10 @@ export const runList = async ( export const runDelete = async ( ref: string, opts: CommonOpts, - client: MemoryClient = defaultClient() + client?: MemoryClient ): Promise => { - const out = await client.delete(ref, { vault: opts.vault }); + const c = client ?? (await resolveClient()); + const out = await c.delete(ref, { vault: opts.vault }); emit(opts.json ?? false, out, () => console.log(`Deleted ${out.path} (recoverable from git history)`) ); diff --git a/src/commands/status.ts b/src/commands/status.ts index 02028d3..1e8f542 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,6 +1,8 @@ import chalk from 'chalk'; import { type Command } from 'commander'; +import { isDaemonRunning, resolvePort } from '../daemon/lifecycle.js'; import { readAuth } from '../lib/config.js'; +import { health, mismatchNotice } from '../lib/daemon-client.js'; import { siteFqdn } from '../lib/origins.js'; import { gatherStatus, type StatusReport } from '../lib/status-info.js'; import { INSTALL_HINT, type UpdateInfo } from '../lib/update-check.js'; @@ -43,10 +45,24 @@ export const printStatus = (report: StatusReport): void => { if (report.update.message) console.log(chalk.yellow(`\n${report.update.message}`)); }; +// Warn only about a daemon this config dir owns (a live pidfile) so `status` never probes an +// unrelated daemon; the hint tells the user to restart it after a CLI upgrade. +const warnDaemonMismatch = async (): Promise => { + if (!isDaemonRunning()) return; + const h = await health(resolvePort()); + if (!h) return; + const notice = mismatchNotice(h.version); + if (notice) console.log(chalk.yellow(notice)); +}; + export const runStatus = async (opts: { json?: boolean } = {}): Promise => { const report = await gatherStatus(readAuth(), siteFqdn()); - if (opts.json) console.log(JSON.stringify(report, null, 2)); - else printStatus(report); + if (opts.json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + printStatus(report); + await warnDaemonMismatch(); }; export const registerStatus = (program: Command): void => { diff --git a/src/daemon-entry.ts b/src/daemon-entry.ts new file mode 100644 index 0000000..a2be186 --- /dev/null +++ b/src/daemon-entry.ts @@ -0,0 +1,37 @@ +import { createClientProvider } from './daemon/client-provider.js'; +import { + removePidFile, + removePortFile, + resolvePort, + writePidFile, + writePortFile, +} from './daemon/lifecycle.js'; +import { createDaemonServer } from './daemon/server.js'; +import { VERSION } from './utils/version.js'; + +// The forked, long-lived engine host: one loopback HTTP server that owns a single in-process +// engine and serialises every vault mutation, avoiding concurrent git index.lock collisions. +const main = async (): Promise => { + const port = resolvePort(); + const server = createDaemonServer({ getClient: createClientProvider(), version: VERSION }); + await server.start(port); + writePidFile(process.pid); + writePortFile(port); + + const shutdown = (): void => { + server.stop().finally(() => { + removePidFile(); + removePortFile(); + process.exit(0); + }); + }; + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +}; + +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); + removePidFile(); + removePortFile(); + process.exit(1); +}); diff --git a/src/daemon/actions.test.ts b/src/daemon/actions.test.ts new file mode 100644 index 0000000..8afbf22 --- /dev/null +++ b/src/daemon/actions.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { type MemoryClient } from '../lib/memory-client.js'; +import { dispatchMemory, isMemoryVerb, MEMORY_VERBS } from './actions.js'; + +const client = (): MemoryClient => ({ + search: vi.fn(async () => ({ results: [] })), + read: vi.fn(async () => ({ + path: 'a.md', + title: 'A', + frontmatter: {}, + body: 'b', + tags: [], + updated: 'now', + deleted: false, + })), + write: vi.fn(async () => ({ path: 'a.md', rev: 'r', updated: 'now' })), + edit: vi.fn(async () => ({ path: 'a.md', rev: 'r', updated: 'now' })), + list: vi.fn(async () => ({ folder: '', entries: [], truncated: false, files: 0 })), + delete: vi.fn(async () => ({ path: 'a.md', deleted: true })), +}); + +describe('dispatchMemory', () => { + it('recognises exactly the six frozen verbs', () => { + expect([...MEMORY_VERBS]).toEqual(['search', 'read', 'write', 'edit', 'list', 'delete']); + expect(isMemoryVerb('search')).toBe(true); + expect(isMemoryVerb('reindex')).toBe(false); + }); + + it('routes each verb to the matching client call with unpacked args', async () => { + const c = client(); + await dispatchMemory(c, 'search', { query: 'q', opts: { limit: 5 } }); + expect(c.search).toHaveBeenCalledWith('q', { limit: 5 }); + + await dispatchMemory(c, 'read', { ref: 'a.md', opts: { vault: 'v' } }); + expect(c.read).toHaveBeenCalledWith('a.md', { vault: 'v' }); + + await dispatchMemory(c, 'write', { ref: 'a.md', body: 'hi', opts: undefined }); + expect(c.write).toHaveBeenCalledWith('a.md', 'hi', undefined); + + await dispatchMemory(c, 'edit', { ref: 'a.md', op: { mode: 'append', body: 'x' } }); + expect(c.edit).toHaveBeenCalledWith('a.md', { mode: 'append', body: 'x' }, undefined); + + await dispatchMemory(c, 'list', { folder: 'notes' }); + expect(c.list).toHaveBeenCalledWith('notes', undefined); + + await dispatchMemory(c, 'delete', { ref: 'a.md' }); + expect(c.delete).toHaveBeenCalledWith('a.md', undefined); + }); + + it('defaults a missing body to an empty payload', async () => { + const c = client(); + await dispatchMemory(c, 'list', undefined); + expect(c.list).toHaveBeenCalledWith(undefined, undefined); + }); +}); diff --git a/src/daemon/actions.ts b/src/daemon/actions.ts new file mode 100644 index 0000000..94582e1 --- /dev/null +++ b/src/daemon/actions.ts @@ -0,0 +1,48 @@ +import { type EditInput } from '@agentage/memory-core'; +import { + type ListOptions, + type MemoryClient, + type SearchOptions, + type VerbOptions, +} from '../lib/memory-client.js'; + +export const MEMORY_VERBS = ['search', 'read', 'write', 'edit', 'list', 'delete'] as const; +export type MemoryVerb = (typeof MEMORY_VERBS)[number]; + +export const isMemoryVerb = (v: string): v is MemoryVerb => + (MEMORY_VERBS as readonly string[]).includes(v); + +type WriteOptions = VerbOptions & { frontmatter?: Record }; + +// Map one wire payload to the matching MemoryClient call. The daemon and the CLI share this verb +// set so a daemon request runs the exact method a DirectClient would run in-process (DO2). +export const dispatchMemory = ( + client: MemoryClient, + verb: MemoryVerb, + body: unknown +): Promise => { + const p = (body ?? {}) as Record; + switch (verb) { + case 'search': + return client.search(p['query'] as string, p['opts'] as SearchOptions | undefined); + case 'read': + return client.read(p['ref'] as string, p['opts'] as VerbOptions | undefined); + case 'write': + return client.write( + p['ref'] as string, + p['body'] as string, + p['opts'] as WriteOptions | undefined + ); + case 'edit': + return client.edit( + p['ref'] as string, + p['op'] as Omit, + p['opts'] as VerbOptions | undefined + ); + case 'list': + return client.list(p['folder'] as string | undefined, p['opts'] as ListOptions | undefined); + case 'delete': + return client.delete(p['ref'] as string, p['opts'] as VerbOptions | undefined); + } + throw new Error(`unknown verb: ${verb as string}`); +}; diff --git a/src/daemon/client-provider.test.ts b/src/daemon/client-provider.test.ts new file mode 100644 index 0000000..f0d3f73 --- /dev/null +++ b/src/daemon/client-provider.test.ts @@ -0,0 +1,37 @@ +import { mkdtempSync, rmSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { saveVaultsConfig, vaultsJsonPath } from '../lib/vaults.js'; +import { createClientProvider } from './client-provider.js'; + +let dir: string; +const saved = process.env['AGENTAGE_CONFIG_DIR']; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cli-provider-')); + process.env['AGENTAGE_CONFIG_DIR'] = dir; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (saved === undefined) delete process.env['AGENTAGE_CONFIG_DIR']; + else process.env['AGENTAGE_CONFIG_DIR'] = saved; +}); + +describe('createClientProvider', () => { + it('builds a client even with no vaults.json on disk', () => { + const provider = createClientProvider(); + expect(typeof provider()).toBe('object'); + }); + + it('caches the client until vaults.json changes on disk', () => { + saveVaultsConfig({ version: 1, vaults: {} }); + const provider = createClientProvider(); + const first = provider(); + expect(provider()).toBe(first); + + const future = new Date(Date.now() + 5000); + utimesSync(vaultsJsonPath(), future, future); + expect(provider()).not.toBe(first); + }); +}); diff --git a/src/daemon/client-provider.ts b/src/daemon/client-provider.ts new file mode 100644 index 0000000..8c945dc --- /dev/null +++ b/src/daemon/client-provider.ts @@ -0,0 +1,17 @@ +import { existsSync, statSync } from 'node:fs'; +import { createDirectClient, type MemoryClient } from '../lib/memory-client.js'; +import { loadVaultsConfig, vaultsJsonPath } from '../lib/vaults.js'; + +// One in-process engine per daemon, rebuilt only when vaults.json changes on disk, so a +// `vault add` between requests is picked up without a daemon restart. +export const createClientProvider = (): (() => MemoryClient) => { + let cached: { client: MemoryClient; mtime: number } | undefined; + return () => { + const path = vaultsJsonPath(); + const mtime = existsSync(path) ? statSync(path).mtimeMs : 0; + if (!cached || cached.mtime !== mtime) { + cached = { client: createDirectClient(loadVaultsConfig().config), mtime }; + } + return cached.client; + }; +}; diff --git a/src/daemon/lifecycle.test.ts b/src/daemon/lifecycle.test.ts new file mode 100644 index 0000000..8e5dab8 --- /dev/null +++ b/src/daemon/lifecycle.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + DEFAULT_DAEMON_PORT, + isDaemonRunning, + isProcessAlive, + removePidFile, + removePortFile, + resolvePort, + stopDaemon, + writePidFile, + writePortFile, +} from './lifecycle.js'; + +// A pid that is essentially never alive (max 32-bit). +const DEAD_PID = 2147483646; + +let dir: string; +const savedConfigDir = process.env['AGENTAGE_CONFIG_DIR']; +const savedPort = process.env['AGENTAGE_DAEMON_PORT']; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cli-daemon-lc-')); + process.env['AGENTAGE_CONFIG_DIR'] = dir; + delete process.env['AGENTAGE_DAEMON_PORT']; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (savedConfigDir === undefined) delete process.env['AGENTAGE_CONFIG_DIR']; + else process.env['AGENTAGE_CONFIG_DIR'] = savedConfigDir; + if (savedPort === undefined) delete process.env['AGENTAGE_DAEMON_PORT']; + else process.env['AGENTAGE_DAEMON_PORT'] = savedPort; +}); + +describe('pidfile lifecycle', () => { + it('reports not-running with no pidfile', () => { + expect(isDaemonRunning()).toBe(false); + }); + + it('treats our own pid as a live daemon', () => { + writePidFile(process.pid); + expect(isDaemonRunning()).toBe(true); + }); + + it('prunes a stale pidfile (dead pid) and reports not-running', () => { + writePidFile(DEAD_PID); + writePortFile(5000); + expect(isDaemonRunning()).toBe(false); + }); + + it('ignores a garbage pidfile', () => { + writeFileSync(join(dir, 'daemon.pid'), 'not-a-number', 'utf-8'); + expect(isDaemonRunning()).toBe(false); + }); + + it('isProcessAlive matches process.kill(pid,0)', () => { + expect(isProcessAlive(process.pid)).toBe(true); + expect(isProcessAlive(DEAD_PID)).toBe(false); + }); +}); + +describe('resolvePort', () => { + it('falls back to the default port', () => { + expect(resolvePort()).toBe(DEFAULT_DAEMON_PORT); + }); + + it('reads the recorded port file', () => { + writePortFile(45123); + expect(resolvePort()).toBe(45123); + }); + + it('lets the env override win over the port file', () => { + writePortFile(45123); + process.env['AGENTAGE_DAEMON_PORT'] = '46000'; + expect(resolvePort()).toBe(46000); + }); +}); + +describe('stopDaemon', () => { + it('returns false and clears files when the recorded pid is dead', () => { + writePidFile(DEAD_PID); + writePortFile(5000); + expect(stopDaemon()).toBe(false); + expect(isDaemonRunning()).toBe(false); + }); + + it('returns false when nothing is recorded', () => { + expect(stopDaemon()).toBe(false); + }); + + it('removePidFile/removePortFile are safe when absent', () => { + writePidFile(DEAD_PID); + writePortFile(1); + expect(readFileSync(join(dir, 'daemon.pid'), 'utf-8')).toBe(String(DEAD_PID)); + removePidFile(); + removePortFile(); + removePidFile(); + expect(isDaemonRunning()).toBe(false); + }); +}); diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts new file mode 100644 index 0000000..ce8f7f6 --- /dev/null +++ b/src/daemon/lifecycle.ts @@ -0,0 +1,71 @@ +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getConfigDir } from '../lib/config.js'; + +// The daemon's on-disk state lives beside the config so an isolated AGENTAGE_CONFIG_DIR fully +// isolates a daemon (pid, port) from any other - tests never collide with a real one. +export const DEFAULT_DAEMON_PORT = 4243; + +const pidPath = (): string => join(getConfigDir(), 'daemon.pid'); +const portPath = (): string => join(getConfigDir(), 'daemon.port'); + +export const writePidFile = (pid: number): void => writeFileSync(pidPath(), String(pid), 'utf-8'); +export const writePortFile = (port: number): void => + writeFileSync(portPath(), String(port), 'utf-8'); + +export const removePidFile = (): void => { + if (existsSync(pidPath())) unlinkSync(pidPath()); +}; +export const removePortFile = (): void => { + if (existsSync(portPath())) unlinkSync(portPath()); +}; + +const readNumberFile = (path: string): number | null => { + if (!existsSync(path)) return null; + const n = Number.parseInt(readFileSync(path, 'utf-8').trim(), 10); + return Number.isNaN(n) ? null : n; +}; + +// Env override wins (test isolation); else the running daemon's recorded port; else the default. +export const resolvePort = (): number => { + const env = process.env['AGENTAGE_DAEMON_PORT']; + if (env) { + const n = Number.parseInt(env, 10); + if (!Number.isNaN(n)) return n; + } + return readNumberFile(portPath()) ?? DEFAULT_DAEMON_PORT; +}; + +const readPid = (): number | null => readNumberFile(pidPath()); + +export const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +export const isDaemonRunning = (): boolean => { + const pid = readPid(); + if (pid === null) return false; + if (!isProcessAlive(pid)) { + removePidFile(); + removePortFile(); + return false; + } + return true; +}; + +// SIGTERM the running daemon and clear its on-disk state; the daemon also self-cleans on exit. +// Returns whether a live process was actually signalled. +export const stopDaemon = (): boolean => { + const pid = readPid(); + if (pid === null) return false; + const alive = isProcessAlive(pid); + if (alive) process.kill(pid, 'SIGTERM'); + removePidFile(); + removePortFile(); + return alive; +}; diff --git a/src/daemon/server.ts b/src/daemon/server.ts new file mode 100644 index 0000000..e48c428 --- /dev/null +++ b/src/daemon/server.ts @@ -0,0 +1,92 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { type MemoryClient } from '../lib/memory-client.js'; +import { dispatchMemory, isMemoryVerb } from './actions.js'; + +const LOOPBACK = '127.0.0.1'; + +export interface DaemonServerOptions { + getClient: () => MemoryClient | Promise; + version: string; + startedAt?: number; +} + +export interface DaemonServer { + server: Server; + start: (port: number, host?: string) => Promise; + stop: () => Promise; +} + +const readBody = (req: IncomingMessage): Promise => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8'); + if (raw.length === 0) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error('invalid JSON body')); + } + }); + req.on('error', reject); + }); + +const send = (res: ServerResponse, status: number, body: unknown): void => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +}; + +// Loopback-only JSON HTTP: GET /api/health + POST /api/memory/. No auth (local socket +// trust); the six verbs are the whole surface, dispatched to one shared MemoryClient. +export const createDaemonServer = (opts: DaemonServerOptions): DaemonServer => { + const startedAt = opts.startedAt ?? Date.now(); + let served = 0; + + const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { + const url = req.url ?? '/'; + if (req.method === 'GET' && url === '/api/health') { + return send(res, 200, { + ok: true, + version: opts.version, + pid: process.pid, + uptime: Math.floor((Date.now() - startedAt) / 1000), + served, + }); + } + const match = url.match(/^\/api\/memory\/([a-z]+)$/); + if (req.method === 'POST' && match) { + const verb = match[1]; + if (!isMemoryVerb(verb)) return send(res, 404, { error: `unknown verb: ${verb}` }); + try { + const result = await dispatchMemory(await opts.getClient(), verb, await readBody(req)); + served += 1; + return send(res, 200, result); + } catch (err) { + return send(res, 400, { error: err instanceof Error ? err.message : String(err) }); + } + } + send(res, 404, { error: 'not found' }); + }; + + const server = createServer((req, res) => { + handle(req, res).catch((err: unknown) => + send(res, 500, { error: err instanceof Error ? err.message : String(err) }) + ); + }); + + const start = (port: number, host: string = LOOPBACK): Promise => + new Promise((resolve, reject) => { + const onError = (err: NodeJS.ErrnoException): void => + reject(err.code === 'EADDRINUSE' ? new Error(`port ${port} already in use`) : err); + server.once('error', onError); + server.listen(port, host, () => { + server.removeListener('error', onError); + resolve(); + }); + }); + + const stop = (): Promise => new Promise((resolve) => server.close(() => resolve())); + + return { server, start, stop }; +}; diff --git a/src/lib/daemon-client.test.ts b/src/lib/daemon-client.test.ts new file mode 100644 index 0000000..15992fe --- /dev/null +++ b/src/lib/daemon-client.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createDaemonServer } from '../daemon/server.js'; +import { VERSION } from '../utils/version.js'; +import { type MemoryClient } from './memory-client.js'; +import { + createDaemonClient, + ensureDaemon, + health, + type Health, + mismatchNotice, + waitForHealth, +} from './daemon-client.js'; + +const mockClient = (): MemoryClient => ({ + search: vi.fn(async () => ({ results: [] })), + read: vi.fn(async () => ({ + path: 'a.md', + title: 'A', + frontmatter: {}, + body: 'hi', + tags: [], + updated: 'now', + deleted: false, + })), + write: vi.fn(async () => ({ path: 'a.md', rev: 'r', updated: 'now' })), + edit: vi.fn(async () => ({ path: 'a.md', rev: 'r', updated: 'now' })), + list: vi.fn(async () => ({ folder: '', entries: [], truncated: false, files: 0 })), + delete: vi.fn(async () => ({ path: 'a.md', deleted: true })), +}); + +// Spin the real daemon server IN-PROCESS on an ephemeral port - never fork (sandbox-safe). +const startServer = async ( + getClient: () => MemoryClient, + version = '9.9.9' +): Promise<{ port: number; stop: () => Promise }> => { + const srv = createDaemonServer({ getClient, version, startedAt: Date.now() - 3000 }); + await srv.start(0); + const addr = srv.server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + return { port, stop: () => srv.stop() }; +}; + +afterEach(() => vi.restoreAllMocks()); + +describe('DaemonClient <-> server round trip', () => { + it('routes all six verbs through the daemon and counts them', async () => { + const c = mockClient(); + const { port, stop } = await startServer(() => c); + try { + const dc = createDaemonClient(port); + expect((await dc.search('q', { limit: 3 })).results).toEqual([]); + expect(c.search).toHaveBeenCalledWith('q', { limit: 3 }); + await dc.read('a.md', { vault: 'v' }); + expect(c.read).toHaveBeenCalledWith('a.md', { vault: 'v' }); + await dc.write('a.md', 'body', { frontmatter: { x: 1 } }); + expect(c.write).toHaveBeenCalledWith('a.md', 'body', { frontmatter: { x: 1 } }); + await dc.edit('a.md', { mode: 'append', body: 'y' }, {}); + await dc.list('notes', {}); + expect((await dc.delete('a.md')).deleted).toBe(true); + + const h = await health(port); + expect(h?.served).toBeGreaterThanOrEqual(6); + expect(h?.version).toBe('9.9.9'); + } finally { + await stop(); + } + }); + + it('surfaces an engine error message across the wire', async () => { + const c = mockClient(); + c.read = vi.fn(async () => { + throw new Error('not found: x'); + }); + const { port, stop } = await startServer(() => c); + try { + await expect(createDaemonClient(port).read('x')).rejects.toThrow('not found: x'); + } finally { + await stop(); + } + }); + + it('404s an unknown verb and unknown route, 400s a bad JSON body', async () => { + const { port, stop } = await startServer(() => mockClient()); + try { + const bogus = await fetch(`http://127.0.0.1:${port}/api/memory/bogus`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + expect(bogus.status).toBe(404); + expect((await fetch(`http://127.0.0.1:${port}/nope`)).status).toBe(404); + const bad = await fetch(`http://127.0.0.1:${port}/api/memory/read`, { + method: 'POST', + body: '{oops', + }); + expect(bad.status).toBe(400); + } finally { + await stop(); + } + }); +}); + +describe('health + waitForHealth', () => { + it('waitForHealth resolves true against a live server', async () => { + const { port, stop } = await startServer(() => mockClient()); + try { + expect(await waitForHealth(port, { timeoutMs: 1000 })).toBe(true); + } finally { + await stop(); + } + }); + + it('health is null and waitForHealth times out on a dead port', async () => { + const { port, stop } = await startServer(() => mockClient()); + await stop(); + expect(await health(port, 200)).toBeNull(); + expect(await waitForHealth(port, { timeoutMs: 250, intervalMs: 50 })).toBe(false); + }); +}); + +describe('mismatchNotice', () => { + it('is null on a version match and a restart hint otherwise', () => { + expect(mismatchNotice(VERSION)).toBeNull(); + expect(mismatchNotice('0.0.0-old')).toContain('agentage daemon stop && agentage daemon start'); + }); +}); + +describe('ensureDaemon fallback logic', () => { + const live: Health = { ok: true, version: VERSION, pid: 1, uptime: 1, served: 0 }; + + it('uses an already-running daemon without spawning', async () => { + const spawn = vi.fn(async () => true); + const client = await ensureDaemon({ port: 40000, probe: async () => live, spawn }); + expect(client).not.toBeNull(); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('warns on a version mismatch but still returns a client', async () => { + const warn = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const client = await ensureDaemon({ + port: 40000, + probe: async () => ({ ...live, version: '0.0.0-old' }), + spawn: async () => true, + }); + expect(client).not.toBeNull(); + expect(warn).toHaveBeenCalled(); + }); + + it('autostarts when absent, returns a client on success', async () => { + const client = await ensureDaemon({ + port: 40000, + probe: async () => null, + spawn: async () => true, + }); + expect(client).not.toBeNull(); + }); + + it('returns null when absent and the fork is blocked', async () => { + const client = await ensureDaemon({ + port: 40000, + probe: async () => null, + spawn: async () => false, + }); + expect(client).toBeNull(); + }); +}); diff --git a/src/lib/daemon-client.ts b/src/lib/daemon-client.ts new file mode 100644 index 0000000..5f2b480 --- /dev/null +++ b/src/lib/daemon-client.ts @@ -0,0 +1,134 @@ +import { spawn as spawnChild } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import chalk from 'chalk'; +import { + type EditInput, + type ListResult, + type MemoryView, + type SearchResult, + type WriteResult, +} from '@agentage/memory-core'; +import { resolvePort } from '../daemon/lifecycle.js'; +import { VERSION } from '../utils/version.js'; +import { + type DeleteResult, + type ListOptions, + type MemoryClient, + type SearchOptions, + type VerbOptions, +} from './memory-client.js'; + +export interface Health { + ok: boolean; + version: string; + pid: number; + uptime: number; + served: number; +} + +const base = (port: number): string => `http://127.0.0.1:${port}`; + +const post = async (port: number, verb: string, body: unknown): Promise => { + const res = await fetch(`${base(port)}/api/memory/${verb}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error || `daemon request failed: ${res.status}`); + } + return res.json() as Promise; +}; + +export const health = async (port: number, timeoutMs = 1000): Promise => { + try { + const res = await fetch(`${base(port)}/api/health`, { + signal: AbortSignal.timeout(timeoutMs), + }); + return res.ok ? ((await res.json()) as Health) : null; + } catch { + return null; + } +}; + +export const waitForHealth = async ( + port: number, + opts: { timeoutMs?: number; intervalMs?: number } = {} +): Promise => { + const intervalMs = opts.intervalMs ?? 100; + const deadline = Date.now() + (opts.timeoutMs ?? 4000); + while (Date.now() < deadline) { + if (await health(port, intervalMs + 200)) return true; + await new Promise((r) => setTimeout(r, intervalMs)); + } + return false; +}; + +// A thin MemoryClient over HTTP: every method is a pass-through POST; the daemon's in-process +// DirectClient does the vault scoping, so the wire carries the raw ref + opts unchanged. +export const createDaemonClient = (port: number): MemoryClient => ({ + search: (query: string, opts?: SearchOptions) => + post(port, 'search', { query, opts }), + read: (ref: string, opts?: VerbOptions) => post(port, 'read', { ref, opts }), + write: ( + ref: string, + body: string, + opts?: VerbOptions & { frontmatter?: Record } + ) => post(port, 'write', { ref, body, opts }), + edit: (ref: string, op: Omit, opts?: VerbOptions) => + post(port, 'edit', { ref, op, opts }), + list: (folder: string | undefined, opts?: ListOptions) => + post(port, 'list', { folder, opts }), + delete: (ref: string, opts?: VerbOptions) => post(port, 'delete', { ref, opts }), +}); + +// A restart hint when the running daemon predates the current binary. +export const mismatchNotice = (daemonVersion: string): string | null => + daemonVersion === VERSION + ? null + : `daemon version ${daemonVersion} != cli ${VERSION}; restart with: agentage daemon stop && agentage daemon start`; + +const entryPath = (): string => fileURLToPath(new URL('../daemon-entry.js', import.meta.url)); + +// Detached spawn (not fork: fork's IPC channel keeps the parent event loop alive past unref) so +// the daemon outlives this CLI; ignore stdio + unref so the CLI can exit. Returns false (never +// throws) when spawning is blocked - callers then fall back to a DirectClient. +export const spawnDaemon = async ( + port: number, + opts: { timeoutMs?: number } = {} +): Promise => { + try { + const child = spawnChild(process.execPath, [entryPath()], { + detached: true, + stdio: 'ignore', + env: { ...process.env, AGENTAGE_DAEMON_PORT: String(port) }, + }); + child.unref(); + } catch { + return false; + } + return waitForHealth(port, { timeoutMs: opts.timeoutMs ?? 4000 }); +}; + +export interface EnsureDeps { + port?: number; + probe?: (port: number) => Promise; + spawn?: (port: number) => Promise; +} + +// DO3/DO4/DO9: prefer a live daemon, autostart one if absent, and return null when it is +// unreachable or cannot be forked so the caller falls back to the in-process DirectClient. +export const ensureDaemon = async (deps: EnsureDeps = {}): Promise => { + const port = deps.port ?? resolvePort(); + const probe = deps.probe ?? health; + const spawn = deps.spawn ?? spawnDaemon; + const existing = await probe(port); + if (existing) { + const notice = mismatchNotice(existing.version); + if (notice) console.error(chalk.yellow(notice)); + return createDaemonClient(port); + } + const started = await spawn(port).catch(() => false); + return started ? createDaemonClient(port) : null; +}; diff --git a/src/lib/daemon-pref.test.ts b/src/lib/daemon-pref.test.ts new file mode 100644 index 0000000..872057f --- /dev/null +++ b/src/lib/daemon-pref.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Fresh module each test so the process-local `forced` flag never leaks between cases. +const load = async () => { + vi.resetModules(); + return import('./daemon-pref.js'); +}; + +describe('daemon preference', () => { + const original = process.env['AGENTAGE_NO_DAEMON']; + beforeEach(() => delete process.env['AGENTAGE_NO_DAEMON']); + afterEach(() => { + if (original === undefined) delete process.env['AGENTAGE_NO_DAEMON']; + else process.env['AGENTAGE_NO_DAEMON'] = original; + }); + + it('is enabled by default', async () => { + const { daemonDisabled } = await load(); + expect(daemonDisabled()).toBe(false); + }); + + it('disables via the env flag (1 or true)', async () => { + const { daemonDisabled } = await load(); + process.env['AGENTAGE_NO_DAEMON'] = '1'; + expect(daemonDisabled()).toBe(true); + process.env['AGENTAGE_NO_DAEMON'] = 'true'; + expect(daemonDisabled()).toBe(true); + process.env['AGENTAGE_NO_DAEMON'] = ''; + expect(daemonDisabled()).toBe(false); + }); + + it('disableDaemon() latches the flag on', async () => { + const { daemonDisabled, disableDaemon } = await load(); + expect(daemonDisabled()).toBe(false); + disableDaemon(); + expect(daemonDisabled()).toBe(true); + }); +}); diff --git a/src/lib/daemon-pref.ts b/src/lib/daemon-pref.ts new file mode 100644 index 0000000..551bc34 --- /dev/null +++ b/src/lib/daemon-pref.ts @@ -0,0 +1,12 @@ +// Whether the memory verbs must run in-process (DirectClient) instead of via the daemon. +// Set by the `--no-daemon` global flag or AGENTAGE_NO_DAEMON=1 (tests/sandbox/CI). +let forced = false; + +export const disableDaemon = (): void => { + forced = true; +}; + +export const daemonDisabled = (): boolean => { + const env = process.env['AGENTAGE_NO_DAEMON']; + return forced || env === '1' || env === 'true'; +}; diff --git a/src/package-guard.test.ts b/src/package-guard.test.ts index 919ac20..e04a52c 100644 --- a/src/package-guard.test.ts +++ b/src/package-guard.test.ts @@ -12,21 +12,19 @@ const sourceFiles = (dir: string): string[] => return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [full] : []; }); -// R6: the memory client must carry zero agent-runtime remnants. +// R6 (amended for M2.5, DO10): the local daemon is now a sanctioned feature, so the +// daemon-entry / ensure-daemon / :4243 strip markers are dropped. The agent-runtime bans stay. const FORBIDDEN = [ - ':4243', '@agentage/core', '@agentage/platform', '@supabase/', "from 'express'", "from 'ws'", 'better-sqlite3', - 'daemon-entry', - 'ensure-daemon', ]; describe('package guard (R6)', () => { - it('source contains no agent-runtime or daemon remnants', () => { + it('source contains no agent-runtime remnants', () => { for (const file of sourceFiles(SRC)) { const content = readFileSync(file, 'utf-8'); for (const token of FORBIDDEN) { diff --git a/vitest.config.ts b/vitest.config.ts index 2352ebf..a5d876e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['src/**/*.ts'], - exclude: ['**/*.test.ts', '**/index.ts', 'src/cli.ts'], + exclude: ['**/*.test.ts', '**/index.ts', 'src/cli.ts', 'src/daemon-entry.ts'], thresholds: { branches: 65, functions: 70,