From db26e3812eec6e960f96c107c80470608327b899 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:07:33 -0600 Subject: [PATCH 1/2] refactor(logger): add tags option to console and file transports Add a `tags` boolean option to `makeConsoleTransport` (default: false) and `makeFileTransport` (default: true) controlling whether log tags are included in output. This replaces the separate `makeTaglessConsoleTransport` constructor. Co-Authored-By: Claude Opus 4.6 --- packages/logger/package.json | 10 +++ packages/logger/src/file-transport.test.ts | 87 ++++++++++++++++++++++ packages/logger/src/file-transport.ts | 36 +++++++++ packages/logger/src/logger.test.ts | 11 ++- packages/logger/src/tags.ts | 25 +++++++ packages/logger/src/transports.test.ts | 30 +++++++- packages/logger/src/transports.ts | 20 +++-- tsconfig.packages.json | 1 + 8 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 packages/logger/src/file-transport.test.ts create mode 100644 packages/logger/src/file-transport.ts create mode 100644 packages/logger/src/tags.ts diff --git a/packages/logger/package.json b/packages/logger/package.json index 43a32dfd5..46ed0663c 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -29,6 +29,16 @@ "default": "./dist/index.cjs" } }, + "./file-transport": { + "import": { + "types": "./dist/file-transport.d.mts", + "default": "./dist/file-transport.mjs" + }, + "require": { + "types": "./dist/file-transport.d.cts", + "default": "./dist/file-transport.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", diff --git a/packages/logger/src/file-transport.test.ts b/packages/logger/src/file-transport.test.ts new file mode 100644 index 000000000..1cbba4e54 --- /dev/null +++ b/packages/logger/src/file-transport.test.ts @@ -0,0 +1,87 @@ +import { readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { makeFileTransport } from './file-transport.ts'; +import type { LogEntry } from './types.ts'; + +const makeLogEntry = (overrides?: Partial): LogEntry => ({ + level: 'info', + message: 'test-message', + tags: ['test-tag'], + ...overrides, +}); + +describe('makeFileTransport', () => { + const testDir = join(tmpdir(), 'logger-file-transport-test'); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('includes tags by default', async () => { + const filePath = join(testDir, 'test.log'); + const transport = makeFileTransport({ filePath }); + const entry = makeLogEntry(); + + transport(entry); + await vi.waitFor(async () => { + const content = await readFile(filePath, 'utf-8'); + expect(content).toContain('[info] [test-tag] test-message'); + }); + }); + + it('omits tags when tags option is false', async () => { + const filePath = join(testDir, 'no-tags.log'); + const transport = makeFileTransport({ filePath, tags: false }); + + transport(makeLogEntry()); + await vi.waitFor(async () => { + const content = await readFile(filePath, 'utf-8'); + expect(content).toMatch(/\[info\] test-message/u); + expect(content).not.toContain('[test-tag]'); + }); + }); + + it('creates parent directories', async () => { + const filePath = join(testDir, 'nested', 'deep', 'test.log'); + const transport = makeFileTransport({ filePath }); + + transport(makeLogEntry()); + await vi.waitFor(async () => { + const content = await readFile(filePath, 'utf-8'); + expect(content).toContain('test-message'); + }); + }); + + it('omits tag prefix when tags are empty', async () => { + const filePath = join(testDir, 'empty-tags.log'); + const transport = makeFileTransport({ filePath }); + + transport(makeLogEntry({ tags: [] })); + await vi.waitFor(async () => { + const content = await readFile(filePath, 'utf-8'); + expect(content).toMatch(/\[info\] test-message/u); + expect(content).not.toContain('[]'); + }); + }); + + it('includes data in the log line', async () => { + const filePath = join(testDir, 'data.log'); + const transport = makeFileTransport({ filePath }); + + transport(makeLogEntry({ data: ['extra-data'] })); + await vi.waitFor(async () => { + const content = await readFile(filePath, 'utf-8'); + expect(content).toContain('test-message extra-data'); + }); + }); + + it('silently handles write errors', async () => { + const transport = makeFileTransport({ + filePath: '/dev/null/impossible/test.log', + }); + expect(() => transport(makeLogEntry())).not.toThrow(); + }); +}); diff --git a/packages/logger/src/file-transport.ts b/packages/logger/src/file-transport.ts new file mode 100644 index 000000000..e8b93092c --- /dev/null +++ b/packages/logger/src/file-transport.ts @@ -0,0 +1,36 @@ +import { appendFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { formatTagPrefix } from './tags.ts'; +import type { Transport } from './types.ts'; + +type FileTransportOptions = { + filePath: string; + tags?: boolean; +}; + +/** + * Creates a file transport that appends timestamped log lines to a file. + * Parent directories are created automatically. + * + * This transport requires Node.js (`node:fs/promises`). + * + * @param options - Options for the file transport. + * @param options.filePath - Absolute path to the log file. + * @param options.tags - Whether to include tags in the output (default: `true`). + * @returns A transport function that appends to the file. + */ +export function makeFileTransport(options: FileTransportOptions): Transport { + const { filePath, tags = true } = options; + return (entry) => { + const tagPrefix = formatTagPrefix(tags, entry); + const parts = [ + ...(entry.message ? [entry.message] : []), + ...(entry.data ?? []), + ]; + const line = `${new Date().toISOString()} [${entry.level}] ${tagPrefix}${parts.join(' ')}\n`; + mkdir(dirname(filePath), { recursive: true }) + .then(async () => appendFile(filePath, line)) + .catch(() => undefined); + }; +} diff --git a/packages/logger/src/logger.test.ts b/packages/logger/src/logger.test.ts index 2257778cc..8f8c26573 100644 --- a/packages/logger/src/logger.test.ts +++ b/packages/logger/src/logger.test.ts @@ -9,7 +9,7 @@ import type { LogMessage } from './stream.ts'; import { makeConsoleTransport } from './transports.ts'; const consoleMethod = ['log', 'debug', 'info', 'warn', 'error'] as const; -const transports = [makeConsoleTransport()]; +const transports = [makeConsoleTransport({ tags: true })]; describe('Logger', () => { it.each([ @@ -18,7 +18,7 @@ describe('Logger', () => { ['a string tag', 'test'], [ 'an options bag', - { tags: ['test'], transports: [makeConsoleTransport()] }, + { tags: ['test'], transports: [makeConsoleTransport({ tags: true })] }, ], ])('can be constructed with $description', (_description, options) => { const logger = new Logger(options); @@ -67,7 +67,10 @@ describe('Logger', () => { it('can be nested', () => { const consoleSpy = vi.spyOn(console, 'log'); const vatLogger = new Logger({ tags: ['vat 0x01'] }); - const subLogger = vatLogger.subLogger({ tags: ['(process)'], transports }); + const subLogger = vatLogger.subLogger({ + tags: ['(process)'], + transports, + }); subLogger.log('foo'); expect(consoleSpy).toHaveBeenCalledWith(['vat 0x01', '(process)'], 'foo'); }); @@ -121,7 +124,7 @@ describe('Logger', () => { consoleMethod.forEach((level) => { it(`logging at level ${level}`, () => { const testLogger = new Logger({ - transports: [makeConsoleTransport(level)], + transports: [makeConsoleTransport({ level })], }); consoleMethod.forEach((method) => { const consoleSpy = vi.spyOn(console, method); diff --git a/packages/logger/src/tags.ts b/packages/logger/src/tags.ts new file mode 100644 index 000000000..7478b97f6 --- /dev/null +++ b/packages/logger/src/tags.ts @@ -0,0 +1,25 @@ +import type { LogEntry } from './types.ts'; + +/** + * Checks whether a log entry has tags that should be rendered, + * given the transport's `tags` option. + * + * @param includeTags - The transport's `tags` option value. + * @param entry - The log entry to check. + * @returns `true` if the entry has tags and the option is enabled. + */ +export function hasTags(includeTags: boolean, entry: LogEntry): boolean { + return includeTags && entry.tags.length > 0; +} + +/** + * Formats an entry's tags as a bracketed string prefix, e.g. `"[cli, daemon] "`. + * Returns an empty string if tags should not be rendered. + * + * @param includeTags - The transport's `tags` option value. + * @param entry - The log entry whose tags to format. + * @returns The formatted tag prefix or `""`. + */ +export function formatTagPrefix(includeTags: boolean, entry: LogEntry): string { + return hasTags(includeTags, entry) ? `[${entry.tags.join(', ')}] ` : ''; +} diff --git a/packages/logger/src/transports.test.ts b/packages/logger/src/transports.test.ts index 6e8af1857..7295f629a 100644 --- a/packages/logger/src/transports.test.ts +++ b/packages/logger/src/transports.test.ts @@ -25,12 +25,34 @@ describe('consoleTransport', () => { const logEntry = makeLogEntry(level); const consoleMethodSpy = vi.spyOn(console, level); consoleTransport(logEntry); - expect(consoleMethodSpy).toHaveBeenCalledWith( - logEntry.tags, - logEntry.message, - ); + expect(consoleMethodSpy).toHaveBeenCalledWith(logEntry.message); }, ); + + it('omits tags by default', () => { + const transport = makeConsoleTransport(); + const entry: LogEntry = { + level: 'info', + message: 'hello', + tags: ['cli', 'daemon'], + data: ['extra'], + }; + const spy = vi.spyOn(console, 'info'); + transport(entry); + expect(spy).toHaveBeenCalledWith('hello', 'extra'); + }); + + it('includes tags when tags option is true', () => { + const transport = makeConsoleTransport({ tags: true }); + const entry: LogEntry = { + level: 'info', + message: 'hello', + tags: ['cli', 'daemon'], + }; + const spy = vi.spyOn(console, 'info'); + transport(entry); + expect(spy).toHaveBeenCalledWith(['cli', 'daemon'], 'hello'); + }); }); describe('makeStreamTransport', () => { diff --git a/packages/logger/src/transports.ts b/packages/logger/src/transports.ts index 492ae62d6..17020eb0f 100644 --- a/packages/logger/src/transports.ts +++ b/packages/logger/src/transports.ts @@ -3,15 +3,26 @@ import type { DuplexStream } from '@metamask/streams'; import { logLevels } from './constants.ts'; import { lser } from './stream.ts'; +import { hasTags } from './tags.ts'; import type { Transport, LogArgs, LogLevel, LogMethod } from './types.ts'; +type ConsoleTransportOptions = { + level?: LogLevel; + tags?: boolean; +}; + /** * The console transport for the logger. * - * @param level - The logging level for this instance. + * @param options - Options for the console transport. + * @param options.level - The logging level for this instance (default: `'debug'`). + * @param options.tags - Whether to include tags in the output (default: `false`). * @returns A transport function that writes to the console. */ -export function makeConsoleTransport(level: LogLevel = 'debug'): Transport { +export function makeConsoleTransport( + options: ConsoleTransportOptions = {}, +): Transport { + const { level = 'debug', tags = false } = options; const baseLevelIdx = logLevels[level]; const logFn = (method: LogLevel): LogMethod => { if (baseLevelIdx <= logLevels[method]) { @@ -31,15 +42,14 @@ export function makeConsoleTransport(level: LogLevel = 'debug'): Transport { warn: logFn('warn'), error: logFn('error'), }; - const consoleTransport: Transport = (entry) => { + return (entry) => { const args = [ - ...(entry.tags.length > 0 ? [entry.tags] : []), + ...(hasTags(tags, entry) ? [entry.tags] : []), ...(entry.message ? [entry.message] : []), ...(entry.data ?? []), ] as LogArgs; filteredConsole[entry.level](...args); }; - return consoleTransport; } /** diff --git a/tsconfig.packages.json b/tsconfig.packages.json index a2cddfd7b..bc9a147e4 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -15,6 +15,7 @@ "@metamask/kernel-store/sqlite/wasm": [ "../kernel-store/src/sqlite/wasm.ts" ], + "@metamask/logger/file-transport": ["../logger/src/file-transport.ts"], "@ocap/*": ["../*/src"] } } From 5b0e9da2acf6d50e6661e1e1d507fb55d4ca10c4 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:07:54 -0600 Subject: [PATCH 2/2] feat(kernel-daemon): add IPC server, client, and lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the `@ocap/kernel-daemon` package with Unix-domain-socket IPC infrastructure: - `DaemonServer` — JSON-RPC dispatcher over `net.Server` - `connectToDaemon` / `sendShutdown` — IPC client helpers - `startDaemon` / `stopDaemon` / `isDaemonRunning` — process lifecycle - Constants for socket, PID, DB, and log file paths Co-Authored-By: Claude Opus 4.6 --- .depcheckrc.yml | 3 + packages/kernel-daemon/package.json | 64 +++ packages/kernel-daemon/src/constants.ts | 27 ++ .../kernel-daemon/src/daemon-client.test.ts | 118 ++++++ packages/kernel-daemon/src/daemon-client.ts | 104 +++++ .../src/daemon-lifecycle.test.ts | 371 ++++++++++++++++++ .../kernel-daemon/src/daemon-lifecycle.ts | 187 +++++++++ .../kernel-daemon/src/daemon-server.test.ts | 40 ++ packages/kernel-daemon/src/daemon-server.ts | 145 +++++++ packages/kernel-daemon/src/index.ts | 19 + packages/kernel-daemon/src/types.ts | 17 + packages/kernel-daemon/test/setup.ts | 1 + packages/kernel-daemon/tsconfig.build.json | 15 + packages/kernel-daemon/tsconfig.json | 14 + packages/kernel-daemon/vitest.config.ts | 17 + yarn.lock | 21 + 16 files changed, 1163 insertions(+) create mode 100644 packages/kernel-daemon/package.json create mode 100644 packages/kernel-daemon/src/constants.ts create mode 100644 packages/kernel-daemon/src/daemon-client.test.ts create mode 100644 packages/kernel-daemon/src/daemon-client.ts create mode 100644 packages/kernel-daemon/src/daemon-lifecycle.test.ts create mode 100644 packages/kernel-daemon/src/daemon-lifecycle.ts create mode 100644 packages/kernel-daemon/src/daemon-server.test.ts create mode 100644 packages/kernel-daemon/src/daemon-server.ts create mode 100644 packages/kernel-daemon/src/index.ts create mode 100644 packages/kernel-daemon/src/types.ts create mode 100644 packages/kernel-daemon/test/setup.ts create mode 100644 packages/kernel-daemon/tsconfig.build.json create mode 100644 packages/kernel-daemon/tsconfig.json create mode 100644 packages/kernel-daemon/vitest.config.ts diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 68297a790..609273cf0 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -17,6 +17,9 @@ ignores: - '@ts-bridge/cli' - '@ts-bridge/shims' + # yargs (type-only usage in kernel-daemon) + - '@types/yargs' + # vitest - 'vite' - '@types/vitest' diff --git a/packages/kernel-daemon/package.json b/packages/kernel-daemon/package.json new file mode 100644 index 000000000..80be05133 --- /dev/null +++ b/packages/kernel-daemon/package.json @@ -0,0 +1,64 @@ +{ + "name": "@ocap/kernel-daemon", + "version": "0.0.0", + "private": true, + "description": "Persistent ocap kernel daemon with IPC server and client", + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --no-references --clean", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck --quiet", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error", + "test": "vitest run --config vitest.config.ts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --mode development", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts", + "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent", + "build:docs": "typedoc" + }, + "dependencies": { + "@metamask/kernel-rpc-methods": "workspace:^", + "@metamask/logger": "workspace:^" + }, + "devDependencies": { + "@ocap/repo-tools": "workspace:^", + "@ts-bridge/cli": "^0.6.3", + "@ts-bridge/shims": "^0.1.1", + "@types/node": "^22.13.1", + "depcheck": "^1.4.7", + "eslint": "^9.23.0", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "ses": "^1.14.0", + "typescript": "~5.8.2", + "vite": "^7.3.0", + "vitest": "^4.0.16" + }, + "engines": { + "node": "^20.11 || >=22" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + } +} diff --git a/packages/kernel-daemon/src/constants.ts b/packages/kernel-daemon/src/constants.ts new file mode 100644 index 000000000..24ee29952 --- /dev/null +++ b/packages/kernel-daemon/src/constants.ts @@ -0,0 +1,27 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Base directory for daemon state files. + */ +export const DAEMON_DIR = join(homedir(), '.ocap-kernel-daemon'); + +/** + * Path to the daemon PID file. + */ +export const PID_FILE = join(DAEMON_DIR, 'daemon.pid'); + +/** + * Path to the daemon Unix domain socket. + */ +export const SOCK_FILE = join(DAEMON_DIR, 'daemon.sock'); + +/** + * Path to the persistent SQLite database. + */ +export const DB_FILE = join(DAEMON_DIR, 'store.db'); + +/** + * Path to the daemon log file. + */ +export const LOG_FILE = join(DAEMON_DIR, 'daemon.log'); diff --git a/packages/kernel-daemon/src/daemon-client.test.ts b/packages/kernel-daemon/src/daemon-client.test.ts new file mode 100644 index 000000000..fdef1af5a --- /dev/null +++ b/packages/kernel-daemon/src/daemon-client.test.ts @@ -0,0 +1,118 @@ +import { EventEmitter } from 'node:events'; +import { createConnection } from 'node:net'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { connectToDaemon, sendShutdown } from './daemon-client.ts'; + +vi.mock('@metamask/kernel-rpc-methods', () => { + const MockRpcClient = vi.fn(); + // eslint-disable-next-line vitest/prefer-spy-on -- vi.spyOn fails on bare vi.fn() constructors + MockRpcClient.prototype.handleResponse = vi.fn(); + // eslint-disable-next-line vitest/prefer-spy-on -- vi.spyOn fails on bare vi.fn() constructors + MockRpcClient.prototype.call = vi.fn(); + return { RpcClient: MockRpcClient }; +}); + +vi.mock('node:net', () => ({ + createConnection: vi.fn(), +})); + +const makeMockSocket = () => { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + removeAllListeners: vi.fn(), + }); +}; + +const mockMethodSpecs = { + getStatus: { method: 'getStatus' }, +}; + +const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +describe('daemon-client', () => { + let mockSocket: ReturnType; + + beforeEach(() => { + mockSocket = makeMockSocket(); + vi.mocked(createConnection).mockReturnValue(mockSocket as never); + }); + + describe('connectToDaemon', () => { + it('resolves with a daemon connection on successful connect', async () => { + const connectionPromise = connectToDaemon( + mockMethodSpecs, + mockLogger as never, + ); + mockSocket.emit('connect'); + const connection = await connectionPromise; + + expect(connection.client).toBeDefined(); + expect(connection.close).toBeInstanceOf(Function); + expect(connection.socket).toBe(mockSocket); + }); + + it('rejects when connection fails', async () => { + const connectionPromise = connectToDaemon( + mockMethodSpecs, + mockLogger as never, + ); + mockSocket.emit('error', new Error('ENOENT')); + + await expect(connectionPromise).rejects.toThrow( + 'Failed to connect to daemon: ENOENT', + ); + }); + + it('removes all listeners and destroys socket on close', async () => { + const connectionPromise = connectToDaemon( + mockMethodSpecs, + mockLogger as never, + ); + mockSocket.emit('connect'); + const connection = await connectionPromise; + + connection.close(); + + expect(mockSocket.removeAllListeners).toHaveBeenCalled(); + expect(mockSocket.destroy).toHaveBeenCalled(); + }); + }); + + describe('sendShutdown', () => { + it('sends shutdown command and resolves on response', async () => { + const shutdownPromise = sendShutdown(); + mockSocket.emit('connect'); + + expect(mockSocket.write).toHaveBeenCalledWith( + expect.stringContaining('"method":"shutdown"'), + ); + + // Simulate response + mockSocket.emit( + 'data', + Buffer.from('{"jsonrpc":"2.0","id":"shutdown-1","result":true}\n'), + ); + + await shutdownPromise; + expect(mockSocket.destroy).toHaveBeenCalled(); + }); + + it('rejects when connection fails', async () => { + const shutdownPromise = sendShutdown(); + mockSocket.emit('error', new Error('ECONNREFUSED')); + + await expect(shutdownPromise).rejects.toThrow( + 'Failed to connect to daemon: ECONNREFUSED', + ); + }); + }); +}); diff --git a/packages/kernel-daemon/src/daemon-client.ts b/packages/kernel-daemon/src/daemon-client.ts new file mode 100644 index 000000000..c8c7e6dcb --- /dev/null +++ b/packages/kernel-daemon/src/daemon-client.ts @@ -0,0 +1,104 @@ +import { RpcClient } from '@metamask/kernel-rpc-methods'; +import type { Logger } from '@metamask/logger'; +import { createConnection } from 'node:net'; + +import { SOCK_FILE } from './constants.ts'; +import type { DaemonConnection } from './types.ts'; + +/** + * Connect to the daemon's Unix domain socket and return an RPC client. + * + * @param methodSpecs - RPC method specifications for the client (e.g. rpcMethodSpecs + * from kernel-browser-runtime). Passed as a parameter to avoid cyclic dependencies. + * @param logger - Logger instance. + * @returns A daemon connection with an RPC client and close function. + */ +export async function connectToDaemon( + methodSpecs: Record, + logger: Logger, +): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(SOCK_FILE); + + socket.once('connect', () => { + const sendMessage = async ( + payload: Record, + ): Promise => { + socket.write(`${JSON.stringify(payload)}\n`); + }; + + const client = new RpcClient( + methodSpecs as never, + sendMessage, + 'cli-', + logger, + ); + + let buffer = ''; + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + try { + const response = JSON.parse(line); + if (response.id !== undefined && response.id !== null) { + client.handleResponse(String(response.id), response); + } + } catch { + logger.error('Failed to parse daemon response'); + } + } + }); + + resolve({ + client, + socket, + close: () => { + socket.removeAllListeners(); + socket.destroy(); + }, + }); + }); + + socket.once('error', (error) => { + reject(new Error(`Failed to connect to daemon: ${error.message}`)); + }); + }); +} + +/** + * Send a raw shutdown command to the daemon over the Unix socket. + * This bypasses the typed RPC client since `shutdown` is daemon-specific. + * + * @returns A promise that resolves when the shutdown acknowledgment is received. + */ +export async function sendShutdown(): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(SOCK_FILE); + + socket.once('connect', () => { + const request = { + jsonrpc: '2.0', + id: 'shutdown-1', + method: 'shutdown', + params: [], + }; + socket.write(`${JSON.stringify(request)}\n`); + + let buffer = ''; + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + if (buffer.includes('\n')) { + socket.destroy(); + resolve(); + } + }); + }); + + socket.once('error', (error) => { + reject(new Error(`Failed to connect to daemon: ${error.message}`)); + }); + }); +} diff --git a/packages/kernel-daemon/src/daemon-lifecycle.test.ts b/packages/kernel-daemon/src/daemon-lifecycle.test.ts new file mode 100644 index 000000000..2ec54c88f --- /dev/null +++ b/packages/kernel-daemon/src/daemon-lifecycle.test.ts @@ -0,0 +1,371 @@ +import { fork } from 'node:child_process'; +import { access, readFile, unlink, mkdir } from 'node:fs/promises'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { sendShutdown } from './daemon-client.ts'; +import { + flushDaemonStore, + isDaemonRunning, + readDaemonPid, + startDaemon, + stopDaemon, +} from './daemon-lifecycle.ts'; + +vi.mock('node:child_process', () => ({ + fork: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + access: vi.fn(), + readFile: vi.fn(), + unlink: vi.fn(), + mkdir: vi.fn(), +})); + +vi.mock('./daemon-client.ts', () => ({ + sendShutdown: vi.fn(), +})); + +describe('daemon-lifecycle', () => { + beforeEach(() => { + vi.mocked(access).mockReset(); + vi.mocked(readFile).mockReset(); + vi.mocked(unlink).mockReset(); + vi.mocked(mkdir).mockReset(); + }); + + describe('isDaemonRunning', () => { + it('returns false when PID file does not exist', async () => { + vi.mocked(access).mockRejectedValue(new Error('ENOENT')); + expect(await isDaemonRunning()).toBe(false); + }); + + it('returns true when PID file exists and process is alive', async () => { + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(readFile).mockResolvedValue('1234'); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + + expect(await isDaemonRunning()).toBe(true); + expect(killSpy).toHaveBeenCalledWith(1234, 0); + + killSpy.mockRestore(); + }); + + it('returns false when PID file exists but process is dead', async () => { + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(readFile).mockResolvedValue('1234'); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('ESRCH'); + }); + + expect(await isDaemonRunning()).toBe(false); + + killSpy.mockRestore(); + }); + }); + + describe('flushDaemonStore', () => { + const makeLogger = () => + ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }) as never; + + it('throws when daemon is running', async () => { + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(readFile).mockResolvedValue('1234'); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + + await expect(flushDaemonStore(makeLogger())).rejects.toThrow( + 'Cannot flush while daemon is running', + ); + + killSpy.mockRestore(); + }); + + it('removes DB file when daemon is stopped', async () => { + // isDaemonRunning: PID file does not exist → not running + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + // fileExists for DB_FILE → exists + vi.mocked(access).mockResolvedValueOnce(undefined); + // fileExists for -wal → does not exist + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + // fileExists for -shm → does not exist + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + // fileExists for -journal → does not exist + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + + const logger = makeLogger(); + await flushDaemonStore(logger); + + expect(unlink).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith('Daemon store flushed'); + }); + + it('removes DB file and all sidecars when they exist', async () => { + // isDaemonRunning: PID file does not exist → not running + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + // fileExists for DB_FILE, -wal, -shm, -journal → all exist + vi.mocked(access).mockResolvedValueOnce(undefined); + vi.mocked(access).mockResolvedValueOnce(undefined); + vi.mocked(access).mockResolvedValueOnce(undefined); + vi.mocked(access).mockResolvedValueOnce(undefined); + + await flushDaemonStore(makeLogger()); + + expect(unlink).toHaveBeenCalledTimes(4); + }); + + it('no-ops gracefully when DB does not exist', async () => { + // isDaemonRunning: PID file does not exist → not running + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + // fileExists for all four files → none exist + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + + const logger = makeLogger(); + await flushDaemonStore(logger); + + expect(unlink).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Daemon store flushed'); + }); + }); + + describe('readDaemonPid', () => { + it('returns null when PID file does not exist', async () => { + vi.mocked(access).mockRejectedValue(new Error('ENOENT')); + expect(await readDaemonPid()).toBeNull(); + }); + + it('returns the PID when file exists', async () => { + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(readFile).mockResolvedValue('5678'); + expect(await readDaemonPid()).toBe(5678); + }); + + it('returns null when file read fails', async () => { + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(readFile).mockRejectedValue(new Error('EACCES')); + expect(await readDaemonPid()).toBeNull(); + }); + }); + + describe('startDaemon', () => { + const makeLogger = () => + ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }) as never; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('throws when daemon is already running', async () => { + // isDaemonRunning: PID file exists and process alive + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(readFile).mockResolvedValue('1234'); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + + await expect( + startDaemon('/path/to/daemon.mjs', makeLogger()), + ).rejects.toThrow('Daemon already running (PID 1234)'); + + killSpy.mockRestore(); + }); + + it('throws when fork returns no PID', async () => { + // isDaemonRunning: PID file does not exist → not running + vi.mocked(access).mockRejectedValue(new Error('ENOENT')); + vi.mocked(mkdir).mockResolvedValue(undefined); + + const mockChild = { unref: vi.fn(), pid: undefined }; + vi.mocked(fork).mockReturnValue(mockChild as never); + + await expect( + startDaemon('/path/to/daemon.mjs', makeLogger()), + ).rejects.toThrow('Failed to start daemon: no PID returned'); + }); + + it('forks a detached child and returns PID when socket appears', async () => { + // isDaemonRunning: PID file does not exist → not running + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + // cleanupStaleFiles: PID file does not exist + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + // cleanupStaleFiles: socket does not exist + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(mkdir).mockResolvedValue(undefined); + + const mockChild = { unref: vi.fn(), pid: 9999 }; + vi.mocked(fork).mockReturnValue(mockChild as never); + + // Socket file: not found on first poll, then found on second + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(access).mockResolvedValueOnce(undefined); + + const startPromise = startDaemon('/path/to/daemon.mjs', makeLogger()); + await vi.advanceTimersByTimeAsync(200); + const pid = await startPromise; + + expect(pid).toBe(9999); + expect(fork).toHaveBeenCalledWith('/path/to/daemon.mjs', [], { + detached: true, + stdio: 'ignore', + }); + expect(mockChild.unref).toHaveBeenCalled(); + }); + + it('throws when socket file does not appear within timeout', async () => { + // isDaemonRunning: not running + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + // cleanupStaleFiles: both absent + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(mkdir).mockResolvedValue(undefined); + + const mockChild = { unref: vi.fn(), pid: 9999 }; + vi.mocked(fork).mockReturnValue(mockChild as never); + + // Socket never appears + vi.mocked(access).mockRejectedValue(new Error('ENOENT')); + + const startPromise = startDaemon('/path/to/daemon.mjs', makeLogger()); + // Attach rejection handler before advancing timers to avoid unhandled rejection + startPromise.catch(() => undefined); + await vi.advanceTimersByTimeAsync(11_000); + + await expect(startPromise).rejects.toThrow( + 'Daemon did not start within timeout', + ); + }); + }); + + describe('stopDaemon', () => { + const makeLogger = () => + ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }) as never; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('throws when daemon is not running', async () => { + vi.mocked(access).mockRejectedValue(new Error('ENOENT')); + + await expect(stopDaemon(makeLogger())).rejects.toThrow( + 'Daemon is not running', + ); + }); + + it('sends shutdown RPC and cleans up', async () => { + const killSpy = vi.spyOn(process, 'kill'); + + // isDaemonRunning (initial check): running + vi.mocked(access).mockResolvedValueOnce(undefined); + vi.mocked(readFile).mockResolvedValueOnce('5555'); + killSpy.mockReturnValueOnce(true); + + // readDaemonPid + vi.mocked(access).mockResolvedValueOnce(undefined); + vi.mocked(readFile).mockResolvedValueOnce('5555'); + + vi.mocked(sendShutdown).mockResolvedValue(undefined); + + // isDaemonRunning (polling): process exited + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + + // cleanupStaleFiles: both absent + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + + const logger = makeLogger(); + const stopPromise = stopDaemon(logger); + await vi.advanceTimersByTimeAsync(200); + await stopPromise; + + expect(sendShutdown).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Daemon stopped'); + + killSpy.mockRestore(); + }); + + it('falls back to SIGTERM when shutdown RPC fails', async () => { + const killSpy = vi.spyOn(process, 'kill'); + + // isDaemonRunning (initial check): running + vi.mocked(access).mockResolvedValueOnce(undefined); + vi.mocked(readFile).mockResolvedValueOnce('5555'); + killSpy.mockReturnValueOnce(true); + + // readDaemonPid + vi.mocked(access).mockResolvedValueOnce(undefined); + vi.mocked(readFile).mockResolvedValueOnce('5555'); + + vi.mocked(sendShutdown).mockRejectedValue(new Error('ECONNREFUSED')); + + // SIGTERM call + killSpy.mockReturnValueOnce(true); + + // isDaemonRunning (polling): process exited + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + + // cleanupStaleFiles + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + + const stopPromise = stopDaemon(makeLogger()); + await vi.advanceTimersByTimeAsync(200); + await stopPromise; + + expect(killSpy).toHaveBeenCalledWith(5555, 'SIGTERM'); + + killSpy.mockRestore(); + }); + + it('escalates to SIGKILL after exit timeout', async () => { + const killSpy = vi.spyOn(process, 'kill'); + + // isDaemonRunning (initial check): running + vi.mocked(access).mockResolvedValueOnce(undefined); + vi.mocked(readFile).mockResolvedValueOnce('5555'); + killSpy.mockReturnValueOnce(true); + + // readDaemonPid + vi.mocked(access).mockResolvedValueOnce(undefined); + vi.mocked(readFile).mockResolvedValueOnce('5555'); + + vi.mocked(sendShutdown).mockResolvedValue(undefined); + + // isDaemonRunning (polling): always running — process won't exit + vi.mocked(access).mockResolvedValue(undefined); + vi.mocked(readFile).mockResolvedValue('5555'); + killSpy.mockReturnValue(true); + + const stopPromise = stopDaemon(makeLogger()); + await vi.advanceTimersByTimeAsync(6_000); + await stopPromise; + + expect(killSpy).toHaveBeenCalledWith(5555, 'SIGKILL'); + + killSpy.mockRestore(); + }); + }); +}); diff --git a/packages/kernel-daemon/src/daemon-lifecycle.ts b/packages/kernel-daemon/src/daemon-lifecycle.ts new file mode 100644 index 000000000..f8fa3fc6a --- /dev/null +++ b/packages/kernel-daemon/src/daemon-lifecycle.ts @@ -0,0 +1,187 @@ +import type { Logger } from '@metamask/logger'; +import { fork } from 'node:child_process'; +import { access, readFile, unlink, mkdir } from 'node:fs/promises'; + +import { DAEMON_DIR, DB_FILE, PID_FILE, SOCK_FILE } from './constants.ts'; +import { sendShutdown } from './daemon-client.ts'; + +/** + * Check whether a file exists at the given path. + * + * @param filePath - The path to check. + * @returns True if the file exists. + */ +export async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Check whether the daemon process is currently running. + * + * @returns True if the daemon PID file exists and the process is alive. + */ +export async function isDaemonRunning(): Promise { + if (!(await fileExists(PID_FILE))) { + return false; + } + try { + const pid = Number((await readFile(PID_FILE, 'utf-8')).trim()); + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Read the daemon PID from the PID file. + * + * @returns The daemon PID, or null if not available. + */ +export async function readDaemonPid(): Promise { + if (!(await fileExists(PID_FILE))) { + return null; + } + try { + return Number((await readFile(PID_FILE, 'utf-8')).trim()); + } catch { + return null; + } +} + +/** + * Clean up stale PID and socket files. + */ +async function cleanupStaleFiles(): Promise { + if (await fileExists(PID_FILE)) { + await unlink(PID_FILE); + } + if (await fileExists(SOCK_FILE)) { + await unlink(SOCK_FILE); + } +} + +/** + * Start the daemon as a detached child process. + * + * @param daemonProcessPath - Absolute path to the daemon process entry point script. + * @param logger - Logger instance. + * @returns The PID of the forked daemon process. + */ +export async function startDaemon( + daemonProcessPath: string, + logger: Logger, +): Promise { + if (await isDaemonRunning()) { + const pid = await readDaemonPid(); + throw new Error(`Daemon already running (PID ${pid})`); + } + + await cleanupStaleFiles(); + await mkdir(DAEMON_DIR, { recursive: true }); + + const child = fork(daemonProcessPath, [], { + detached: true, + stdio: 'ignore', + }); + + child.unref(); + + const { pid } = child; + if (pid === undefined) { + throw new Error('Failed to start daemon: no PID returned'); + } + + // Wait for the socket file to appear, confirming startup + const startTime = Date.now(); + const timeout = 10_000; + while (!(await fileExists(SOCK_FILE))) { + if (Date.now() - startTime > timeout) { + throw new Error('Daemon did not start within timeout'); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + logger.info(`Daemon started (PID ${pid})`); + return pid; +} + +/** + * Stop a running daemon process. + * Sends a shutdown RPC; falls back to SIGTERM if RPC fails. + * + * @param logger - Logger instance. + */ +export async function stopDaemon(logger: Logger): Promise { + if (!(await isDaemonRunning())) { + throw new Error('Daemon is not running'); + } + + const pid = await readDaemonPid(); + + try { + await sendShutdown(); + } catch { + // Fallback: send SIGTERM + if (pid !== null) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Process may have already exited + } + } + } + + // Wait for the process to exit + const startTime = Date.now(); + const exitTimeout = 5_000; + while (await isDaemonRunning()) { + if (Date.now() - startTime > exitTimeout) { + // Force kill + if (pid !== null) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already exited + } + } + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await cleanupStaleFiles(); + logger.info('Daemon stopped'); +} + +/** + * Delete the daemon database and its SQLite sidecar files. + * Refuses to run if the daemon is currently active. + * + * @param logger - Logger instance. + */ +export async function flushDaemonStore(logger: Logger): Promise { + if (await isDaemonRunning()) { + throw new Error('Cannot flush while daemon is running — stop it first'); + } + + const filesToDelete = [ + DB_FILE, + `${DB_FILE}-wal`, + `${DB_FILE}-shm`, + `${DB_FILE}-journal`, + ]; + + for (const filePath of filesToDelete) { + if (await fileExists(filePath)) { + await unlink(filePath); + } + } + + logger.info('Daemon store flushed'); +} diff --git a/packages/kernel-daemon/src/daemon-server.test.ts b/packages/kernel-daemon/src/daemon-server.test.ts new file mode 100644 index 000000000..a54d31566 --- /dev/null +++ b/packages/kernel-daemon/src/daemon-server.test.ts @@ -0,0 +1,40 @@ +import { Logger } from '@metamask/logger'; +import { createServer } from 'node:net'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { createDaemonServer } from './daemon-server.ts'; +import type { RpcDispatcher } from './daemon-server.ts'; + +vi.mock('node:net', () => ({ + createServer: vi.fn(), +})); + +describe('createDaemonServer', () => { + const mockServer = { + listen: vi.fn(), + close: vi.fn(), + }; + + beforeEach(() => { + vi.mocked(createServer).mockReturnValue(mockServer as never); + }); + + it('creates a server and listens on the socket file', () => { + const rpcDispatcher: RpcDispatcher = { + assertHasMethod: vi.fn(), + execute: vi.fn(), + }; + const logger = new Logger('test'); + const onShutdown = vi.fn(); + + const server = createDaemonServer({ + rpcDispatcher, + logger, + onShutdown, + }); + + expect(createServer).toHaveBeenCalled(); + expect(server).toBe(mockServer); + expect(mockServer.listen).toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-daemon/src/daemon-server.ts b/packages/kernel-daemon/src/daemon-server.ts new file mode 100644 index 000000000..6755bd29e --- /dev/null +++ b/packages/kernel-daemon/src/daemon-server.ts @@ -0,0 +1,145 @@ +import type { Logger } from '@metamask/logger'; +import { createServer } from 'node:net'; +import type { Server, Socket } from 'node:net'; + +import { SOCK_FILE } from './constants.ts'; + +/** + * An object capable of dispatching RPC method calls. + * Typically backed by an RpcService, but abstracted to avoid + * importing cycle-inducing handler packages. + */ +export type RpcDispatcher = { + assertHasMethod(method: string): void; + execute(method: string, params: unknown): Promise; +}; + +/** + * Options for creating the daemon RPC server. + */ +type DaemonServerOptions = { + rpcDispatcher: RpcDispatcher; + logger: Logger; + onShutdown: () => Promise; +}; + +/** + * Create and start the Unix domain socket RPC server. + * + * @param options - Options for the daemon server. + * @param options.rpcDispatcher - The RPC dispatcher for handling method calls. + * @param options.logger - Logger instance. + * @param options.onShutdown - Callback invoked on shutdown RPC. + * @returns The running server instance. + */ +export function createDaemonServer({ + rpcDispatcher, + logger, + onShutdown, +}: DaemonServerOptions): Server { + const server = createServer((connection: Socket) => { + logger.debug('Client connected'); + let buffer = ''; + + connection.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + handleMessage( + connection, + rpcDispatcher, + line, + logger, + onShutdown, + ).catch((error) => logger.error('Error handling message', error)); + } + }); + + connection.on('error', (error) => { + logger.debug('Client connection error', error); + }); + + connection.on('close', () => { + logger.debug('Client disconnected'); + }); + }); + + server.listen(SOCK_FILE, () => { + logger.info(`Daemon server listening on ${SOCK_FILE}`); + }); + + return server; +} + +/** + * Handle a single JSON-RPC message from a client connection. + * + * @param connection - The client socket connection. + * @param rpcDispatcher - The RPC dispatcher for handling method calls. + * @param line - The raw JSON-RPC message string. + * @param logger - Logger instance. + * @param onShutdown - Callback for shutdown requests. + */ +async function handleMessage( + connection: Socket, + rpcDispatcher: RpcDispatcher, + line: string, + logger: Logger, + onShutdown: () => Promise, +): Promise { + let parsed: { + id?: string | number | null; + method?: string; + params?: unknown; + }; + try { + parsed = JSON.parse(line); + } catch { + const response = { + jsonrpc: '2.0' as const, + id: null, + error: { code: -32700, message: 'Parse error' }, + }; + connection.write(`${JSON.stringify(response)}\n`); + return; + } + + const { id, method, params } = parsed; + + if (!method) { + const response = { + jsonrpc: '2.0' as const, + id, + error: { code: -32600, message: 'Invalid Request: missing method' }, + }; + connection.write(`${JSON.stringify(response)}\n`); + return; + } + + if (method === 'shutdown') { + const response = { jsonrpc: '2.0' as const, id, result: true }; + connection.write(`${JSON.stringify(response)}\n`); + await onShutdown(); + return; + } + + try { + rpcDispatcher.assertHasMethod(method); + const result = await rpcDispatcher.execute(method, params); + const response = { jsonrpc: '2.0' as const, id, result }; + connection.write(`${JSON.stringify(response)}\n`); + } catch (error) { + logger.error(`RPC error for method ${method}`, error); + const response = { + jsonrpc: '2.0' as const, + id, + error: { + code: (error as { code?: number }).code ?? -32603, + message: (error as Error).message, + }, + }; + connection.write(`${JSON.stringify(response)}\n`); + } +} diff --git a/packages/kernel-daemon/src/index.ts b/packages/kernel-daemon/src/index.ts new file mode 100644 index 000000000..1e7e45bfe --- /dev/null +++ b/packages/kernel-daemon/src/index.ts @@ -0,0 +1,19 @@ +export { + DAEMON_DIR, + PID_FILE, + SOCK_FILE, + DB_FILE, + LOG_FILE, +} from './constants.ts'; +export { connectToDaemon, sendShutdown } from './daemon-client.ts'; +export { + fileExists, + startDaemon, + stopDaemon, + isDaemonRunning, + readDaemonPid, + flushDaemonStore, +} from './daemon-lifecycle.ts'; +export { createDaemonServer } from './daemon-server.ts'; +export type { RpcDispatcher } from './daemon-server.ts'; +export type { DaemonConnection } from './types.ts'; diff --git a/packages/kernel-daemon/src/types.ts b/packages/kernel-daemon/src/types.ts new file mode 100644 index 000000000..ef4929848 --- /dev/null +++ b/packages/kernel-daemon/src/types.ts @@ -0,0 +1,17 @@ +import type { Socket } from 'node:net'; + +/** + * A connected daemon client wrapping an RPC client over a Unix socket. + */ +export type DaemonConnection = { + client: { + call(method: string, params: Record): Promise; + }; + close: () => void; + socket: Socket; +}; + +/** + * Shutdown RPC method name. + */ +export const SHUTDOWN_METHOD = 'shutdown' as const; diff --git a/packages/kernel-daemon/test/setup.ts b/packages/kernel-daemon/test/setup.ts new file mode 100644 index 000000000..9345e03aa --- /dev/null +++ b/packages/kernel-daemon/test/setup.ts @@ -0,0 +1 @@ +import '@ocap/repo-tools/test-utils/mock-endoify'; diff --git a/packages/kernel-daemon/tsconfig.build.json b/packages/kernel-daemon/tsconfig.build.json new file mode 100644 index 000000000..a52de7e20 --- /dev/null +++ b/packages/kernel-daemon/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "types": ["node", "ses"] + }, + "references": [ + { "path": "../kernel-rpc-methods/tsconfig.build.json" }, + { "path": "../logger/tsconfig.build.json" } + ], + "include": ["./src"] +} diff --git a/packages/kernel-daemon/tsconfig.json b/packages/kernel-daemon/tsconfig.json new file mode 100644 index 000000000..4886950f9 --- /dev/null +++ b/packages/kernel-daemon/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "types": ["node", "ses", "vitest"] + }, + "references": [ + { "path": "../kernel-rpc-methods" }, + { "path": "../logger" }, + { "path": "../repo-tools" } + ], + "include": ["../../vitest.config.ts", "./src", "./test", "./vitest.config.ts"] +} diff --git a/packages/kernel-daemon/vitest.config.ts b/packages/kernel-daemon/vitest.config.ts new file mode 100644 index 000000000..23077cfd4 --- /dev/null +++ b/packages/kernel-daemon/vitest.config.ts @@ -0,0 +1,17 @@ +import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'kernel-daemon', + setupFiles: ['./test/setup.ts'], + }, + }), + ); +}); diff --git a/yarn.lock b/yarn.lock index 61c9f72d5..e01265ef3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3601,6 +3601,27 @@ __metadata: languageName: unknown linkType: soft +"@ocap/kernel-daemon@workspace:packages/kernel-daemon": + version: 0.0.0-use.local + resolution: "@ocap/kernel-daemon@workspace:packages/kernel-daemon" + dependencies: + "@metamask/kernel-rpc-methods": "workspace:^" + "@metamask/logger": "workspace:^" + "@ocap/repo-tools": "workspace:^" + "@ts-bridge/cli": "npm:^0.6.3" + "@ts-bridge/shims": "npm:^0.1.1" + "@types/node": "npm:^22.13.1" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + ses: "npm:^1.14.0" + typescript: "npm:~5.8.2" + vite: "npm:^7.3.0" + vitest: "npm:^4.0.16" + languageName: unknown + linkType: soft + "@ocap/kernel-language-model-service@workspace:^, @ocap/kernel-language-model-service@workspace:packages/kernel-language-model-service": version: 0.0.0-use.local resolution: "@ocap/kernel-language-model-service@workspace:packages/kernel-language-model-service"