diff --git a/AGENTS.md b/AGENTS.md index ef22e2de..bc145e87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ Work style: telegraph; noun-phrases ok; drop grammar; Short guide for AI agents in this repo. Prefer progressive loading: start with the root README, then package READMEs as needed. ## Repo layout -- `cli/` - hapi CLI, daemon, Codex/MCP tooling +- `cli/` - hapi CLI, runner, Codex/MCP tooling - `server/` - Telegram bot + HTTP API + Socket.IO + SSE - `web/` - React Mini App / PWA diff --git a/cli/.github/workflows/smoke-test.yml b/cli/.github/workflows/smoke-test.yml index af845c47..537991e6 100644 --- a/cli/.github/workflows/smoke-test.yml +++ b/cli/.github/workflows/smoke-test.yml @@ -59,9 +59,9 @@ jobs: exit 1 } - echo "Testing happy daemon status..." - timeout 10s happy daemon status || { - echo "Error: happy daemon status failed or timed out" + echo "Testing happy runner status..." + timeout 10s happy runner status || { + echo "Error: happy runner status failed or timed out" exit 1 } @@ -192,15 +192,15 @@ jobs: exit /b 1 ) - rem Test daemon status - echo Testing happy daemon status... + rem Test runner status + echo Testing happy runner status... if exist "%NPM_PREFIX%\happy.cmd" ( - "%NPM_PREFIX%\happy.cmd" daemon status + "%NPM_PREFIX%\happy.cmd" runner status ) else ( - happy daemon status + happy runner status ) if errorlevel 1 ( - echo Error: happy daemon status failed + echo Error: happy runner status failed exit /b 1 ) diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index ca07375b..60c19698 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -131,26 +131,26 @@ User interface components. - Testing: Vitest -# Running the Daemon +# Running the Runner -## Starting the Daemon +## Starting the Runner ```bash # From the hapi CLI directory: -hapi daemon start +hapi runner start # With custom bot URL (for local development): -HAPI_SERVER_URL=http://localhost:3006 CLI_API_TOKEN=your_token hapi daemon start +HAPI_SERVER_URL=http://localhost:3006 CLI_API_TOKEN=your_token hapi runner start -# Stop the daemon: -hapi daemon stop +# Stop the runner: +hapi runner stop -# Check daemon status: -hapi daemon status +# Check runner status: +hapi runner status ``` -## Daemon Logs -- Daemon logs are stored in `~/.hapi/logs/` (or `$HAPI_HOME/logs/`) -- Named with format: `YYYY-MM-DD-HH-MM-SS-daemon.log` +## Runner Logs +- Runner logs are stored in `~/.hapi/logs/` (or `$HAPI_HOME/logs/`) +- Named with format: `YYYY-MM-DD-HH-MM-SS-runner.log` # Session Forking `claude` and sdk behavior diff --git a/cli/NOTICE b/cli/NOTICE index 6390deed..41fcc973 100644 --- a/cli/NOTICE +++ b/cli/NOTICE @@ -35,7 +35,7 @@ The following files/directories are derived from happy-cli: - src/api/ - src/claude/ - src/codex/ -- src/daemon/ +- src/runner/ - src/commands/ - src/ui/ - src/utils/ diff --git a/cli/README.md b/cli/README.md index 776a2b3f..35f9b32e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -8,7 +8,7 @@ Run Claude Code, Codex, or Gemini sessions from your terminal and control them r - Starts Codex mode for OpenAI-based sessions. - Starts Gemini mode via ACP (Anthropic Code Plugins). - Provides an MCP stdio bridge for external tools. -- Manages a background daemon for long-running sessions. +- Manages a background runner for long-running sessions. - Includes diagnostics and auth helpers. ## Typical flow @@ -35,22 +35,22 @@ Run Claude Code, Codex, or Gemini sessions from your terminal and control them r See `src/commands/auth.ts`. -### Daemon management +### Runner management -- `hapi daemon start` - Start daemon as detached process. -- `hapi daemon stop` - Stop daemon gracefully. -- `hapi daemon status` - Show daemon diagnostics. -- `hapi daemon list` - List active sessions managed by daemon. -- `hapi daemon stop-session ` - Terminate specific session. -- `hapi daemon logs` - Print path to latest daemon log file. -- `hapi daemon install` - Install daemon as system service. -- `hapi daemon uninstall` - Remove daemon system service. +- `hapi runner start` - Start runner as detached process. +- `hapi runner stop` - Stop runner gracefully. +- `hapi runner status` - Show runner diagnostics. +- `hapi runner list` - List active sessions managed by runner. +- `hapi runner stop-session ` - Terminate specific session. +- `hapi runner logs` - Print path to latest runner log file. +- `hapi runner install` - Install runner as system service. +- `hapi runner uninstall` - Remove runner system service. -See `src/daemon/run.ts`. +See `src/runner/run.ts`. ### Diagnostics -- `hapi doctor` - Show full diagnostics (version, daemon status, logs, processes). +- `hapi doctor` - Show full diagnostics (version, runner status, logs, processes). - `hapi doctor clean` - Kill runaway HAPI processes. See `src/ui/doctor.ts`. @@ -76,17 +76,17 @@ See `src/configuration.ts` for all options. - `HAPI_CLAUDE_PATH` - Path to a specific `claude` executable. - `HAPI_HTTP_MCP_URL` - Default MCP target for `hapi mcp`. -### Daemon +### Runner -- `HAPI_DAEMON_HEARTBEAT_INTERVAL` - Heartbeat interval in ms (default: 60000). -- `HAPI_DAEMON_HTTP_TIMEOUT` - HTTP timeout for daemon control in ms (default: 10000). +- `HAPI_RUNNER_HEARTBEAT_INTERVAL` - Heartbeat interval in ms (default: 60000). +- `HAPI_RUNNER_HTTP_TIMEOUT` - HTTP timeout for runner control in ms (default: 10000). ## Storage Data is stored in `~/.hapi/` (or `$HAPI_HOME`): - `settings.json` - User settings (machineId, token, onboarding flag). See `src/persistence.ts`. -- `daemon.state.json` - Daemon state (pid, port, version, heartbeat). +- `runner.state.json` - Runner state (pid, port, version, heartbeat). - `logs/` - Log files. ## Requirements @@ -116,7 +116,7 @@ bun run build:single-exe - `src/claude/` - Claude Code integration. - `src/codex/` - Codex mode integration. - `src/agent/` - Multi-agent support (Gemini via ACP). -- `src/daemon/` - Background service. +- `src/runner/` - Background service. - `src/commands/` - CLI command handlers. - `src/ui/` - User interface and diagnostics. - `src/modules/` - Tool implementations (ripgrep, difftastic, git). diff --git a/cli/src/agent/localLaunchPolicy.ts b/cli/src/agent/localLaunchPolicy.ts index c879b3a2..7b88f453 100644 --- a/cli/src/agent/localLaunchPolicy.ts +++ b/cli/src/agent/localLaunchPolicy.ts @@ -1,4 +1,4 @@ -export type StartedBy = 'daemon' | 'terminal'; +export type StartedBy = 'runner' | 'terminal'; export type LocalLaunchExitReason = 'switch' | 'exit'; @@ -8,7 +8,7 @@ export type LocalLaunchContext = { }; export function getLocalLaunchExitReason(context: LocalLaunchContext): LocalLaunchExitReason { - if (context.startedBy === 'daemon' || context.startingMode === 'remote') { + if (context.startedBy === 'runner' || context.startingMode === 'remote') { return 'switch'; } diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index c764546a..e8f32cab 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -26,7 +26,7 @@ function emitReadyIfIdle(props: { export async function runAgentSession(opts: { agentType: string; - startedBy?: 'daemon' | 'terminal'; + startedBy?: 'runner' | 'terminal'; }): Promise { const initialState: AgentState = { controlledByUser: false diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index dcd22637..025b4e8e 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -5,7 +5,7 @@ import { resolve } from 'node:path' import { ApiClient } from '@/api/api' import type { ApiSessionClient } from '@/api/apiSession' import type { AgentState, MachineMetadata, Metadata, Session } from '@/api/types' -import { notifyDaemonSessionStarted } from '@/daemon/controlClient' +import { notifyRunnerSessionStarted } from '@/runner/controlClient' import { readSettings } from '@/persistence' import { configuration } from '@/configuration' import { logger } from '@/ui/logger' @@ -13,7 +13,7 @@ import { runtimePath } from '@/projectPath' import { readWorktreeEnv } from '@/utils/worktreeEnv' import packageJson from '../../package.json' -export type SessionStartedBy = 'daemon' | 'terminal' +export type SessionStartedBy = 'runner' | 'terminal' export type SessionBootstrapOptions = { flavor: string @@ -65,7 +65,7 @@ export function buildSessionMetadata(options: { happyHomeDir: configuration.happyHomeDir, happyLibDir, happyToolsDir: resolve(happyLibDir, 'tools', 'unpacked'), - startedFromDaemon: options.startedBy === 'daemon', + startedFromRunner: options.startedBy === 'runner', hostPid: process.pid, startedBy: options.startedBy, lifecycleState: 'running', @@ -88,15 +88,15 @@ async function getMachineIdOrExit(): Promise { async function reportSessionStarted(sessionId: string, metadata: Metadata): Promise { try { - logger.debug(`[START] Reporting session ${sessionId} to daemon`) - const result = await notifyDaemonSessionStarted(sessionId, metadata) + logger.debug(`[START] Reporting session ${sessionId} to runner`) + const result = await notifyRunnerSessionStarted(sessionId, metadata) if (result?.error) { - logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error) + logger.debug(`[START] Failed to report to runner (may not be running):`, result.error) } else { - logger.debug(`[START] Reported session ${sessionId} to daemon`) + logger.debug(`[START] Reported session ${sessionId} to runner`) } } catch (error) { - logger.debug('[START] Failed to report to daemon (may not be running):', error) + logger.debug('[START] Failed to report to runner (may not be running):', error) } } diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 69f8bf36..a1402feb 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -1,6 +1,6 @@ import axios from 'axios' -import type { AgentState, CreateMachineResponse, CreateSessionResponse, DaemonState, Machine, MachineMetadata, Metadata, Session } from '@/api/types' -import { AgentStateSchema, CreateMachineResponseSchema, CreateSessionResponseSchema, DaemonStateSchema, MachineMetadataSchema, MetadataSchema } from '@/api/types' +import type { AgentState, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types' +import { AgentStateSchema, CreateMachineResponseSchema, CreateSessionResponseSchema, RunnerStateSchema, MachineMetadataSchema, MetadataSchema } from '@/api/types' import { configuration } from '@/configuration' import { getAuthToken } from '@/api/auth' import { ApiMachineClient } from './apiMachine' @@ -76,14 +76,14 @@ export class ApiClient { async getOrCreateMachine(opts: { machineId: string metadata: MachineMetadata - daemonState?: DaemonState + runnerState?: RunnerState }): Promise { const response = await axios.post( `${configuration.serverUrl}/cli/machines`, { id: opts.machineId, metadata: opts.metadata, - daemonState: opts.daemonState ?? null + runnerState: opts.runnerState ?? null }, { headers: { @@ -107,10 +107,10 @@ export class ApiClient { return parsedMetadata.success ? parsedMetadata.data : null })() - const daemonState = (() => { - if (raw.daemonState == null) return null - const parsedDaemonState = DaemonStateSchema.safeParse(raw.daemonState) - return parsedDaemonState.success ? parsedDaemonState.data : null + const runnerState = (() => { + if (raw.runnerState == null) return null + const parsedRunnerState = RunnerStateSchema.safeParse(raw.runnerState) + return parsedRunnerState.success ? parsedRunnerState.data : null })() return { @@ -122,8 +122,8 @@ export class ApiClient { activeAt: raw.activeAt, metadata, metadataVersion: raw.metadataVersion, - daemonState, - daemonStateVersion: raw.daemonStateVersion + runnerState, + runnerStateVersion: raw.runnerStateVersion } } diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 20cda20a..c6c87412 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -1,26 +1,26 @@ /** - * WebSocket client for machine/daemon communication with hapi-server + * WebSocket client for machine/runner communication with hapi-server */ import { io, type Socket } from 'socket.io-client' import { stat } from 'node:fs/promises' import { logger } from '@/ui/logger' import { configuration } from '@/configuration' -import type { DaemonState, Machine, MachineMetadata, Update, UpdateMachineBody } from './types' -import { DaemonStateSchema, MachineMetadataSchema } from './types' +import type { RunnerState, Machine, MachineMetadata, Update, UpdateMachineBody } from './types' +import { RunnerStateSchema, MachineMetadataSchema } from './types' import { backoff } from '@/utils/time' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' -interface ServerToDaemonEvents { +interface ServerToRunnerEvents { update: (data: Update) => void 'rpc-request': (data: { method: string; params: string }, callback: (response: string) => void) => void error: (data: { message: string }) => void } -interface DaemonToServerEvents { +interface RunnerToServerEvents { 'machine-alive': (data: { machineId: string; time: number }) => void 'machine-update-metadata': (data: { machineId: string; metadata: unknown; expectedVersion: number }, cb: (answer: { result: 'error' @@ -33,16 +33,16 @@ interface DaemonToServerEvents { version: number metadata: unknown | null }) => void) => void - 'machine-update-state': (data: { machineId: string; daemonState: unknown | null; expectedVersion: number }, cb: (answer: { + 'machine-update-state': (data: { machineId: string; runnerState: unknown | null; expectedVersion: number }, cb: (answer: { result: 'error' } | { result: 'version-mismatch' version: number - daemonState: unknown | null + runnerState: unknown | null } | { result: 'success' version: number - daemonState: unknown | null + runnerState: unknown | null }) => void) => void 'rpc-register': (data: { method: string }) => void 'rpc-unregister': (data: { method: string }) => void @@ -63,7 +63,7 @@ interface PathExistsResponse { } export class ApiMachineClient { - private socket!: Socket + private socket!: Socket private keepAliveInterval: NodeJS.Timeout | null = null private rpcHandlerManager: RpcHandlerManager @@ -142,9 +142,9 @@ export class ApiMachineClient { return { message: 'Session stopped' } }) - this.rpcHandlerManager.registerHandler('stop-daemon', () => { + this.rpcHandlerManager.registerHandler('stop-runner', () => { setTimeout(() => requestShutdown(), 100) - return { message: 'Daemon stop request acknowledged' } + return { message: 'Runner stop request acknowledged' } }) } @@ -181,35 +181,35 @@ export class ApiMachineClient { }) } - async updateDaemonState(handler: (state: DaemonState | null) => DaemonState): Promise { + async updateRunnerState(handler: (state: RunnerState | null) => RunnerState): Promise { await backoff(async () => { - const updated = handler(this.machine.daemonState) + const updated = handler(this.machine.runnerState) const answer = await this.socket.emitWithAck('machine-update-state', { machineId: this.machine.id, - daemonState: updated, - expectedVersion: this.machine.daemonStateVersion + runnerState: updated, + expectedVersion: this.machine.runnerStateVersion }) as unknown applyVersionedAck(answer, { - valueKey: 'daemonState', + valueKey: 'runnerState', parseValue: (value) => { - const parsed = DaemonStateSchema.safeParse(value) + const parsed = RunnerStateSchema.safeParse(value) return parsed.success ? parsed.data : null }, applyValue: (value) => { - this.machine.daemonState = value + this.machine.runnerState = value }, applyVersion: (version) => { - this.machine.daemonStateVersion = version + this.machine.runnerStateVersion = version }, logInvalidValue: (context, version) => { const suffix = context === 'success' ? 'ack' : 'version-mismatch ack' - logger.debug(`[API MACHINE] Ignoring invalid daemonState value from ${suffix}`, { version }) + logger.debug(`[API MACHINE] Ignoring invalid runnerState value from ${suffix}`, { version }) }, invalidResponseMessage: 'Invalid machine-update-state response', errorMessage: 'Machine state update failed', - versionMismatchMessage: 'Daemon state version mismatch' + versionMismatchMessage: 'Runner state version mismatch' }) }) } @@ -231,14 +231,14 @@ export class ApiMachineClient { this.socket.on('connect', () => { logger.debug('[API MACHINE] Connected to bot') this.rpcHandlerManager.onSocketConnect(this.socket) - this.updateDaemonState((state) => ({ + this.updateRunnerState((state) => ({ ...(state ?? {}), status: 'running', pid: process.pid, - httpPort: this.machine.daemonState?.httpPort, + httpPort: this.machine.runnerState?.httpPort, startedAt: Date.now() })).catch((error) => { - logger.debug('[API MACHINE] Failed to update daemon state on connect', error) + logger.debug('[API MACHINE] Failed to update runner state on connect', error) }) this.startKeepAlive() }) @@ -273,19 +273,19 @@ export class ApiMachineClient { this.machine.metadataVersion = update.metadata.version } - if (update.daemonState) { - const next = update.daemonState.value + if (update.runnerState) { + const next = update.runnerState.value if (next == null) { - this.machine.daemonState = null + this.machine.runnerState = null } else { - const parsed = DaemonStateSchema.safeParse(next) + const parsed = RunnerStateSchema.safeParse(next) if (parsed.success) { - this.machine.daemonState = parsed.data + this.machine.runnerState = parsed.data } else { - logger.debug('[API MACHINE] Ignoring invalid daemonState update', { version: update.daemonState.version }) + logger.debug('[API MACHINE] Ignoring invalid runnerState update', { version: update.runnerState.version }) } } - this.machine.daemonStateVersion = update.daemonState.version + this.machine.runnerStateVersion = update.runnerState.version } }) diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index f1abfa66..f635de1c 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -32,7 +32,7 @@ export const MachineMetadataSchema = z.object({ export type MachineMetadata = z.infer -export const DaemonStateSchema = z.object({ +export const RunnerStateSchema = z.object({ status: z.union([z.enum(['running', 'shutting-down']), z.string()]), pid: z.number().optional(), httpPort: z.number().optional(), @@ -41,7 +41,7 @@ export const DaemonStateSchema = z.object({ shutdownSource: z.union([z.enum(['mobile-app', 'cli', 'os-signal', 'unknown']), z.string()]).optional() }).passthrough() -export type DaemonState = z.infer +export type RunnerState = z.infer export type Machine = { id: string @@ -52,8 +52,8 @@ export type Machine = { activeAt: number metadata: MachineMetadata | null metadataVersion: number - daemonState: DaemonState | null - daemonStateVersion: number + runnerState: RunnerState | null + runnerStateVersion: number } export const UpdateNewMessageBodySchema = z.object({ @@ -92,7 +92,7 @@ export const UpdateMachineBodySchema = z.object({ version: z.number(), value: z.unknown() }).nullable(), - daemonState: z.object({ + runnerState: z.object({ version: z.number(), value: z.unknown().nullable() }).nullable() @@ -154,8 +154,8 @@ export const CreateMachineResponseSchema = z.object({ activeAt: z.number(), metadata: z.unknown().nullable(), metadataVersion: z.number(), - daemonState: z.unknown().nullable(), - daemonStateVersion: z.number() + runnerState: z.unknown().nullable(), + runnerStateVersion: z.number() }) }) @@ -271,17 +271,17 @@ export interface ClientToServerEvents { version: number metadata: unknown | null }) => void) => void - 'machine-update-state': (data: { machineId: string; expectedVersion: number; daemonState: unknown | null }, cb: (answer: { + 'machine-update-state': (data: { machineId: string; expectedVersion: number; runnerState: unknown | null }, cb: (answer: { result: 'error' reason?: SocketErrorReason } | { result: 'version-mismatch' version: number - daemonState: unknown | null + runnerState: unknown | null } | { result: 'success' version: number - daemonState: unknown | null + runnerState: unknown | null }) => void) => void 'rpc-register': (data: { method: string }) => void 'rpc-unregister': (data: { method: string }) => void diff --git a/cli/src/claude/loop.ts b/cli/src/claude/loop.ts index 8030786f..7f337cc3 100644 --- a/cli/src/claude/loop.ts +++ b/cli/src/claude/loop.ts @@ -26,7 +26,7 @@ interface LoopOptions { model?: string permissionMode?: PermissionMode startingMode?: 'local' | 'remote' - startedBy?: 'daemon' | 'terminal' + startedBy?: 'runner' | 'terminal' onModeChange: (mode: 'local' | 'remote') => void mcpServers: Record session: ApiSessionClient diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index b79dc9a9..b2509067 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -22,10 +22,10 @@ export interface StartOptions { model?: string permissionMode?: PermissionMode startingMode?: 'local' | 'remote' - shouldStartDaemon?: boolean + shouldStartRunner?: boolean claudeEnvVars?: Record claudeArgs?: string[] - startedBy?: 'daemon' | 'terminal' + startedBy?: 'runner' | 'terminal' } export async function runClaude(options: StartOptions = {}): Promise { @@ -36,12 +36,12 @@ export async function runClaude(options: StartOptions = {}): Promise { logger.debugLargeJson('[START] HAPI process started', getEnvironmentInfo()); logger.debug(`[START] Options: startedBy=${startedBy}, startingMode=${options.startingMode}`); - // Validate daemon spawn requirements - if (startedBy === 'daemon' && options.startingMode === 'local') { - logger.debug('Daemon spawn requested with local mode - forcing remote mode'); + // Validate runner spawn requirements + if (startedBy === 'runner' && options.startingMode === 'local') { + logger.debug('Runner spawn requested with local mode - forcing remote mode'); options.startingMode = 'remote'; // TODO: Eventually we should error here instead of silently switching - // throw new Error('Daemon-spawned sessions cannot use local/interactive mode'); + // throw new Error('Runner-spawned sessions cannot use local/interactive mode'); } const initialState: AgentState = {}; @@ -124,7 +124,7 @@ export async function runClaude(options: StartOptions = {}): Promise { registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); // Set initial agent state - const startingMode = options.startingMode ?? (startedBy === 'daemon' ? 'remote' : 'local'); + const startingMode = options.startingMode ?? (startedBy === 'runner' ? 'remote' : 'local'); setControlledByUser(session, startingMode); // Import MessageQueue2 and create message queue diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index ae5e888a..faa11b77 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -18,7 +18,7 @@ export class Session extends AgentSessionBase { readonly mcpServers: Record; readonly allowedTools?: string[]; readonly hookSettingsPath: string; - readonly startedBy: 'daemon' | 'terminal'; + readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; @@ -35,7 +35,7 @@ export class Session extends AgentSessionBase { onModeChange: (mode: 'local' | 'remote') => void; allowedTools?: string[]; mode?: 'local' | 'remote'; - startedBy: 'daemon' | 'terminal'; + startedBy: 'runner' | 'terminal'; startingMode: 'local' | 'remote'; hookSettingsPath: string; permissionMode?: PermissionMode; diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 8dbdaa0e..d0d170c4 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -18,7 +18,7 @@ export interface EnhancedMode { interface LoopOptions { path: string; startingMode?: 'local' | 'remote'; - startedBy?: 'daemon' | 'terminal'; + startedBy?: 'runner' | 'terminal'; onModeChange: (mode: 'local' | 'remote') => void; messageQueue: MessageQueue2; session: ApiSessionClient; diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 8f0244c1..4810a9d0 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -15,7 +15,7 @@ import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; export async function runCodex(opts: { - startedBy?: 'daemon' | 'terminal'; + startedBy?: 'runner' | 'terminal'; codexArgs?: string[]; permissionMode?: PermissionMode; resumeSessionId?: string; @@ -35,7 +35,7 @@ export async function runCodex(opts: { agentState: state }); - const startingMode: 'local' | 'remote' = startedBy === 'daemon' ? 'remote' : 'local'; + const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; setControlledByUser(session, startingMode); diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index d6a71815..d32ba4fa 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -13,7 +13,7 @@ type LocalLaunchFailure = { export class CodexSession extends AgentSessionBase { readonly codexArgs?: string[]; readonly codexCliOverrides?: CodexCliOverrides; - readonly startedBy: 'daemon' | 'terminal'; + readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; @@ -26,7 +26,7 @@ export class CodexSession extends AgentSessionBase { messageQueue: MessageQueue2; onModeChange: (mode: 'local' | 'remote') => void; mode?: 'local' | 'remote'; - startedBy: 'daemon' | 'terminal'; + startedBy: 'runner' | 'terminal'; startingMode: 'local' | 'remote'; codexArgs?: string[]; codexCliOverrides?: CodexCliOverrides; diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index c0c285a6..38db4c0f 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process' import { z } from 'zod' import type { StartOptions } from '@/claude/runClaude' import { configuration } from '@/configuration' -import { isDaemonRunningCurrentlyInstalledHappyVersion } from '@/daemon/controlClient' +import { isRunnerRunningCurrentlyInstalledHappyVersion } from '@/runner/controlClient' import { authAndSetupMachineIfNeeded } from '@/ui/auth' import { logger } from '@/ui/logger' import { initializeToken } from '@/ui/tokenInit' @@ -42,7 +42,7 @@ export const claudeCommand: CommandDefinition = { options.permissionMode = 'bypassPermissions' unknownArgs.push(arg) } else if (arg === '--started-by') { - options.startedBy = args[++i] as 'daemon' | 'terminal' + options.startedBy = args[++i] as 'runner' | 'terminal' } else { unknownArgs.push(arg) if (i + 1 < args.length && !args[i + 1].startsWith('-')) { @@ -69,7 +69,7 @@ ${chalk.bold('Usage:')} hapi notify (not available in direct-connect mode) hapi server Start the API + web server hapi server --relay Start with public relay - hapi daemon Manage background service that allows + hapi runner Manage background service that allows to spawn new sessions away from your computer hapi doctor System diagnostics & troubleshooting @@ -110,15 +110,15 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} logger.debug('Ensuring hapi background service is running & matches our version...') - if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) { + if (!(await isRunnerRunningCurrentlyInstalledHappyVersion())) { logger.debug('Starting hapi background service...') - const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], { + const runnerProcess = spawnHappyCLI(['runner', 'start-sync'], { detached: true, stdio: 'ignore', env: process.env }) - daemonProcess.unref() + runnerProcess.unref() await new Promise(resolve => setTimeout(resolve, 200)) } diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index e98e0aff..d7147745 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -13,7 +13,7 @@ export const codexCommand: CommandDefinition = { const { runCodex } = await import('@/codex/runCodex') const options: { - startedBy?: 'daemon' | 'terminal' + startedBy?: 'runner' | 'terminal' codexArgs?: string[] permissionMode?: CodexPermissionMode resumeSessionId?: string @@ -32,7 +32,7 @@ export const codexCommand: CommandDefinition = { continue } if (arg === '--started-by') { - options.startedBy = commandArgs[++i] as 'daemon' | 'terminal' + options.startedBy = commandArgs[++i] as 'runner' | 'terminal' } else if (arg === '--yolo' || arg === '--dangerously-bypass-approvals-and-sandbox') { options.permissionMode = 'yolo' unknownArgs.push(arg) diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 86a135f1..39067ae9 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -1,4 +1,4 @@ -import { killRunawayHappyProcesses } from '@/daemon/doctor' +import { killRunawayHappyProcesses } from '@/runner/doctor' import { runDoctorCommand } from '@/ui/doctor' import type { CommandDefinition } from './types' diff --git a/cli/src/commands/gemini.ts b/cli/src/commands/gemini.ts index 6501ecfd..9f9aaeb9 100644 --- a/cli/src/commands/gemini.ts +++ b/cli/src/commands/gemini.ts @@ -9,12 +9,12 @@ export const geminiCommand: CommandDefinition = { requiresRuntimeAssets: true, run: async ({ commandArgs }) => { try { - let startedBy: 'daemon' | 'terminal' | undefined + let startedBy: 'runner' | 'terminal' | undefined let yolo = false for (let i = 0; i < commandArgs.length; i++) { if (commandArgs[i] === '--started-by') { - startedBy = commandArgs[++i] as 'daemon' | 'terminal' + startedBy = commandArgs[++i] as 'runner' | 'terminal' } else if (commandArgs[i] === '--yolo') { yolo = true } diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 5c1bc627..2a2a1e87 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -2,7 +2,7 @@ import { authCommand } from './auth' import { claudeCommand } from './claude' import { codexCommand } from './codex' import { connectCommand } from './connect' -import { daemonCommand } from './daemon' +import { runnerCommand } from './runner' import { doctorCommand } from './doctor' import { geminiCommand } from './gemini' import { hookForwarderCommand } from './hookForwarder' @@ -20,7 +20,7 @@ const COMMANDS: CommandDefinition[] = [ serverCommand, hookForwarderCommand, doctorCommand, - daemonCommand, + runnerCommand, notifyCommand ] diff --git a/cli/src/commands/daemon.ts b/cli/src/commands/runner.ts similarity index 54% rename from cli/src/commands/daemon.ts rename to cli/src/commands/runner.ts index 22fae15e..218bf638 100644 --- a/cli/src/commands/daemon.ts +++ b/cli/src/commands/runner.ts @@ -1,40 +1,40 @@ import chalk from 'chalk' -import { startDaemon } from '@/daemon/run' +import { startRunner } from '@/runner/run' import { - checkIfDaemonRunningAndCleanupStaleState, - listDaemonSessions, - stopDaemon, - stopDaemonSession -} from '@/daemon/controlClient' -import { getLatestDaemonLog } from '@/ui/logger' + checkIfRunnerRunningAndCleanupStaleState, + listRunnerSessions, + stopRunner, + stopRunnerSession +} from '@/runner/controlClient' +import { getLatestRunnerLog } from '@/ui/logger' import { spawnHappyCLI } from '@/utils/spawnHappyCLI' import { runDoctorCommand } from '@/ui/doctor' import { initializeToken } from '@/ui/tokenInit' import type { CommandDefinition } from './types' -export const daemonCommand: CommandDefinition = { - name: 'daemon', +export const runnerCommand: CommandDefinition = { + name: 'runner', requiresRuntimeAssets: true, run: async ({ commandArgs }) => { - const daemonSubcommand = commandArgs[0] + const runnerSubcommand = commandArgs[0] - if (daemonSubcommand === 'list') { + if (runnerSubcommand === 'list') { try { - const sessions = await listDaemonSessions() + const sessions = await listRunnerSessions() if (sessions.length === 0) { - console.log('No active sessions this daemon is aware of (they might have been started by a previous version of the daemon)') + console.log('No active sessions this runner is aware of (they might have been started by a previous version of the runner)') } else { console.log('Active sessions:') console.log(JSON.stringify(sessions, null, 2)) } } catch { - console.log('No daemon running') + console.log('No runner running') } return } - if (daemonSubcommand === 'stop-session') { + if (runnerSubcommand === 'stop-session') { const sessionId = commandArgs[1] if (!sessionId) { console.error('Session ID required') @@ -42,16 +42,16 @@ export const daemonCommand: CommandDefinition = { } try { - const success = await stopDaemonSession(sessionId) + const success = await stopRunnerSession(sessionId) console.log(success ? 'Session stopped' : 'Failed to stop session') } catch { - console.log('No daemon running') + console.log('No runner running') } return } - if (daemonSubcommand === 'start') { - const child = spawnHappyCLI(['daemon', 'start-sync'], { + if (runnerSubcommand === 'start') { + const child = spawnHappyCLI(['runner', 'start-sync'], { detached: true, stdio: 'ignore', env: process.env @@ -60,7 +60,7 @@ export const daemonCommand: CommandDefinition = { let started = false for (let i = 0; i < 50; i++) { - if (await checkIfDaemonRunningAndCleanupStaleState()) { + if (await checkIfRunnerRunningAndCleanupStaleState()) { started = true break } @@ -68,34 +68,34 @@ export const daemonCommand: CommandDefinition = { } if (started) { - console.log('Daemon started successfully') + console.log('Runner started successfully') } else { - console.error('Failed to start daemon') + console.error('Failed to start runner') process.exit(1) } process.exit(0) } - if (daemonSubcommand === 'start-sync') { + if (runnerSubcommand === 'start-sync') { await initializeToken() - await startDaemon() + await startRunner() process.exit(0) } - if (daemonSubcommand === 'stop') { - await stopDaemon() + if (runnerSubcommand === 'stop') { + await stopRunner() process.exit(0) } - if (daemonSubcommand === 'status') { - await runDoctorCommand('daemon') + if (runnerSubcommand === 'status') { + await runDoctorCommand('runner') process.exit(0) } - if (daemonSubcommand === 'logs') { - const latest = await getLatestDaemonLog() + if (runnerSubcommand === 'logs') { + const latest = await getLatestRunnerLog() if (!latest) { - console.log('No daemon logs found') + console.log('No runner logs found') } else { console.log(latest.path) } @@ -103,18 +103,18 @@ export const daemonCommand: CommandDefinition = { } console.log(` -${chalk.bold('hapi daemon')} - Daemon management +${chalk.bold('hapi runner')} - Runner management ${chalk.bold('Usage:')} - hapi daemon start Start the daemon (detached) - hapi daemon stop Stop the daemon (sessions stay alive) - hapi daemon status Show daemon status - hapi daemon list List active sessions + hapi runner start Start the runner (detached) + hapi runner stop Stop the runner (sessions stay alive) + hapi runner status Show runner status + hapi runner list List active sessions If you want to kill all hapi related processes run ${chalk.cyan('hapi doctor clean')} -${chalk.bold('Note:')} The daemon runs in the background and manages Claude sessions. +${chalk.bold('Note:')} The runner runs in the background and manages Claude sessions. ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('hapi doctor clean')} `) diff --git a/cli/src/configuration.ts b/cli/src/configuration.ts index 691b2839..93c9817f 100644 --- a/cli/src/configuration.ts +++ b/cli/src/configuration.ts @@ -14,15 +14,15 @@ import { getCliArgs } from '@/utils/cliArgs' class Configuration { private _serverUrl: string private _cliApiToken: string - public readonly isDaemonProcess: boolean + public readonly isRunnerProcess: boolean // Directories and paths (from persistence) public readonly happyHomeDir: string public readonly logsDir: string public readonly settingsFile: string public readonly privateKeyFile: string - public readonly daemonStateFile: string - public readonly daemonLockFile: string + public readonly runnerStateFile: string + public readonly runnerLockFile: string public readonly currentCliVersion: string public readonly isExperimentalEnabled: boolean @@ -32,9 +32,9 @@ class Configuration { this._serverUrl = process.env.HAPI_SERVER_URL || 'http://localhost:3006' this._cliApiToken = process.env.CLI_API_TOKEN || '' - // Check if we're running as daemon based on process args + // Check if we're running as runner based on process args const args = getCliArgs() - this.isDaemonProcess = args.length >= 2 && args[0] === 'daemon' && (args[1] === 'start-sync') + this.isRunnerProcess = args.length >= 2 && args[0] === 'runner' && (args[1] === 'start-sync') // Directory configuration - Priority: HAPI_HOME env > default home dir if (process.env.HAPI_HOME) { @@ -48,8 +48,8 @@ class Configuration { this.logsDir = join(this.happyHomeDir, 'logs') this.settingsFile = join(this.happyHomeDir, 'settings.json') this.privateKeyFile = join(this.happyHomeDir, 'access.key') - this.daemonStateFile = join(this.happyHomeDir, 'daemon.state.json') - this.daemonLockFile = join(this.happyHomeDir, 'daemon.state.json.lock') + this.runnerStateFile = join(this.happyHomeDir, 'runner.state.json') + this.runnerLockFile = join(this.happyHomeDir, 'runner.state.json.lock') this.isExperimentalEnabled = ['true', '1', 'yes'].includes(process.env.HAPI_EXPERIMENTAL?.toLowerCase() || '') diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index 5942db54..e17cfaa5 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -1,7 +1,7 @@ /** * Minimal persistence functions for HAPI CLI * - * Handles settings, encryption key, and daemon state storage in ~/.hapi/ (or HAPI_HOME override) + * Handles settings, encryption key, and runner state storage in ~/.hapi/ (or HAPI_HOME override) */ import { FileHandle } from 'node:fs/promises' @@ -15,7 +15,7 @@ interface Settings { // All machine operations use this ID machineId?: string machineIdConfirmedByServer?: boolean - daemonAutoStartWhenRunningHappy?: boolean + runnerAutoStartWhenRunningHappy?: boolean cliApiToken?: string // Server URL for API connections (priority: env HAPI_SERVER_URL > this > default) serverUrl?: string @@ -24,17 +24,17 @@ interface Settings { const defaultSettings: Settings = {} /** - * Daemon state persisted locally (different from API DaemonState) - * This is written to disk by the daemon to track its local process state + * Runner state persisted locally (different from API RunnerState) + * This is written to disk by the runner to track its local process state */ -export interface DaemonLocallyPersistedState { +export interface RunnerLocallyPersistedState { pid: number; httpPort: number; startTime: string; startedWithCliVersion: string; startedWithCliMtimeMs?: number; lastHeartbeat?: string; - daemonLogPath?: string; + runnerLogPath?: string; } export async function readSettings(): Promise { @@ -156,59 +156,59 @@ export async function clearMachineId(): Promise { } /** - * Read daemon state from local file + * Read runner state from local file */ -export async function readDaemonState(): Promise { +export async function readRunnerState(): Promise { try { - if (!existsSync(configuration.daemonStateFile)) { + if (!existsSync(configuration.runnerStateFile)) { return null; } - const content = await readFile(configuration.daemonStateFile, 'utf-8'); - return JSON.parse(content) as DaemonLocallyPersistedState; + const content = await readFile(configuration.runnerStateFile, 'utf-8'); + return JSON.parse(content) as RunnerLocallyPersistedState; } catch (error) { // State corrupted somehow :( - console.error(`[PERSISTENCE] Daemon state file corrupted: ${configuration.daemonStateFile}`, error); + console.error(`[PERSISTENCE] Runner state file corrupted: ${configuration.runnerStateFile}`, error); return null; } } /** - * Write daemon state to local file (synchronously for atomic operation) + * Write runner state to local file (synchronously for atomic operation) */ -export function writeDaemonState(state: DaemonLocallyPersistedState): void { - writeFileSync(configuration.daemonStateFile, JSON.stringify(state, null, 2), 'utf-8'); +export function writeRunnerState(state: RunnerLocallyPersistedState): void { + writeFileSync(configuration.runnerStateFile, JSON.stringify(state, null, 2), 'utf-8'); } /** - * Clean up daemon state file and lock file + * Clean up runner state file and lock file */ -export async function clearDaemonState(): Promise { - if (existsSync(configuration.daemonStateFile)) { - await unlink(configuration.daemonStateFile); +export async function clearRunnerState(): Promise { + if (existsSync(configuration.runnerStateFile)) { + await unlink(configuration.runnerStateFile); } // Also clean up lock file if it exists (for stale cleanup) - if (existsSync(configuration.daemonLockFile)) { + if (existsSync(configuration.runnerLockFile)) { try { - await unlink(configuration.daemonLockFile); + await unlink(configuration.runnerLockFile); } catch { - // Lock file might be held by running daemon, ignore error + // Lock file might be held by running runner, ignore error } } } /** - * Acquire an exclusive lock file for the daemon. - * The lock file proves the daemon is running and prevents multiple instances. - * Returns the file handle to hold for the daemon's lifetime, or null if locked. + * Acquire an exclusive lock file for the runner. + * The lock file proves the runner is running and prevents multiple instances. + * Returns the file handle to hold for the runner's lifetime, or null if locked. */ -export async function acquireDaemonLock( +export async function acquireRunnerLock( maxAttempts: number = 5, delayIncrementMs: number = 200 ): Promise { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { // 'wx' ensures we only create if it doesn't exist (atomic lock acquisition) - const fileHandle = await open(configuration.daemonLockFile, 'wx'); + const fileHandle = await open(configuration.runnerLockFile, 'wx'); // Write PID to lock file for debugging await fileHandle.writeFile(String(process.pid)); return fileHandle; @@ -216,11 +216,11 @@ export async function acquireDaemonLock( if (error.code === 'EEXIST') { // Lock file exists, check if process is still running try { - const lockPid = readFileSync(configuration.daemonLockFile, 'utf-8').trim(); + const lockPid = readFileSync(configuration.runnerLockFile, 'utf-8').trim(); if (lockPid && !isNaN(Number(lockPid))) { if (!isProcessAlive(Number(lockPid))) { // Process doesn't exist, remove stale lock - unlinkSync(configuration.daemonLockFile); + unlinkSync(configuration.runnerLockFile); continue; // Retry acquisition } } @@ -240,16 +240,16 @@ export async function acquireDaemonLock( } /** - * Release daemon lock by closing handle and deleting lock file + * Release runner lock by closing handle and deleting lock file */ -export async function releaseDaemonLock(lockHandle: FileHandle): Promise { +export async function releaseRunnerLock(lockHandle: FileHandle): Promise { try { await lockHandle.close(); } catch { } try { - if (existsSync(configuration.daemonLockFile)) { - unlinkSync(configuration.daemonLockFile); + if (existsSync(configuration.runnerLockFile)) { + unlinkSync(configuration.runnerLockFile); } } catch { } } diff --git a/cli/src/daemon/README.md b/cli/src/runner/README.md similarity index 75% rename from cli/src/daemon/README.md rename to cli/src/runner/README.md index dd810fba..f4d1e4e6 100644 --- a/cli/src/daemon/README.md +++ b/cli/src/runner/README.md @@ -1,84 +1,84 @@ -# HAPI CLI Daemon: Control Flow and Lifecycle +# HAPI CLI Runner: Control Flow and Lifecycle -The daemon is a persistent background process that manages HAPI sessions, enables remote control from the mobile app, and handles auto-updates when the CLI version changes. +The runner is a persistent background process that manages HAPI sessions, enables remote control from the mobile app, and handles auto-updates when the CLI version changes. -## 1. Daemon Lifecycle +## 1. Runner Lifecycle -### Starting the Daemon +### Starting the Runner -Command: `hapi daemon start` +Command: `hapi runner start` Control Flow: -1. `src/index.ts` receives `daemon start` command -2. Spawns detached process via `spawnHappyCLI(['daemon', 'start-sync'], { detached: true })` -3. New process calls `startDaemon()` from `src/daemon/run.ts` -4. `startDaemon()` performs startup: +1. `src/index.ts` receives `runner start` command +2. Spawns detached process via `spawnHappyCLI(['runner', 'start-sync'], { detached: true })` +3. New process calls `startRunner()` from `src/runner/run.ts` +4. `startRunner()` performs startup: - Sets up shutdown promise and handlers (SIGINT, SIGTERM, uncaughtException, unhandledRejection) - - Version check: `isDaemonRunningCurrentlyInstalledHappyVersion()` compares CLI binary mtime - - If version mismatch: calls `stopDaemon()` to kill old daemon before proceeding - - If same version running: exits with "Daemon already running" - - Lock acquisition: `acquireDaemonLock()` creates exclusive lock file to prevent multiple daemons + - Version check: `isRunnerRunningCurrentlyInstalledHappyVersion()` compares CLI binary mtime + - If version mismatch: calls `stopRunner()` to kill old runner before proceeding + - If same version running: exits with "Runner already running" + - Lock acquisition: `acquireRunnerLock()` creates exclusive lock file to prevent multiple runners - Direct-connect setup: `authAndSetupMachineIfNeeded()` ensures `CLI_API_TOKEN` is set and `machineId` exists - - State persistence: writes PID, version, HTTP port, mtime to daemon.state.json + - State persistence: writes PID, version, HTTP port, mtime to runner.state.json - HTTP server: starts Fastify on random port for local CLI control (list, stop, spawn) - WebSocket: establishes persistent connection to backend via `ApiMachineClient` - - RPC registration: exposes `spawn-happy-session`, `stop-session`, `stop-daemon` handlers - - Heartbeat loop: every 60s (or `HAPI_DAEMON_HEARTBEAT_INTERVAL`) checks for version updates, prunes dead sessions, verifies PID ownership + - RPC registration: exposes `spawn-happy-session`, `stop-session`, `stop-runner` handlers + - Heartbeat loop: every 60s (or `HAPI_RUNNER_HEARTBEAT_INTERVAL`) checks for version updates, prunes dead sessions, verifies PID ownership 5. Awaits shutdown promise which resolves when: - OS signal received (SIGINT/SIGTERM) - source: `os-signal` - HTTP `/stop` endpoint called - source: `hapi-cli` - - RPC `stop-daemon` invoked - source: `hapi-app` + - RPC `stop-runner` invoked - source: `hapi-app` - Uncaught exception occurs - source: `exception` 6. On shutdown, `cleanupAndShutdown()` performs: - Clears heartbeat interval - - Updates daemon state to "shutting-down" on backend with shutdown source + - Updates runner state to "shutting-down" on backend with shutdown source - Disconnects WebSocket - Stops HTTP server - - Deletes daemon.state.json + - Deletes runner.state.json - Releases lock file - Exits process ### Version Detection & Auto-Update -The daemon detects when CLI binary changes (e.g., after `npm upgrade hapi`): +The runner detects when CLI binary changes (e.g., after `npm upgrade hapi`): 1. At startup, records `startedWithCliMtimeMs` (file modification time of CLI binary) 2. Heartbeat compares current CLI mtime with recorded mtime via `getInstalledCliMtimeMs()` 3. If mtime changed: - Clears heartbeat interval - - Spawns new daemon via `spawnHappyCLI(['daemon', 'start'])` - - Waits 10 seconds to be killed by new daemon -4. New daemon starts, sees old daemon running with different mtime -5. New daemon calls `stopDaemon()` which tries HTTP `/stop`, falls back to SIGKILL -6. New daemon takes over + - Spawns new runner via `spawnHappyCLI(['runner', 'start'])` + - Waits 10 seconds to be killed by new runner +4. New runner starts, sees old runner running with different mtime +5. New runner calls `stopRunner()` which tries HTTP `/stop`, falls back to SIGKILL +6. New runner takes over ### Heartbeat System -Every 60 seconds (configurable via `HAPI_DAEMON_HEARTBEAT_INTERVAL`): +Every 60 seconds (configurable via `HAPI_RUNNER_HEARTBEAT_INTERVAL`): 1. **Guard**: Skips if previous heartbeat still running (prevents concurrent heartbeats) 2. **Session Pruning**: Checks each tracked PID with `isProcessAlive(pid)`, removes dead sessions 3. **Version Check**: Compares CLI binary mtime, triggers self-restart if changed -4. **PID Ownership**: Verifies daemon still owns state file, self-terminates if another daemon took over -5. **State Update**: Writes `lastHeartbeat` timestamp to daemon.state.json +4. **PID Ownership**: Verifies runner still owns state file, self-terminates if another runner took over +5. **State Update**: Writes `lastHeartbeat` timestamp to runner.state.json -### Stopping the Daemon +### Stopping the Runner -Command: `hapi daemon stop` +Command: `hapi runner stop` Control Flow: -1. `stopDaemon()` in `controlClient.ts` reads daemon.state.json +1. `stopRunner()` in `controlClient.ts` reads runner.state.json 2. Attempts graceful shutdown via HTTP POST to `/stop` -3. Daemon receives request, triggers shutdown with source `hapi-cli` +3. Runner receives request, triggers shutdown with source `hapi-cli` 4. `cleanupAndShutdown()` executes: - Updates backend status to "shutting-down" - Closes WebSocket connection - Stops HTTP server - - Deletes daemon.state.json + - Deletes runner.state.json - Releases lock file 5. If HTTP fails, falls back to `killProcess(pid, true)` (uses `taskkill /T /F` on Windows) ## 2. Multi-Agent Support -The daemon supports spawning sessions with different AI agents: +The runner supports spawning sessions with different AI agents: | Agent | Command | Token Environment | |-------|---------|-------------------| @@ -94,29 +94,29 @@ When spawning a session with a token: ## 3. Session Management -### Daemon-Spawned Sessions (Remote) +### Runner-Spawned Sessions (Remote) Initiated by mobile app via backend RPC: -1. Backend forwards RPC `spawn-happy-session` to daemon via WebSocket +1. Backend forwards RPC `spawn-happy-session` to runner via WebSocket 2. `ApiMachineClient` invokes `spawnSession()` handler 3. `spawnSession()`: - Validates/creates directory (with approval flow) - Configures agent-specific token environment - - Spawns detached HAPI process with `--hapi-starting-mode remote --started-by daemon` + - Spawns detached HAPI process with `--hapi-starting-mode remote --started-by runner` - Adds to `pidToTrackedSession` map - Sets up 15-second awaiter for session webhook 4. New HAPI process: - Creates session with backend, receives `happySessionId` - - Calls `notifyDaemonSessionStarted()` to POST to daemon's `/session-started` -5. Daemon updates tracking with `happySessionId`, resolves awaiter + - Calls `notifyRunnerSessionStarted()` to POST to runner's `/session-started` +5. Runner updates tracking with `happySessionId`, resolves awaiter 6. RPC returns session info to mobile app ### Terminal-Spawned Sessions User runs `hapi` directly: -1. CLI auto-starts daemon if configured -2. HAPI process calls `notifyDaemonSessionStarted()` -3. Daemon receives webhook, creates `TrackedSession` with `startedBy: 'hapi directly - likely by user from terminal'` +1. CLI auto-starts runner if configured +2. HAPI process calls `notifyRunnerSessionStarted()` +3. Runner receives webhook, creates `TrackedSession` with `startedBy: 'hapi directly - likely by user from terminal'` 4. Session tracked for health monitoring ### Directory Creation Approval @@ -166,7 +166,7 @@ Returns all tracked sessions. ```json { "children": [ - { "startedBy": "daemon", "happySessionId": "uuid", "pid": 12345 } + { "startedBy": "runner", "happySessionId": "uuid", "pid": 12345 } ] } ``` @@ -213,7 +213,7 @@ Creates a new session. ``` #### POST `/stop` -Graceful daemon shutdown. +Graceful runner shutdown. **Response (200):** ```json @@ -222,7 +222,7 @@ Graceful daemon shutdown. ## 5. State Persistence -### daemon.state.json +### runner.state.json ```json { "pid": 12345, @@ -231,30 +231,30 @@ Graceful daemon shutdown. "startedWithCliVersion": "0.9.0-6", "startedWithCliMtimeMs": 1724531182000, "lastHeartbeat": "8/24/2025, 6:47:22 PM", - "daemonLogPath": "/path/to/daemon.log" + "runnerLogPath": "/path/to/runner.log" } ``` ### Lock File - Created with O_EXCL flag for atomic acquisition - Contains PID for debugging -- Prevents multiple daemon instances +- Prevents multiple runner instances - Cleaned up on graceful shutdown ## 6. WebSocket Communication `ApiMachineClient` handles bidirectional communication: -**Daemon to Server:** +**Runner to Server:** - `machine-alive` - 20-second heartbeat - `machine-update-metadata` - static machine info changes -- `machine-update-state` - daemon status changes +- `machine-update-state` - runner status changes -**Server to Daemon:** +**Server to Runner:** - `rpc-request` with methods: - `spawn-happy-session` - spawn new session - `stop-session` - stop session by ID - - `stop-daemon` - request shutdown + - `stop-runner` - request shutdown All data is plain JSON over TLS; authentication is `CLI_API_TOKEN` (no end-to-end encryption). @@ -265,7 +265,7 @@ All data is plain JSON over TLS; authentication is `CLI_API_TOKEN` (no end-to-en `hapi doctor` uses `ps aux | grep` to find all HAPI processes: - Production: matches `hapi` binary, `happy-coder` - Development: matches `src/index.ts` (run via `bun`) -- Categorizes by command args: daemon, daemon-spawned, user-session, doctor +- Categorizes by command args: runner, runner-spawned, user-session, doctor ### Clean Runaway Processes @@ -287,14 +287,14 @@ All data is plain JSON over TLS; authentication is `CLI_API_TOKEN` (no end-to-en - Session listing, spawning, stopping - External session webhook tracking - Graceful SIGTERM/SIGKILL shutdown -- Multiple daemon prevention +- Multiple runner prevention - Version mismatch detection - Directory creation approval flow - Concurrent session stress tests --- -# Machine Sync Architecture - Separated Metadata & Daemon State +# Machine Sync Architecture - Separated Metadata & Runner State > Direct-connect note: the "server" is `hapi-server`, payloads are plain JSON (no base64/encryption), > and authentication uses `CLI_API_TOKEN` (REST `Authorization: Bearer ...` + Socket.IO `handshake.auth.token`). @@ -312,8 +312,8 @@ interface MachineMetadata { happyLibDir: string; // runtime path } -// Dynamic daemon state (frequently updated) -interface DaemonState { +// Dynamic runner state (frequently updated) +interface RunnerState { status: 'running' | 'shutting-down' | 'offline'; pid?: number; httpPort?: number; @@ -327,10 +327,10 @@ interface DaemonState { Checks if machine ID exists in settings: - If not: creates ID locally only (so sessions can reference it) -- Does NOT create machine on server - that's daemon's job -- CLI doesn't manage machine details - all API & schema live in daemon subpackage +- Does NOT create machine on server - that's runner's job +- CLI doesn't manage machine details - all API & schema live in runner subpackage -## 2. Daemon Startup - Initial Registration +## 2. Runner Startup - Initial Registration ### REST Request: `POST /cli/machines` ```json @@ -344,7 +344,7 @@ Checks if machine ID exists in settings: "happyHomeDir": "/Users/john/.hapi", "happyLibDir": "/usr/local/lib/node_modules/hapi" }, - "daemonState": { + "runnerState": { "status": "running", "pid": 12345, "httpPort": 8080, @@ -360,8 +360,8 @@ Checks if machine ID exists in settings: "id": "machine-uuid-123", "metadata": { "host": "...", "platform": "...", "happyCliVersion": "..." }, "metadataVersion": 1, - "daemonState": { "status": "running", "pid": 12345 }, - "daemonStateVersion": 1, + "runnerState": { "status": "running", "pid": 12345 }, + "runnerStateVersion": 1, "active": true, "activeAt": 1703001234567, "createdAt": 1703001234567, @@ -394,14 +394,14 @@ socket.emit('machine-alive', { }) ``` -## 4. Daemon State Updates (via WebSocket) +## 4. Runner State Updates (via WebSocket) -### When daemon status changes: +### When runner status changes: ```json // Client -> Server socket.emit('machine-update-state', { "machineId": "machine-uuid-123", - "daemonState": { + "runnerState": { "status": "shutting-down", "pid": 12345, "httpPort": 8080, @@ -417,14 +417,14 @@ socket.emit('machine-update-state', { { "result": "success", "version": 2, - "daemonState": { "status": "shutting-down" } + "runnerState": { "status": "shutting-down" } } // Version mismatch: { "result": "version-mismatch", "version": 3, - "daemonState": { "status": "running" } + "runnerState": { "status": "running" } } ``` @@ -447,14 +447,14 @@ socket.emit('machine-update-metadata', { ## 5. Mini App RPC Calls (via hapi-server) The Telegram Mini App calls REST endpoints on `hapi-server` (for example `POST /api/machines/:id/spawn`). -`hapi-server` then relays those requests to the daemon via Socket.IO `rpc-request` on the `/cli` namespace. +`hapi-server` then relays those requests to the runner via Socket.IO `rpc-request` on the `/cli` namespace. RPC method naming (machine-scoped) uses a `${machineId}:` prefix, for example: - `${machineId}:spawn-happy-session` ## 6. Server Broadcasts to Clients -### When daemon state changes: +### When runner state changes: ```json // Server -> Mobile/Web clients socket.emit('update', { @@ -463,7 +463,7 @@ socket.emit('update', { "body": { "t": "update-machine", "machineId": "machine-uuid-123", - "daemonState": { + "runnerState": { "value": { "status": "shutting-down" }, "version": 2 } @@ -503,8 +503,8 @@ Authorization: Bearer "id": "machine-uuid-123", "metadata": { "host": "...", "platform": "...", "happyCliVersion": "..." }, "metadataVersion": 2, - "daemonState": { "status": "running", "pid": 12345 }, - "daemonStateVersion": 3, + "runnerState": { "status": "running", "pid": 12345 }, + "runnerStateVersion": 3, "active": true, "activeAt": 1703001244567, "createdAt": 1703001234567, @@ -517,17 +517,17 @@ Authorization: Bearer 1. **Separation of Concerns**: - `metadata`: Static machine info (host, platform, versions) - - `daemonState`: Dynamic runtime state (status, pid, ports) + - `runnerState`: Dynamic runtime state (status, pid, ports) 2. **Independent Versioning**: - `metadataVersion`: For machine metadata updates - - `daemonStateVersion`: For daemon state updates + - `runnerStateVersion`: For runner state updates - Allows concurrent updates without conflicts 3. **Security**: No end-to-end encryption (TLS only); CLI auth is a shared secret `CLI_API_TOKEN` 4. **Update Events**: Server broadcasts use same pattern as sessions: - - `t: 'update-machine'` with optional metadata and/or daemonState fields + - `t: 'update-machine'` with optional metadata and/or runnerState fields - Clients only receive updates for fields that changed 5. **RPC Pattern**: Machine-scoped RPC methods prefixed with machineId (like sessions) @@ -536,13 +536,13 @@ Authorization: Bearer # Improvements -- daemon.state.json file is getting hard removed when daemon exits or is stopped. We should keep it around and have 'state' field and 'stateReason' field that will explain why the daemon is in that state -- If the file is not found - we assume the daemon was never started or was cleaned out by the user or doctor +- runner.state.json file is getting hard removed when runner exits or is stopped. We should keep it around and have 'state' field and 'stateReason' field that will explain why the runner is in that state +- If the file is not found - we assume the runner was never started or was cleaned out by the user or doctor - If the file is found and corrupted - we should try to upgrade it to the latest version? or simply remove it if we have write access -- posts helpers for daemon do not return typed results -- I don't like that daemonPost returns either response from daemon or { error: ... }. We should have consistent envelope type +- posts helpers for runner do not return typed results +- I don't like that runnerPost returns either response from runner or { error: ... }. We should have consistent envelope type -- we loose track of children processes when daemon exits / restarts - we should write them to the same state file? At least the pids should be there for doctor & cleanup +- we loose track of children processes when runner exits / restarts - we should write them to the same state file? At least the pids should be there for doctor & cleanup -- the daemon control server binds to `127.0.0.1` on a random port; if we ever expose it beyond localhost, require an explicit auth token/header +- the runner control server binds to `127.0.0.1` on a random port; if we ever expose it beyond localhost, require an explicit auth token/header diff --git a/cli/src/daemon/controlClient.ts b/cli/src/runner/controlClient.ts similarity index 63% rename from cli/src/daemon/controlClient.ts rename to cli/src/runner/controlClient.ts index f312687e..1f013732 100644 --- a/cli/src/daemon/controlClient.ts +++ b/cli/src/runner/controlClient.ts @@ -1,10 +1,10 @@ /** - * HTTP client helpers for daemon communication - * Used by CLI commands to interact with running daemon + * HTTP client helpers for runner communication + * Used by CLI commands to interact with running runner */ import { logger } from '@/ui/logger'; -import { clearDaemonState, readDaemonState } from '@/persistence'; +import { clearRunnerState, readRunnerState } from '@/persistence'; import { Metadata } from '@/api/types'; import packageJson from '../../package.json'; import { existsSync, statSync } from 'node:fs'; @@ -33,10 +33,10 @@ export function getInstalledCliMtimeMs(): number | undefined { } } -async function daemonPost(path: string, body?: any): Promise<{ error?: string } | any> { - const state = await readDaemonState(); +async function runnerPost(path: string, body?: any): Promise<{ error?: string } | any> { + const state = await readRunnerState(); if (!state?.httpPort) { - const errorMessage = 'No daemon running, no state file found'; + const errorMessage = 'No runner running, no state file found'; logger.debug(`[CONTROL CLIENT] ${errorMessage}`); return { error: errorMessage @@ -44,7 +44,7 @@ async function daemonPost(path: string, body?: any): Promise<{ error?: string } } if (!isProcessAlive(state.pid)) { - const errorMessage = 'Daemon is not running, file is stale'; + const errorMessage = 'Runner is not running, file is stale'; logger.debug(`[CONTROL CLIENT] ${errorMessage}`); return { error: errorMessage @@ -52,7 +52,7 @@ async function daemonPost(path: string, body?: any): Promise<{ error?: string } } try { - const timeout = process.env.HAPI_DAEMON_HTTP_TIMEOUT ? parseInt(process.env.HAPI_DAEMON_HTTP_TIMEOUT) : 10_000; + const timeout = process.env.HAPI_RUNNER_HTTP_TIMEOUT ? parseInt(process.env.HAPI_RUNNER_HTTP_TIMEOUT) : 10_000; const response = await fetch(`http://127.0.0.1:${state.httpPort}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -79,39 +79,39 @@ async function daemonPost(path: string, body?: any): Promise<{ error?: string } } } -export async function notifyDaemonSessionStarted( +export async function notifyRunnerSessionStarted( sessionId: string, metadata: Metadata ): Promise<{ error?: string } | any> { - return await daemonPost('/session-started', { + return await runnerPost('/session-started', { sessionId, metadata }); } -export async function listDaemonSessions(): Promise { - const result = await daemonPost('/list'); +export async function listRunnerSessions(): Promise { + const result = await runnerPost('/list'); return result.children || []; } -export async function stopDaemonSession(sessionId: string): Promise { - const result = await daemonPost('/stop-session', { sessionId }); +export async function stopRunnerSession(sessionId: string): Promise { + const result = await runnerPost('/stop-session', { sessionId }); return result.success || false; } -export async function spawnDaemonSession(directory: string, sessionId?: string): Promise { - const result = await daemonPost('/spawn-session', { directory, sessionId }); +export async function spawnRunnerSession(directory: string, sessionId?: string): Promise { + const result = await runnerPost('/spawn-session', { directory, sessionId }); return result; } -export async function stopDaemonHttp(): Promise { - await daemonPost('/stop'); +export async function stopRunnerHttp(): Promise { + await runnerPost('/stop'); } /** * The version check is still quite naive. * For instance we are not handling the case where we upgraded hapi, - * the daemon is still running, and it recieves a new message to spawn a new session. + * the runner is still running, and it recieves a new message to spawn a new session. * This is a tough case - we need to somehow figure out to restart ourselves, * yet still handle the original request. * @@ -123,8 +123,8 @@ export async function stopDaemonHttp(): Promise { * b. Let the request fail, restart and rely on the client retrying the request * * I like option 1 a little better. - * Maybe we can ... wait for it ... have another daemon to make sure - * our daemon is always alive and running the latest version. + * Maybe we can ... wait for it ... have another runner to make sure + * our runner is always alive and running the latest version. * * That seems like an overkill and yet another process to manage - lets not do this :D * @@ -133,54 +133,54 @@ export async function stopDaemonHttp(): Promise { * Not just a boolean. * * We can destructure the response on the caller for richer output. - * For instance when running `hapi daemon status` we can show more information. + * For instance when running `hapi runner status` we can show more information. */ -export async function checkIfDaemonRunningAndCleanupStaleState(): Promise { - const state = await readDaemonState(); +export async function checkIfRunnerRunningAndCleanupStaleState(): Promise { + const state = await readRunnerState(); if (!state) { return false; } - // Check if the daemon is running + // Check if the runner is running if (isProcessAlive(state.pid)) { return true; } - logger.debug('[DAEMON RUN] Daemon PID not running, cleaning up state'); - await cleanupDaemonState(); + logger.debug('[RUNNER RUN] Runner PID not running, cleaning up state'); + await cleanupRunnerState(); return false; } /** - * Check if the running daemon version matches the current CLI version. - * This should work from both the daemon itself & a new CLI process. - * Works via the daemon.state.json file. + * Check if the running runner version matches the current CLI version. + * This should work from both the runner itself & a new CLI process. + * Works via the runner.state.json file. * - * @returns true if versions match, false if versions differ or no daemon running + * @returns true if versions match, false if versions differ or no runner running */ -export async function isDaemonRunningCurrentlyInstalledHappyVersion(): Promise { - logger.debug('[DAEMON CONTROL] Checking if daemon is running same version'); - const runningDaemon = await checkIfDaemonRunningAndCleanupStaleState(); - if (!runningDaemon) { - logger.debug('[DAEMON CONTROL] No daemon running, returning false'); +export async function isRunnerRunningCurrentlyInstalledHappyVersion(): Promise { + logger.debug('[RUNNER CONTROL] Checking if runner is running same version'); + const runningRunner = await checkIfRunnerRunningAndCleanupStaleState(); + if (!runningRunner) { + logger.debug('[RUNNER CONTROL] No runner running, returning false'); return false; } - const state = await readDaemonState(); + const state = await readRunnerState(); if (!state) { - logger.debug('[DAEMON CONTROL] No daemon state found, returning false'); + logger.debug('[RUNNER CONTROL] No runner state found, returning false'); return false; } try { const currentCliMtimeMs = getInstalledCliMtimeMs(); if (typeof currentCliMtimeMs === 'number' && typeof state.startedWithCliMtimeMs === 'number') { - logger.debug(`[DAEMON CONTROL] Current CLI mtime: ${currentCliMtimeMs}, Daemon started with mtime: ${state.startedWithCliMtimeMs}`); + logger.debug(`[RUNNER CONTROL] Current CLI mtime: ${currentCliMtimeMs}, Runner started with mtime: ${state.startedWithCliMtimeMs}`); return currentCliMtimeMs === state.startedWithCliMtimeMs; } const currentCliVersion = packageJson.version; - logger.debug(`[DAEMON CONTROL] Current CLI version: ${currentCliVersion}, Daemon started with version: ${state.startedWithCliVersion}`); + logger.debug(`[RUNNER CONTROL] Current CLI version: ${currentCliVersion}, Runner started with version: ${state.startedWithCliVersion}`); return currentCliVersion === state.startedWithCliVersion; // PREVIOUS IMPLEMENTATION - Keeping this commented in case we need it @@ -196,41 +196,41 @@ export async function isDaemonRunningCurrentlyInstalledHappyVersion(): Promise happyProcess.stdout?.on('close', resolve)); - logger.debug(`[DAEMON CONTROL] Current CLI version: ${version}, Daemon started with version: ${state.startedWithCliVersion}`); + logger.debug(`[RUNNER CONTROL] Current CLI version: ${version}, Runner started with version: ${state.startedWithCliVersion}`); return version === state.startedWithCliVersion; */ } catch (error) { - logger.debug('[DAEMON CONTROL] Error checking daemon version', error); + logger.debug('[RUNNER CONTROL] Error checking runner version', error); return false; } } -export async function cleanupDaemonState(): Promise { +export async function cleanupRunnerState(): Promise { try { - await clearDaemonState(); - logger.debug('[DAEMON RUN] Daemon state file removed'); + await clearRunnerState(); + logger.debug('[RUNNER RUN] Runner state file removed'); } catch (error) { - logger.debug('[DAEMON RUN] Error cleaning up daemon metadata', error); + logger.debug('[RUNNER RUN] Error cleaning up runner metadata', error); } } -export async function stopDaemon() { +export async function stopRunner() { try { - const state = await readDaemonState(); + const state = await readRunnerState(); if (!state) { - logger.debug('No daemon state found'); + logger.debug('No runner state found'); return; } - logger.debug(`Stopping daemon with PID ${state.pid}`); + logger.debug(`Stopping runner with PID ${state.pid}`); // Try HTTP graceful stop try { - await stopDaemonHttp(); + await stopRunnerHttp(); - // Wait for daemon to die + // Wait for runner to die await waitForProcessDeath(state.pid, 2000); - logger.debug('Daemon stopped gracefully via HTTP'); + logger.debug('Runner stopped gracefully via HTTP'); return; } catch (error) { logger.debug('HTTP stop failed, will force kill', error); @@ -239,12 +239,12 @@ export async function stopDaemon() { // Force kill const killed = await killProcess(state.pid, true); if (killed) { - logger.debug('Force killed daemon'); + logger.debug('Force killed runner'); } else { - logger.debug('Daemon already dead or could not be killed'); + logger.debug('Runner already dead or could not be killed'); } } catch (error) { - logger.debug('Error stopping daemon', error); + logger.debug('Error stopping runner', error); } } diff --git a/cli/src/daemon/controlServer.ts b/cli/src/runner/controlServer.ts similarity index 95% rename from cli/src/daemon/controlServer.ts rename to cli/src/runner/controlServer.ts index 91d5e43f..b07278c8 100644 --- a/cli/src/daemon/controlServer.ts +++ b/cli/src/runner/controlServer.ts @@ -1,6 +1,6 @@ /** - * HTTP control server for daemon management - * Provides endpoints for listing sessions, stopping sessions, and daemon shutdown + * HTTP control server for runner management + * Provides endpoints for listing sessions, stopping sessions, and runner shutdown */ import fastify, { FastifyInstance } from 'fastify'; @@ -11,7 +11,7 @@ import { Metadata } from '@/api/types'; import { TrackedSession } from './types'; import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/rpcTypes'; -export function startDaemonControlServer({ +export function startRunnerControlServer({ getChildren, stopSession, spawnSession, @@ -170,7 +170,7 @@ export function startDaemonControlServer({ } }); - // Stop daemon + // Stop runner typed.post('/stop', { schema: { response: { @@ -180,11 +180,11 @@ export function startDaemonControlServer({ } } }, async () => { - logger.debug('[CONTROL SERVER] Stop daemon request received'); + logger.debug('[CONTROL SERVER] Stop runner request received'); // Give time for response to arrive setTimeout(() => { - logger.debug('[CONTROL SERVER] Triggering daemon shutdown'); + logger.debug('[CONTROL SERVER] Triggering runner shutdown'); requestShutdown(); }, 50); diff --git a/cli/src/daemon/doctor.ts b/cli/src/runner/doctor.ts similarity index 83% rename from cli/src/daemon/doctor.ts rename to cli/src/runner/doctor.ts index ea052000..08dca930 100644 --- a/cli/src/daemon/doctor.ts +++ b/cli/src/runner/doctor.ts @@ -1,7 +1,7 @@ /** - * Daemon doctor utilities + * Runner doctor utilities * - * Process discovery and cleanup functions for the daemon + * Process discovery and cleanup functions for the runner * Helps diagnose and fix issues with hung or orphaned processes */ @@ -37,11 +37,11 @@ export async function findAllHappyProcesses(): Promise p.pid !== process.pid && ( - p.type === 'daemon' || - p.type === 'dev-daemon' || - p.type === 'daemon-spawned-session' || - p.type === 'dev-daemon-spawned' || - p.type === 'daemon-version-check' || - p.type === 'dev-daemon-version-check' + p.type === 'runner' || + p.type === 'dev-runner' || + p.type === 'runner-spawned-session' || + p.type === 'dev-runner-spawned' || + p.type === 'runner-version-check' || + p.type === 'dev-runner-version-check' ) ) .map(p => ({ pid: p.pid, command: p.command })); diff --git a/cli/src/daemon/run.ts b/cli/src/runner/run.ts similarity index 73% rename from cli/src/daemon/run.ts rename to cli/src/runner/run.ts index 7f4f45bd..bf1f0953 100644 --- a/cli/src/daemon/run.ts +++ b/cli/src/runner/run.ts @@ -3,25 +3,25 @@ import os from 'os'; import { ApiClient } from '@/api/api'; import { TrackedSession } from './types'; -import { DaemonState, Metadata } from '@/api/types'; +import { RunnerState, Metadata } from '@/api/types'; import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/rpcTypes'; import { logger } from '@/ui/logger'; import { authAndSetupMachineIfNeeded } from '@/ui/auth'; import packageJson from '../../package.json'; import { getEnvironmentInfo } from '@/ui/doctor'; import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; -import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquireDaemonLock, releaseDaemonLock } from '@/persistence'; +import { writeRunnerState, RunnerLocallyPersistedState, readRunnerState, acquireRunnerLock, releaseRunnerLock } from '@/persistence'; import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } from '@/utils/process'; import { withRetry } from '@/utils/time'; import { isRetryableConnectionError } from '@/utils/errorUtils'; -import { cleanupDaemonState, getInstalledCliMtimeMs, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient'; -import { startDaemonControlServer } from './controlServer'; +import { cleanupRunnerState, getInstalledCliMtimeMs, isRunnerRunningCurrentlyInstalledHappyVersion, stopRunner } from './controlClient'; +import { startRunnerControlServer } from './controlServer'; import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree'; import { join } from 'path'; import { buildMachineMetadata } from '@/agent/sessionFactory'; -export async function startDaemon(): Promise { +export async function startRunner(): Promise { // We don't have cleanup function at the time of server construction // Control flow is: // 1. Create promise that will resolve when shutdown is requested @@ -34,11 +34,11 @@ export async function startDaemon(): Promise { let requestShutdown: (source: 'hapi-app' | 'hapi-cli' | 'os-signal' | 'exception', errorMessage?: string) => void; let resolvesWhenShutdownRequested = new Promise<({ source: 'hapi-app' | 'hapi-cli' | 'os-signal' | 'exception', errorMessage?: string })>((resolve) => { requestShutdown = (source, errorMessage) => { - logger.debug(`[DAEMON RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`); + logger.debug(`[RUNNER RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`); // Fallback - in case startup malfunctions - we will force exit the process with code 1 setTimeout(async () => { - logger.debug('[DAEMON RUN] Startup malfunctioned, forcing exit with code 1'); + logger.debug('[RUNNER RUN] Startup malfunctioned, forcing exit with code 1'); // Give time for logs to be flushed await new Promise(resolve => setTimeout(resolve, 100)) @@ -53,74 +53,74 @@ export async function startDaemon(): Promise { // Setup signal handlers process.on('SIGINT', () => { - logger.debug('[DAEMON RUN] Received SIGINT'); + logger.debug('[RUNNER RUN] Received SIGINT'); requestShutdown('os-signal'); }); process.on('SIGTERM', () => { - logger.debug('[DAEMON RUN] Received SIGTERM'); + logger.debug('[RUNNER RUN] Received SIGTERM'); requestShutdown('os-signal'); }); if (isWindows()) { process.on('SIGBREAK', () => { - logger.debug('[DAEMON RUN] Received SIGBREAK'); + logger.debug('[RUNNER RUN] Received SIGBREAK'); requestShutdown('os-signal'); }); } process.on('uncaughtException', (error) => { - logger.debug('[DAEMON RUN] FATAL: Uncaught exception', error); - logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); + logger.debug('[RUNNER RUN] FATAL: Uncaught exception', error); + logger.debug(`[RUNNER RUN] Stack trace: ${error.stack}`); requestShutdown('exception', error.message); }); process.on('unhandledRejection', (reason, promise) => { - logger.debug('[DAEMON RUN] FATAL: Unhandled promise rejection', reason); - logger.debug(`[DAEMON RUN] Rejected promise:`, promise); + logger.debug('[RUNNER RUN] FATAL: Unhandled promise rejection', reason); + logger.debug(`[RUNNER RUN] Rejected promise:`, promise); const error = reason instanceof Error ? reason : new Error(`Unhandled promise rejection: ${reason}`); - logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`); + logger.debug(`[RUNNER RUN] Stack trace: ${error.stack}`); requestShutdown('exception', error.message); }); process.on('exit', (code) => { - logger.debug(`[DAEMON RUN] Process exiting with code: ${code}`); + logger.debug(`[RUNNER RUN] Process exiting with code: ${code}`); }); process.on('beforeExit', (code) => { - logger.debug(`[DAEMON RUN] Process about to exit with code: ${code}`); + logger.debug(`[RUNNER RUN] Process about to exit with code: ${code}`); }); - logger.debug('[DAEMON RUN] Starting daemon process...'); - logger.debugLargeJson('[DAEMON RUN] Environment', getEnvironmentInfo()); + logger.debug('[RUNNER RUN] Starting runner process...'); + logger.debugLargeJson('[RUNNER RUN] Environment', getEnvironmentInfo()); // Check if already running - // Check if running daemon version matches current CLI version - const runningDaemonVersionMatches = await isDaemonRunningCurrentlyInstalledHappyVersion(); - if (!runningDaemonVersionMatches) { - logger.debug('[DAEMON RUN] Daemon version mismatch detected, restarting daemon with current CLI version'); - await stopDaemon(); + // Check if running runner version matches current CLI version + const runningRunnerVersionMatches = await isRunnerRunningCurrentlyInstalledHappyVersion(); + if (!runningRunnerVersionMatches) { + logger.debug('[RUNNER RUN] Runner version mismatch detected, restarting runner with current CLI version'); + await stopRunner(); } else { - logger.debug('[DAEMON RUN] Daemon version matches, keeping existing daemon'); - console.log('Daemon already running with matching version'); + logger.debug('[RUNNER RUN] Runner version matches, keeping existing runner'); + console.log('Runner already running with matching version'); process.exit(0); } - // Acquire exclusive lock (proves daemon is running) - const daemonLockHandle = await acquireDaemonLock(5, 200); - if (!daemonLockHandle) { - logger.debug('[DAEMON RUN] Daemon lock file already held, another daemon is running'); + // Acquire exclusive lock (proves runner is running) + const runnerLockHandle = await acquireRunnerLock(5, 200); + if (!runnerLockHandle) { + logger.debug('[RUNNER RUN] Runner lock file already held, another runner is running'); process.exit(0); } - // At this point we should be safe to startup the daemon: - // 1. Not have a stale daemon state - // 2. Should not have another daemon process running + // At this point we should be safe to startup the runner: + // 1. Not have a stale runner state + // 2. Should not have another runner process running try { // Ensure auth and machine registration BEFORE anything else const { machineId } = await authAndSetupMachineIfNeeded(); - logger.debug('[DAEMON RUN] Auth and machine setup complete'); + logger.debug('[RUNNER RUN] Auth and machine setup complete'); // Setup state - key by PID const pidToTrackedSession = new Map(); @@ -133,32 +133,32 @@ export async function startDaemon(): Promise { // Handle webhook from HAPI session reporting itself const onHappySessionWebhook = (sessionId: string, sessionMetadata: Metadata) => { - logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata); + logger.debugLargeJson(`[RUNNER RUN] Session reported`, sessionMetadata); const pid = sessionMetadata.hostPid; if (!pid) { - logger.debug(`[DAEMON RUN] Session webhook missing hostPid for sessionId: ${sessionId}`); + logger.debug(`[RUNNER RUN] Session webhook missing hostPid for sessionId: ${sessionId}`); return; } - logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || 'unknown'}`); - logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(', ')}`); + logger.debug(`[RUNNER RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || 'unknown'}`); + logger.debug(`[RUNNER RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(', ')}`); - // Check if we already have this PID (daemon-spawned) + // Check if we already have this PID (runner-spawned) const existingSession = pidToTrackedSession.get(pid); - if (existingSession && existingSession.startedBy === 'daemon') { - // Update daemon-spawned session with reported data + if (existingSession && existingSession.startedBy === 'runner') { + // Update runner-spawned session with reported data existingSession.happySessionId = sessionId; existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata; - logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`); + logger.debug(`[RUNNER RUN] Updated runner-spawned session ${sessionId} with metadata`); // Resolve any awaiter for this PID const awaiter = pidToAwaiter.get(pid); if (awaiter) { pidToAwaiter.delete(pid); awaiter(existingSession); - logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`); + logger.debug(`[RUNNER RUN] Resolved session awaiter for PID ${pid}`); } } else if (!existingSession) { // New session started externally @@ -169,13 +169,13 @@ export async function startDaemon(): Promise { pid }; pidToTrackedSession.set(pid, trackedSession); - logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`); + logger.debug(`[RUNNER RUN] Registered externally-started session ${sessionId}`); } }; // Spawn a new session (sessionId reserved for future --resume functionality) const spawnSession = async (options: SpawnSessionOptions): Promise => { - logger.debugLargeJson('[DAEMON RUN] Spawning session', options); + logger.debugLargeJson('[RUNNER RUN] Spawning session', options); const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; const agent = options.agent ?? 'claude'; @@ -190,13 +190,13 @@ export async function startDaemon(): Promise { if (sessionType === 'simple') { try { await fs.access(directory); - logger.debug(`[DAEMON RUN] Directory exists: ${directory}`); + logger.debug(`[RUNNER RUN] Directory exists: ${directory}`); } catch (error) { - logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`); + logger.debug(`[RUNNER RUN] Directory doesn't exist, creating: ${directory}`); // Check if directory creation is approved if (!approvedNewDirectoryCreation) { - logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`); + logger.debug(`[RUNNER RUN] Directory creation not approved for: ${directory}`); return { type: 'requestToApproveDirectoryCreation', directory @@ -205,7 +205,7 @@ export async function startDaemon(): Promise { try { await fs.mkdir(directory, { recursive: true }); - logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`); + logger.debug(`[RUNNER RUN] Successfully created directory: ${directory}`); directoryCreated = true; } catch (mkdirError: any) { let errorMessage = `Unable to create directory at '${directory}'. `; @@ -223,7 +223,7 @@ export async function startDaemon(): Promise { errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`; } - logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`); + logger.debug(`[RUNNER RUN] Directory creation failed: ${errorMessage}`); return { type: 'error', errorMessage @@ -233,9 +233,9 @@ export async function startDaemon(): Promise { } else { try { await fs.access(directory); - logger.debug(`[DAEMON RUN] Worktree base directory exists: ${directory}`); + logger.debug(`[RUNNER RUN] Worktree base directory exists: ${directory}`); } catch (error) { - logger.debug(`[DAEMON RUN] Worktree base directory missing: ${directory}`); + logger.debug(`[RUNNER RUN] Worktree base directory missing: ${directory}`); return { type: 'error', errorMessage: `Worktree sessions require an existing Git repository. Directory not found: ${directory}` @@ -249,7 +249,7 @@ export async function startDaemon(): Promise { nameHint: worktreeName }); if (!worktreeResult.ok) { - logger.debug(`[DAEMON RUN] Worktree creation failed: ${worktreeResult.error}`); + logger.debug(`[RUNNER RUN] Worktree creation failed: ${worktreeResult.error}`); return { type: 'error', errorMessage: worktreeResult.error @@ -257,7 +257,7 @@ export async function startDaemon(): Promise { } worktreeInfo = worktreeResult.info; spawnDirectory = worktreeInfo.worktreePath; - logger.debug(`[DAEMON RUN] Created worktree ${worktreeInfo.worktreePath} (branch ${worktreeInfo.branch})`); + logger.debug(`[RUNNER RUN] Created worktree ${worktreeInfo.worktreePath} (branch ${worktreeInfo.branch})`); } const cleanupWorktree = async () => { @@ -269,7 +269,7 @@ export async function startDaemon(): Promise { worktreePath: worktreeInfo.worktreePath }); if (!result.ok) { - logger.debug(`[DAEMON RUN] Failed to remove worktree ${worktreeInfo.worktreePath}: ${result.error}`); + logger.debug(`[RUNNER RUN] Failed to remove worktree ${worktreeInfo.worktreePath}: ${result.error}`); } }; const maybeCleanupWorktree = async (reason: string) => { @@ -278,7 +278,7 @@ export async function startDaemon(): Promise { } const pid = happyProcess?.pid; if (pid && isProcessAlive(pid)) { - logger.debug(`[DAEMON RUN] Skipping worktree cleanup after ${reason}; child still running`, { + logger.debug(`[RUNNER RUN] Skipping worktree cleanup after ${reason}; child still running`, { pid, worktreePath: worktreeInfo.worktreePath }); @@ -331,7 +331,7 @@ export async function startDaemon(): Promise { const args = [ agentCommand, '--hapi-starting-mode', 'remote', - '--started-by', 'daemon' + '--started-by', 'runner' ]; if (yolo) { args.push('--yolo'); @@ -354,12 +354,12 @@ export async function startDaemon(): Promise { if (!trimmed) { return; } - logger.debug('[DAEMON RUN] Child stderr tail', trimmed); + logger.debug('[RUNNER RUN] Child stderr tail', trimmed); }; happyProcess = spawnHappyCLI(args, { cwd: spawnDirectory, - detached: true, // Sessions stay alive when daemon stops + detached: true, // Sessions stay alive when runner stops stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr for debugging env: { ...process.env, @@ -372,7 +372,7 @@ export async function startDaemon(): Promise { }); if (!happyProcess.pid) { - logger.debug('[DAEMON RUN] Failed to spawn process - no PID returned'); + logger.debug('[RUNNER RUN] Failed to spawn process - no PID returned'); await maybeCleanupWorktree('no-pid'); return { type: 'error', @@ -381,10 +381,10 @@ export async function startDaemon(): Promise { } const pid = happyProcess.pid; - logger.debug(`[DAEMON RUN] Spawned process with PID ${pid}`); + logger.debug(`[RUNNER RUN] Spawned process with PID ${pid}`); const trackedSession: TrackedSession = { - startedBy: 'daemon', + startedBy: 'runner', pid, childProcess: happyProcess, directoryCreated, @@ -394,7 +394,7 @@ export async function startDaemon(): Promise { pidToTrackedSession.set(pid, trackedSession); happyProcess.on('exit', (code, signal) => { - logger.debug(`[DAEMON RUN] Child PID ${pid} exited with code ${code}, signal ${signal}`); + logger.debug(`[RUNNER RUN] Child PID ${pid} exited with code ${code}, signal ${signal}`); if (code !== 0 || signal) { logStderrTail(); } @@ -402,18 +402,18 @@ export async function startDaemon(): Promise { }); happyProcess.on('error', (error) => { - logger.debug(`[DAEMON RUN] Child process error:`, error); + logger.debug(`[RUNNER RUN] Child process error:`, error); onChildExited(pid); }); // Wait for webhook to populate session with happySessionId - logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${pid}`); + logger.debug(`[RUNNER RUN] Waiting for session webhook for PID ${pid}`); const spawnResult = await new Promise((resolve) => { // Set timeout for webhook const timeout = setTimeout(() => { pidToAwaiter.delete(pid); - logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${pid}`); + logger.debug(`[RUNNER RUN] Session webhook timeout for PID ${pid}`); logStderrTail(); resolve({ type: 'error', @@ -426,7 +426,7 @@ export async function startDaemon(): Promise { // Register awaiter pidToAwaiter.set(pid, (completedSession) => { clearTimeout(timeout); - logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); + logger.debug(`[RUNNER RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); resolve({ type: 'success', sessionId: completedSession.happySessionId! @@ -439,7 +439,7 @@ export async function startDaemon(): Promise { return spawnResult; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - logger.debug('[DAEMON RUN] Failed to spawn session:', error); + logger.debug('[RUNNER RUN] Failed to spawn session:', error); await maybeCleanupWorktree('exception'); return { type: 'error', @@ -450,48 +450,48 @@ export async function startDaemon(): Promise { // Stop a session by sessionId or PID fallback const stopSession = (sessionId: string): boolean => { - logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`); + logger.debug(`[RUNNER RUN] Attempting to stop session ${sessionId}`); // Try to find by sessionId first for (const [pid, session] of pidToTrackedSession.entries()) { if (session.happySessionId === sessionId || (sessionId.startsWith('PID-') && pid === parseInt(sessionId.replace('PID-', '')))) { - if (session.startedBy === 'daemon' && session.childProcess) { + if (session.startedBy === 'runner' && session.childProcess) { try { void killProcessByChildProcess(session.childProcess); - logger.debug(`[DAEMON RUN] Requested termination for daemon-spawned session ${sessionId}`); + logger.debug(`[RUNNER RUN] Requested termination for runner-spawned session ${sessionId}`); } catch (error) { - logger.debug(`[DAEMON RUN] Failed to kill session ${sessionId}:`, error); + logger.debug(`[RUNNER RUN] Failed to kill session ${sessionId}:`, error); } } else { // For externally started sessions, try to kill by PID try { void killProcess(pid); - logger.debug(`[DAEMON RUN] Requested termination for external session PID ${pid}`); + logger.debug(`[RUNNER RUN] Requested termination for external session PID ${pid}`); } catch (error) { - logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error); + logger.debug(`[RUNNER RUN] Failed to kill external session PID ${pid}:`, error); } } pidToTrackedSession.delete(pid); - logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`); + logger.debug(`[RUNNER RUN] Removed session ${sessionId} from tracking`); return true; } } - logger.debug(`[DAEMON RUN] Session ${sessionId} not found`); + logger.debug(`[RUNNER RUN] Session ${sessionId} not found`); return false; }; // Handle child process exit const onChildExited = (pid: number) => { - logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`); + logger.debug(`[RUNNER RUN] Removing exited process PID ${pid} from tracking`); pidToTrackedSession.delete(pid); }; // Start control server - const { port: controlPort, stop: stopControlServer } = await startDaemonControlServer({ + const { port: controlPort, stop: stopControlServer } = await startRunnerControlServer({ getChildren: getCurrentChildren, stopSession, spawnSession, @@ -501,20 +501,20 @@ export async function startDaemon(): Promise { const startedWithCliMtimeMs = getInstalledCliMtimeMs(); - // Write initial daemon state (no lock needed for state file) - const fileState: DaemonLocallyPersistedState = { + // Write initial runner state (no lock needed for state file) + const fileState: RunnerLocallyPersistedState = { pid: process.pid, httpPort: controlPort, startTime: new Date().toLocaleString(), startedWithCliVersion: packageJson.version, startedWithCliMtimeMs, - daemonLogPath: logger.logFilePath + runnerLogPath: logger.logFilePath }; - writeDaemonState(fileState); - logger.debug('[DAEMON RUN] Daemon state written'); + writeRunnerState(fileState); + logger.debug('[RUNNER RUN] Runner state written'); - // Prepare initial daemon state - const initialDaemonState: DaemonState = { + // Prepare initial runner state + const initialRunnerState: RunnerState = { status: 'offline', pid: process.pid, httpPort: controlPort, @@ -529,7 +529,7 @@ export async function startDaemon(): Promise { () => api.getOrCreateMachine({ machineId, metadata: buildMachineMetadata(), - daemonState: initialDaemonState + runnerState: initialRunnerState }), { maxAttempts: 60, @@ -538,11 +538,11 @@ export async function startDaemon(): Promise { shouldRetry: isRetryableConnectionError, onRetry: (error, attempt, nextDelayMs) => { const errorMsg = error instanceof Error ? error.message : String(error) - logger.debug(`[DAEMON RUN] Failed to register machine (attempt ${attempt}), retrying in ${nextDelayMs}ms: ${errorMsg}`) + logger.debug(`[RUNNER RUN] Failed to register machine (attempt ${attempt}), retrying in ${nextDelayMs}ms: ${errorMsg}`) } } ); - logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`); + logger.debug(`[RUNNER RUN] Machine registered: ${machine.id}`); // Create realtime machine session const apiMachine = api.machineSyncClient(machine); @@ -559,10 +559,10 @@ export async function startDaemon(): Promise { // Every 60 seconds: // 1. Prune stale sessions - // 2. Check if daemon needs update + // 2. Check if runner needs update // 3. If outdated, restart with latest version // 4. Write heartbeat - const heartbeatIntervalMs = parseInt(process.env.HAPI_DAEMON_HEARTBEAT_INTERVAL || '60000'); + const heartbeatIntervalMs = parseInt(process.env.HAPI_RUNNER_HEARTBEAT_INTERVAL || '60000'); let heartbeatRunning = false const restartOnStaleVersionAndHeartbeat = setInterval(async () => { if (heartbeatRunning) { @@ -571,73 +571,73 @@ export async function startDaemon(): Promise { heartbeatRunning = true; if (process.env.DEBUG) { - logger.debug(`[DAEMON RUN] Health check started at ${new Date().toLocaleString()}`); + logger.debug(`[RUNNER RUN] Health check started at ${new Date().toLocaleString()}`); } // Prune stale sessions for (const [pid, _] of pidToTrackedSession.entries()) { if (!isProcessAlive(pid)) { - logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`); + logger.debug(`[RUNNER RUN] Removing stale session with PID ${pid} (process no longer exists)`); pidToTrackedSession.delete(pid); } } - // Check if daemon needs update + // Check if runner needs update const installedCliMtimeMs = getInstalledCliMtimeMs(); if (typeof installedCliMtimeMs === 'number' && typeof startedWithCliMtimeMs === 'number' && installedCliMtimeMs !== startedWithCliMtimeMs) { - logger.debug('[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval'); + logger.debug('[RUNNER RUN] Runner is outdated, triggering self-restart with latest version, clearing heartbeat interval'); clearInterval(restartOnStaleVersionAndHeartbeat); - // Spawn new daemon through the CLI + // Spawn new runner through the CLI // We do not need to clean ourselves up - we will be killed by // the CLI start command. - // 1. It will first check if daemon is running (yes in this case) - // 2. If the version is stale (it will read daemon.state.json file and check startedWithCliVersion) & compare it to its own version - // 3. Next it will start a new daemon with the latest version with daemon-sync :D + // 1. It will first check if runner is running (yes in this case) + // 2. If the version is stale (it will read runner.state.json file and check startedWithCliVersion) & compare it to its own version + // 3. Next it will start a new runner with the latest version with runner-sync :D // Done! try { - spawnHappyCLI(['daemon', 'start'], { + spawnHappyCLI(['runner', 'start'], { detached: true, stdio: 'ignore' }); } catch (error) { - logger.debug('[DAEMON RUN] Failed to spawn new daemon, this is quite likely to happen during integration tests as we are cleaning out dist/ directory', error); + logger.debug('[RUNNER RUN] Failed to spawn new runner, this is quite likely to happen during integration tests as we are cleaning out dist/ directory', error); } // So we can just hang forever - logger.debug('[DAEMON RUN] Hanging for a bit - waiting for CLI to kill us because we are running outdated version of the code'); + logger.debug('[RUNNER RUN] Hanging for a bit - waiting for CLI to kill us because we are running outdated version of the code'); await new Promise(resolve => setTimeout(resolve, 10_000)); process.exit(0); } - // Before wrecklessly overriting the daemon state file, we should check if we are the ones who own it + // Before wrecklessly overriting the runner state file, we should check if we are the ones who own it // Race condition is possible, but thats okay for the time being :D - const daemonState = await readDaemonState(); - if (daemonState && daemonState.pid !== process.pid) { - logger.debug('[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves.') - requestShutdown('exception', 'A different daemon was started without killing us. We should kill ourselves.') + const runnerState = await readRunnerState(); + if (runnerState && runnerState.pid !== process.pid) { + logger.debug('[RUNNER RUN] Somehow a different runner was started without killing us. We should kill ourselves.') + requestShutdown('exception', 'A different runner was started without killing us. We should kill ourselves.') } // Heartbeat try { - const updatedState: DaemonLocallyPersistedState = { + const updatedState: RunnerLocallyPersistedState = { pid: process.pid, httpPort: controlPort, startTime: fileState.startTime, startedWithCliVersion: packageJson.version, startedWithCliMtimeMs, lastHeartbeat: new Date().toLocaleString(), - daemonLogPath: fileState.daemonLogPath + runnerLogPath: fileState.runnerLogPath }; - writeDaemonState(updatedState); + writeRunnerState(updatedState); if (process.env.DEBUG) { - logger.debug(`[DAEMON RUN] Health check completed at ${updatedState.lastHeartbeat}`); + logger.debug(`[RUNNER RUN] Health check completed at ${updatedState.lastHeartbeat}`); } } catch (error) { - logger.debug('[DAEMON RUN] Failed to write heartbeat', error); + logger.debug('[RUNNER RUN] Failed to write heartbeat', error); } heartbeatRunning = false; @@ -645,16 +645,16 @@ export async function startDaemon(): Promise { // Setup signal handlers const cleanupAndShutdown = async (source: 'hapi-app' | 'hapi-cli' | 'os-signal' | 'exception', errorMessage?: string) => { - logger.debug(`[DAEMON RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`); + logger.debug(`[RUNNER RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`); // Clear health check interval if (restartOnStaleVersionAndHeartbeat) { clearInterval(restartOnStaleVersionAndHeartbeat); - logger.debug('[DAEMON RUN] Health check interval cleared'); + logger.debug('[RUNNER RUN] Health check interval cleared'); } - // Update daemon state before shutting down - await apiMachine.updateDaemonState((state: DaemonState | null) => ({ + // Update runner state before shutting down + await apiMachine.updateRunnerState((state: RunnerState | null) => ({ ...state, status: 'shutting-down', shutdownRequestedAt: Date.now(), @@ -666,20 +666,20 @@ export async function startDaemon(): Promise { apiMachine.shutdown(); await stopControlServer(); - await cleanupDaemonState(); - await releaseDaemonLock(daemonLockHandle); + await cleanupRunnerState(); + await releaseRunnerLock(runnerLockHandle); - logger.debug('[DAEMON RUN] Cleanup completed, exiting process'); + logger.debug('[RUNNER RUN] Cleanup completed, exiting process'); process.exit(0); }; - logger.debug('[DAEMON RUN] Daemon started successfully, waiting for shutdown request'); + logger.debug('[RUNNER RUN] Runner started successfully, waiting for shutdown request'); // Wait for shutdown request const shutdownRequest = await resolvesWhenShutdownRequested; await cleanupAndShutdown(shutdownRequest.source, shutdownRequest.errorMessage); } catch (error) { - logger.debug('[DAEMON RUN][FATAL] Failed somewhere unexpectedly - exiting with code 1', error); + logger.debug('[RUNNER RUN][FATAL] Failed somewhere unexpectedly - exiting with code 1', error); process.exit(1); } } diff --git a/cli/src/daemon/daemon.integration.test.ts b/cli/src/runner/runner.integration.test.ts similarity index 69% rename from cli/src/daemon/daemon.integration.test.ts rename to cli/src/runner/runner.integration.test.ts index f2d397a4..98d403c5 100644 --- a/cli/src/daemon/daemon.integration.test.ts +++ b/cli/src/runner/runner.integration.test.ts @@ -1,13 +1,13 @@ /** - * Integration tests for daemon HTTP control system + * Integration tests for runner HTTP control system * - * Tests the full flow of daemon startup, session tracking, and shutdown + * Tests the full flow of runner startup, session tracking, and shutdown * * IMPORTANT: These tests MUST be run with the integration test environment: * yarn test:integration-test-env * * DO NOT run with regular 'npm test' or 'yarn test' - it will use the wrong environment - * and the daemon will not work properly! + * and the runner will not work properly! * * The integration test environment uses .env.integration-test which sets: * - HAPI_HOME=~/.hapi-dev-test (DIFFERENT from dev's ~/.hapi-dev!) @@ -21,17 +21,17 @@ import { existsSync, unlinkSync, readFileSync, writeFileSync, readdirSync } from import path, { join } from 'path'; import { configuration } from '@/configuration'; import { - listDaemonSessions, - stopDaemonSession, - spawnDaemonSession, - stopDaemonHttp, - notifyDaemonSessionStarted, - stopDaemon -} from '@/daemon/controlClient'; -import { readDaemonState, clearDaemonState } from '@/persistence'; + listRunnerSessions, + stopRunnerSession, + spawnRunnerSession, + stopRunnerHttp, + notifyRunnerSessionStarted, + stopRunner +} from '@/runner/controlClient'; +import { readRunnerState, clearRunnerState } from '@/persistence'; import { Metadata } from '@/api/types'; import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; -import { getLatestDaemonLog } from '@/ui/logger'; +import { getLatestRunnerLog } from '@/ui/logger'; import { isProcessAlive, isWindows, killProcess, killProcessByChildProcess } from '@/utils/process'; // Utility to wait for condition @@ -78,46 +78,46 @@ async function isServerHealthy(): Promise { } } -describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: 20_000 }, () => { - let daemonPid: number; +describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: 20_000 }, () => { + let runnerPid: number; beforeEach(async () => { - // First ensure no daemon is running by checking PID in metadata file - await stopDaemon() + // First ensure no runner is running by checking PID in metadata file + await stopRunner() - // Start fresh daemon for this test + // Start fresh runner for this test // This will return and start a background process - we don't need to wait for it - void spawnHappyCLI(['daemon', 'start'], { + void spawnHappyCLI(['runner', 'start'], { stdio: 'ignore' }); - // Wait for daemon to write its state file (it needs to auth, setup, and start server) + // Wait for runner to write its state file (it needs to auth, setup, and start server) await waitFor(async () => { - const state = await readDaemonState(); + const state = await readRunnerState(); return state !== null; }, 10_000, 250); // Wait up to 10 seconds, checking every 250ms - const daemonState = await readDaemonState(); - if (!daemonState) { - throw new Error('Daemon failed to start within timeout'); + const runnerState = await readRunnerState(); + if (!runnerState) { + throw new Error('Runner failed to start within timeout'); } - daemonPid = daemonState.pid; + runnerPid = runnerState.pid; - console.log(`[TEST] Daemon started for test: PID=${daemonPid}`); - console.log(`[TEST] Daemon log file: ${daemonState?.daemonLogPath}`); + console.log(`[TEST] Runner started for test: PID=${runnerPid}`); + console.log(`[TEST] Runner log file: ${runnerState?.runnerLogPath}`); }); afterEach(async () => { - await stopDaemon() + await stopRunner() }); it('should list sessions (initially empty)', async () => { - const sessions = await listDaemonSessions(); + const sessions = await listRunnerSessions(); expect(sessions).toEqual([]); }); it('should track session-started webhook from terminal session', async () => { - // Simulate a terminal-started session reporting to daemon + // Simulate a terminal-started session reporting to runner const mockMetadata: Metadata = { path: '/test/path', host: 'test-host', @@ -130,10 +130,10 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: machineId: 'test-machine-123' }; - await notifyDaemonSessionStarted('test-session-123', mockMetadata); + await notifyRunnerSessionStarted('test-session-123', mockMetadata); // Verify session is tracked - const sessions = await listDaemonSessions(); + const sessions = await listRunnerSessions(); expect(sessions).toHaveLength(1); const tracked = sessions[0]; @@ -143,56 +143,56 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: }); it('should spawn & stop a session via HTTP (not testing RPC route, but similar enough)', async () => { - const response = await spawnDaemonSession('/tmp', 'spawned-test-456'); + const response = await spawnRunnerSession('/tmp', 'spawned-test-456'); expect(response).toHaveProperty('success', true); expect(response).toHaveProperty('sessionId'); // Verify session is tracked - const sessions = await listDaemonSessions(); + const sessions = await listRunnerSessions(); const spawnedSession = sessions.find( (s: any) => s.happySessionId === response.sessionId ); expect(spawnedSession).toBeDefined(); - expect(spawnedSession.startedBy).toBe('daemon'); + expect(spawnedSession.startedBy).toBe('runner'); // Clean up - stop the spawned session expect(spawnedSession.happySessionId).toBeDefined(); - await stopDaemonSession(spawnedSession.happySessionId); + await stopRunnerSession(spawnedSession.happySessionId); }); it('stress test: spawn / stop', { timeout: 60_000 }, async () => { const promises = []; const sessionCount = 20; for (let i = 0; i < sessionCount; i++) { - promises.push(spawnDaemonSession('/tmp')); + promises.push(spawnRunnerSession('/tmp')); } // Wait for all sessions to be spawned const results = await Promise.all(promises); const sessionIds = results.map(r => r.sessionId); - const sessions = await listDaemonSessions(); + const sessions = await listRunnerSessions(); expect(sessions).toHaveLength(sessionCount); // Stop all sessions - const stopResults = await Promise.all(sessionIds.map(sessionId => stopDaemonSession(sessionId))); + const stopResults = await Promise.all(sessionIds.map(sessionId => stopRunnerSession(sessionId))); expect(stopResults.every(r => r), 'Not all sessions reported stopped').toBe(true); // Verify all sessions are stopped - const emptySessions = await listDaemonSessions(); + const emptySessions = await listRunnerSessions(); expect(emptySessions).toHaveLength(0); }); - it('should handle daemon stop request gracefully', async () => { - await stopDaemonHttp(); + it('should handle runner stop request gracefully', async () => { + await stopRunnerHttp(); // Verify metadata file is cleaned up - await waitFor(async () => !existsSync(configuration.daemonStateFile), 1000); + await waitFor(async () => !existsSync(configuration.runnerStateFile), 1000); }); - it('should track both daemon-spawned and terminal sessions', async () => { + it('should track both runner-spawned and terminal sessions', async () => { // Spawn a real hapi process that looks like it was started from terminal const terminalHappyProcess = spawnHappyCLI([ '--hapi-starting-mode', 'remote', @@ -208,30 +208,30 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: // Give time to start & report itself await new Promise(resolve => setTimeout(resolve, 5_000)); - // Spawn a daemon session - const spawnResponse = await spawnDaemonSession('/tmp', 'daemon-session-bbb'); + // Spawn a runner session + const spawnResponse = await spawnRunnerSession('/tmp', 'runner-session-bbb'); // List all sessions - const sessions = await listDaemonSessions(); + const sessions = await listRunnerSessions(); expect(sessions).toHaveLength(2); // Verify we have one of each type const terminalSession = sessions.find( (s: any) => s.pid === terminalHappyProcess.pid ); - const daemonSession = sessions.find( + const runnerSession = sessions.find( (s: any) => s.happySessionId === spawnResponse.sessionId ); expect(terminalSession).toBeDefined(); expect(terminalSession.startedBy).toBe('hapi directly - likely by user from terminal'); - expect(daemonSession).toBeDefined(); - expect(daemonSession.startedBy).toBe('daemon'); + expect(runnerSession).toBeDefined(); + expect(runnerSession.startedBy).toBe('runner'); // Clean up both sessions - await stopDaemonSession('terminal-session-aaa'); - await stopDaemonSession(daemonSession.happySessionId); + await stopRunnerSession('terminal-session-aaa'); + await stopRunnerSession(runnerSession.happySessionId); // Also kill the terminal process directly to be sure try { @@ -243,21 +243,21 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: it('should update session metadata when webhook is called', async () => { // Spawn a session - const spawnResponse = await spawnDaemonSession('/tmp'); + const spawnResponse = await spawnRunnerSession('/tmp'); // Verify webhook was processed (session ID updated) - const sessions = await listDaemonSessions(); + const sessions = await listRunnerSessions(); const session = sessions.find((s: any) => s.happySessionId === spawnResponse.sessionId); expect(session).toBeDefined(); // Clean up - await stopDaemonSession(spawnResponse.sessionId); + await stopRunnerSession(spawnResponse.sessionId); }); - it('should not allow starting a second daemon', async () => { - // Daemon is already running from beforeEach - // Try to start another daemon - const secondChild = spawn('bun', ['src/index.ts', 'daemon', 'start-sync'], { + it('should not allow starting a second runner', async () => { + // Runner is already running from beforeEach + // Try to start another runner + const secondChild = spawn('bun', ['src/index.ts', 'runner', 'start-sync'], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'] @@ -271,12 +271,12 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: output += data.toString(); }); - // Wait for the second daemon to exit + // Wait for the second runner to exit await new Promise((resolve) => { secondChild.on('exit', () => resolve()); }); - // Should report that daemon is already running + // Should report that runner is already running expect(output).toContain('already running'); }); @@ -285,7 +285,7 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: const promises = []; for (let i = 0; i < 3; i++) { promises.push( - spawnDaemonSession('/tmp') + spawnRunnerSession('/tmp') ); } @@ -304,68 +304,68 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: await new Promise(resolve => setTimeout(resolve, 1000)); // List should show all sessions - const sessions = await listDaemonSessions(); - const daemonSessions = sessions.filter( - (s: any) => s.startedBy === 'daemon' && spawnedSessionIds.includes(s.happySessionId) + const sessions = await listRunnerSessions(); + const runnerSessions = sessions.filter( + (s: any) => s.startedBy === 'runner' && spawnedSessionIds.includes(s.happySessionId) ); - expect(daemonSessions.length).toBeGreaterThanOrEqual(3); + expect(runnerSessions.length).toBeGreaterThanOrEqual(3); // Stop all spawned sessions - for (const session of daemonSessions) { + for (const session of runnerSessions) { expect(session.happySessionId).toBeDefined(); - await stopDaemonSession(session.happySessionId); + await stopRunnerSession(session.happySessionId); } }); it('should die with logs when SIGKILL is sent', async () => { - // SIGKILL test - daemon should die immediately + // SIGKILL test - runner should die immediately const logsDir = configuration.logsDir; const { readdirSync } = await import('fs'); // Get initial log files - const initialLogs = readdirSync(logsDir).filter(f => f.endsWith('-daemon.log')); + const initialLogs = readdirSync(logsDir).filter(f => f.endsWith('-runner.log')); - // Send SIGKILL to daemon (force kill) - await killProcess(daemonPid, true); + // Send SIGKILL to runner (force kill) + await killProcess(runnerPid, true); // Wait for process to die await new Promise(resolve => setTimeout(resolve, 500)); // Check if process is dead - const isDead = !isProcessAlive(daemonPid); + const isDead = !isProcessAlive(runnerPid); expect(isDead).toBe(true); - // Check that log file exists (it was created when daemon started) - const finalLogs = readdirSync(logsDir).filter(f => f.endsWith('-daemon.log')); + // Check that log file exists (it was created when runner started) + const finalLogs = readdirSync(logsDir).filter(f => f.endsWith('-runner.log')); expect(finalLogs.length).toBeGreaterThanOrEqual(initialLogs.length); - // The daemon won't have time to write cleanup logs with SIGKILL - console.log('[TEST] Daemon killed with SIGKILL - no cleanup logs expected'); + // The runner won't have time to write cleanup logs with SIGKILL + console.log('[TEST] Runner killed with SIGKILL - no cleanup logs expected'); - // Clean up state file manually since daemon couldn't do it - await clearDaemonState(); + // Clean up state file manually since runner couldn't do it + await clearRunnerState(); }); it('should die with cleanup logs when a graceful shutdown is requested', async () => { - // Graceful shutdown test - daemon should cleanup gracefully - const logFile = await getLatestDaemonLog(); + // Graceful shutdown test - runner should cleanup gracefully + const logFile = await getLatestRunnerLog(); if (!logFile) { throw new Error('No log file found'); } if (isWindows()) { // Windows taskkill does not deliver SIGTERM/SIGBREAK to Node handlers. - await stopDaemonHttp(); + await stopRunnerHttp(); } else { - // Send SIGTERM to daemon (graceful shutdown) - await killProcess(daemonPid); + // Send SIGTERM to runner (graceful shutdown) + await killProcess(runnerPid); } // Wait for graceful shutdown await new Promise(resolve => setTimeout(resolve, 4_000)); // Check if process is dead - const isDead = !isProcessAlive(daemonPid); + const isDead = !isProcessAlive(runnerPid); expect(isDead).toBe(true); // Read the log file to check for cleanup messages @@ -377,44 +377,44 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: } expect(logContent).toContain('cleanup'); - console.log('[TEST] Daemon terminated gracefully - cleanup logs written'); + console.log('[TEST] Runner terminated gracefully - cleanup logs written'); // Clean up state file if it still exists (should have been cleaned by SIGTERM handler) - await clearDaemonState(); + await clearRunnerState(); }); /** * Version mismatch detection test - control flow: * - * 1. Test starts daemon with original version (e.g., 0.9.0-6) compiled into dist/ + * 1. Test starts runner with original version (e.g., 0.9.0-6) compiled into dist/ * 2. Test modifies package.json to new version (e.g., 0.0.0-integration-test-*) * 3. Test runs `yarn build` to recompile with new version - * 4. Daemon's heartbeat (every 30s) reads package.json and compares to its compiled version - * 5. Daemon detects mismatch: package.json != configuration.currentCliVersion - * 6. Daemon spawns new daemon via spawnHappyCLI(['daemon', 'start']) - * 7. New daemon starts, reads daemon.state.json, sees old version != its compiled version - * 8. New daemon calls stopDaemon() to kill old daemon, then takes over + * 4. Runner's heartbeat (every 30s) reads package.json and compares to its compiled version + * 5. Runner detects mismatch: package.json != configuration.currentCliVersion + * 6. Runner spawns new runner via spawnHappyCLI(['runner', 'start']) + * 7. New runner starts, reads runner.state.json, sees old version != its compiled version + * 8. New runner calls stopRunner() to kill old runner, then takes over * * This simulates what happens during `npm upgrade hapi`: - * - Running daemon has OLD version loaded in memory (configuration.currentCliVersion) + * - Running runner has OLD version loaded in memory (configuration.currentCliVersion) * - npm replaces node_modules/hapi/ with NEW version files * - package.json on disk now has NEW version - * - Daemon reads package.json, detects mismatch, triggers self-update + * - Runner reads package.json, detects mismatch, triggers self-update * - Key difference: npm atomically replaces the entire module directory, while * our test must carefully rebuild to avoid missing entrypoint errors * * Critical timing constraints: - * - Heartbeat must be long enough (30s) for yarn build to complete before daemon tries to spawn + * - Heartbeat must be long enough (30s) for yarn build to complete before runner tries to spawn * - If heartbeat fires during rebuild, spawn fails (entrypoint missing) and test fails * - pkgroll doesn't reliably update compiled version, must use full yarn build * - Test modifies package.json BEFORE rebuild to ensure new version is compiled in * * Common failure modes: - * - Heartbeat too short: daemon tries to spawn while dist/ is being rebuilt + * - Heartbeat too short: runner tries to spawn while dist/ is being rebuilt * - Using pkgroll alone: doesn't update compiled configuration.currentCliVersion - * - Modifying package.json after daemon starts: triggers immediate version check on startup + * - Modifying package.json after runner starts: triggers immediate version check on startup */ - it('[takes 1 minute to run] should detect version mismatch and kill old daemon', { timeout: 100_000 }, async () => { + it('[takes 1 minute to run] should detect version mismatch and kill old runner', { timeout: 100_000 }, async () => { // Read current package.json to get version const packagePath = path.join(process.cwd(), 'package.json'); const packageJsonOriginalRawText = readFileSync(packagePath, 'utf8'); @@ -429,8 +429,8 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: writeFileSync(packagePath, JSON.stringify(modifiedPackage, null, 2)); try { - // Get initial daemon state - const initialState = await readDaemonState(); + // Get initial runner state + const initialState = await readRunnerState(); expect(initialState).toBeDefined(); expect(initialState!.startedWithCliVersion).toBe(originalVersion); const initialPid = initialState!.pid; @@ -439,24 +439,24 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: // and think it is a new version // We are not using yarn build here because it cleans out dist/ // and we want to avoid that, - // otherwise daemon will spawn a non existing happy js script. - // We need to remove index, but not the other files, otherwise some of our code might fail when called from within the daemon. + // otherwise runner will spawn a non existing happy js script. + // We need to remove index, but not the other files, otherwise some of our code might fail when called from within the runner. execSync('yarn build', { stdio: 'ignore' }); - console.log(`[TEST] Current daemon running with version ${originalVersion}, PID: ${initialPid}`); + console.log(`[TEST] Current runner running with version ${originalVersion}, PID: ${initialPid}`); console.log(`[TEST] Changed package.json version to ${testVersion}`); - // The daemon should automatically detect the version mismatch and restart itself + // The runner should automatically detect the version mismatch and restart itself // We check once per minute, wait for a little longer than that - await new Promise(resolve => setTimeout(resolve, parseInt(process.env.HAPI_DAEMON_HEARTBEAT_INTERVAL || '30000') + 10_000)); + await new Promise(resolve => setTimeout(resolve, parseInt(process.env.HAPI_RUNNER_HEARTBEAT_INTERVAL || '30000') + 10_000)); - // Check that the daemon is running with the new version - const finalState = await readDaemonState(); + // Check that the runner is running with the new version + const finalState = await readRunnerState(); expect(finalState).toBeDefined(); expect(finalState!.startedWithCliVersion).toBe(testVersion); expect(finalState!.pid).not.toBe(initialPid); - console.log('[TEST] Daemon version mismatch detection successful'); + console.log('[TEST] Runner version mismatch detection successful'); } finally { // CRITICAL: Restore original package.json version writeFileSync(packagePath, packageJsonOriginalRawText); @@ -469,7 +469,7 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout: // TODO: Add a test to see if a corrupted file will work - // TODO: Test npm uninstall scenario - daemon should gracefully handle when hapi is uninstalled - // Current behavior: daemon tries to spawn new daemon on version mismatch but entrypoint is gone - // Expected: daemon should detect missing entrypoint and either exit cleanly or at minimum not respawn infinitely + // TODO: Test npm uninstall scenario - runner should gracefully handle when hapi is uninstalled + // Current behavior: runner tries to spawn new runner on version mismatch but entrypoint is gone + // Expected: runner should detect missing entrypoint and either exit cleanly or at minimum not respawn infinitely }); diff --git a/cli/src/daemon/types.ts b/cli/src/runner/types.ts similarity index 72% rename from cli/src/daemon/types.ts rename to cli/src/runner/types.ts index 51db1adb..0f060fd5 100644 --- a/cli/src/daemon/types.ts +++ b/cli/src/runner/types.ts @@ -1,15 +1,15 @@ /** - * Daemon-specific types (not related to API/server communication) + * Runner-specific types (not related to API/server communication) */ import { Metadata } from '@/api/types'; import { ChildProcess } from 'child_process'; /** - * Session tracking for daemon + * Session tracking for runner */ export interface TrackedSession { - startedBy: 'daemon' | string; + startedBy: 'runner' | string; happySessionId?: string; happySessionMetadataFromLocalWebhook?: Metadata; pid: number; diff --git a/cli/src/daemon/worktree.ts b/cli/src/runner/worktree.ts similarity index 100% rename from cli/src/daemon/worktree.ts rename to cli/src/runner/worktree.ts diff --git a/cli/src/ui/doctor.ts b/cli/src/ui/doctor.ts index 89dcab82..3ce1e78c 100644 --- a/cli/src/ui/doctor.ts +++ b/cli/src/ui/doctor.ts @@ -2,15 +2,15 @@ * Doctor command implementation * * Provides comprehensive diagnostics and troubleshooting information - * for hapi CLI including configuration, daemon status, logs, and links + * for hapi CLI including configuration, runner status, logs, and links */ import chalk from 'chalk' import { configuration } from '@/configuration' import { readSettings } from '@/persistence' -import { checkIfDaemonRunningAndCleanupStaleState } from '@/daemon/controlClient' -import { findRunawayHappyProcesses, findAllHappyProcesses } from '@/daemon/doctor' -import { readDaemonState } from '@/persistence' +import { checkIfRunnerRunningAndCleanupStaleState } from '@/runner/controlClient' +import { findRunawayHappyProcesses, findAllHappyProcesses } from '@/runner/doctor' +import { readRunnerState } from '@/persistence' import { existsSync, readdirSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -66,13 +66,13 @@ function getLogFiles(logDir: string): { file: string, path: string, modified: Da } /** - * Run doctor command specifically for daemon diagnostics + * Run doctor command specifically for runner diagnostics */ -export async function runDoctorDaemon(): Promise { - return runDoctorCommand('daemon'); +export async function runDoctorRunner(): Promise { + return runDoctorCommand('runner'); } -export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise { +export async function runDoctorCommand(filter?: 'all' | 'runner'): Promise { // Default to 'all' if no filter specified if (!filter) { filter = 'all'; @@ -80,7 +80,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(chalk.bold.cyan('\n🩺 hapi CLI Doctor\n')); - // For 'all' filter, show everything. For 'daemon', only show daemon-related info + // For 'all' filter, show everything. For 'runner', only show runner-related info if (filter === 'all') { // Version and basic info console.log(chalk.bold('šŸ“‹ Basic Information')); @@ -89,8 +89,8 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(`Node.js Version: ${chalk.green(process.version)}`); console.log(''); - // Daemon spawn diagnostics - console.log(chalk.bold('šŸ”§ Daemon Spawn Diagnostics')); + // Runner spawn diagnostics + console.log(chalk.bold('šŸ”§ Runner Spawn Diagnostics')); const projectRoot = projectPath(); const cliEntrypoint = join(projectRoot, 'src', 'index.ts'); @@ -149,14 +149,14 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } - // Daemon status - shown for both 'all' and 'daemon' filters - console.log(chalk.bold('\nšŸ¤– Daemon Status')); + // Runner status - shown for both 'all' and 'runner' filters + console.log(chalk.bold('\nšŸ¤– Runner Status')); try { - const isRunning = await checkIfDaemonRunningAndCleanupStaleState(); - const state = await readDaemonState(); + const isRunning = await checkIfRunnerRunningAndCleanupStaleState(); + const state = await readRunnerState(); if (isRunning && state) { - console.log(chalk.green('āœ“ Daemon is running')); + console.log(chalk.green('āœ“ Runner is running')); console.log(` PID: ${state.pid}`); console.log(` Started: ${new Date(state.startTime).toLocaleString()}`); console.log(` CLI Version: ${state.startedWithCliVersion}`); @@ -164,15 +164,15 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(` HTTP Port: ${state.httpPort}`); } } else if (state && !isRunning) { - console.log(chalk.yellow('āš ļø Daemon state exists but process not running (stale)')); + console.log(chalk.yellow('āš ļø Runner state exists but process not running (stale)')); } else { - console.log(chalk.red('āŒ Daemon is not running')); + console.log(chalk.red('āŒ Runner is not running')); } - // Show daemon state file + // Show runner state file if (state) { - console.log(chalk.bold('\nšŸ“„ Daemon State:')); - console.log(chalk.blue(`Location: ${configuration.daemonStateFile}`)); + console.log(chalk.bold('\nšŸ“„ Runner State:')); + console.log(chalk.blue(`Location: ${configuration.runnerStateFile}`)); console.log(chalk.gray(JSON.stringify(state, null, 2))); } @@ -192,12 +192,12 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise Object.entries(grouped).forEach(([type, processes]) => { const typeLabels: Record = { 'current': 'šŸ“ Current Process', - 'daemon': 'šŸ¤– Daemon', - 'daemon-version-check': 'šŸ” Daemon Version Check (stuck)', - 'daemon-spawned-session': 'šŸ”— Daemon-Spawned Sessions', + 'runner': 'šŸ¤– Runner', + 'runner-version-check': 'šŸ” Runner Version Check (stuck)', + 'runner-spawned-session': 'šŸ”— Runner-Spawned Sessions', 'user-session': 'šŸ‘¤ User Sessions', - 'dev-daemon': 'šŸ› ļø Dev Daemon', - 'dev-daemon-version-check': 'šŸ› ļø Dev Daemon Version Check (stuck)', + 'dev-runner': 'šŸ› ļø Dev Runner', + 'dev-runner-version-check': 'šŸ› ļø Dev Runner Version Check (stuck)', 'dev-session': 'šŸ› ļø Dev Sessions', 'dev-doctor': 'šŸ› ļø Dev Doctor', 'dev-related': 'šŸ› ļø Dev Related', @@ -209,7 +209,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise processes.forEach(({ pid, command }) => { const color = type === 'current' ? chalk.green : type.startsWith('dev') ? chalk.cyan : - type.includes('daemon') ? chalk.blue : chalk.gray; + type.includes('runner') ? chalk.blue : chalk.gray; console.log(` ${color(`PID ${pid}`)}: ${chalk.gray(command)}`); }); }); @@ -222,7 +222,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(chalk.gray('To clean up runaway processes: hapi doctor clean')); } } catch (error) { - console.log(chalk.red('āŒ Error checking daemon status')); + console.log(chalk.red('āŒ Error checking runner status')); } // Log files - only show for 'all' filter @@ -233,9 +233,9 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise const allLogs = getLogFiles(configuration.logsDir); if (allLogs.length > 0) { - // Separate daemon and regular logs - const daemonLogs = allLogs.filter(({ file }) => file.includes('daemon')); - const regularLogs = allLogs.filter(({ file }) => !file.includes('daemon')); + // Separate runner and regular logs + const runnerLogs = allLogs.filter(({ file }) => file.includes('runner')); + const regularLogs = allLogs.filter(({ file }) => !file.includes('runner')); // Show regular logs (max 10) if (regularLogs.length > 0) { @@ -250,19 +250,19 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } } - // Show daemon logs (max 5) - if (daemonLogs.length > 0) { - console.log(chalk.blue('\nDaemon Logs:')); - const daemonLogsToShow = daemonLogs.slice(0, 5); - daemonLogsToShow.forEach(({ file, path, modified }) => { + // Show runner logs (max 5) + if (runnerLogs.length > 0) { + console.log(chalk.blue('\nRunner Logs:')); + const runnerLogsToShow = runnerLogs.slice(0, 5); + runnerLogsToShow.forEach(({ file, path, modified }) => { console.log(` ${chalk.green(file)} - ${modified.toLocaleString()}`); console.log(chalk.gray(` ${path}`)); }); - if (daemonLogs.length > 5) { - console.log(chalk.gray(` ... and ${daemonLogs.length - 5} more daemon log files`)); + if (runnerLogs.length > 5) { + console.log(chalk.gray(` ... and ${runnerLogs.length - 5} more runner log files`)); } } else { - console.log(chalk.yellow('\nNo daemon log files found')); + console.log(chalk.yellow('\nNo runner log files found')); } } else { console.log(chalk.yellow('No log files found')); diff --git a/cli/src/ui/logger.ts b/cli/src/ui/logger.ts index fdda3b2f..4c45fd9c 100644 --- a/cli/src/ui/logger.ts +++ b/cli/src/ui/logger.ts @@ -10,7 +10,7 @@ import { appendFileSync } from 'fs' import { configuration } from '@/configuration' import { existsSync, readdirSync, statSync } from 'node:fs' import { join, basename } from 'node:path' -import { readDaemonState } from '@/persistence' +import { readRunnerState } from '@/persistence' /** * Consistent date/time formatting functions @@ -40,7 +40,7 @@ function createTimestampForLogEntry(date: Date = new Date()): string { function getSessionLogPath(): string { const timestamp = createTimestampForFilename() - const filename = configuration.isDaemonProcess ? `${timestamp}-daemon.log` : `${timestamp}.log` + const filename = configuration.isRunnerProcess ? `${timestamp}-runner.log` : `${timestamp}.log` return join(configuration.logsDir, filename) } @@ -243,10 +243,10 @@ export type LogFileInfo = { }; /** - * List daemon log files in descending modification time order. + * List runner log files in descending modification time order. * Returns up to `limit` entries; empty array if none. */ -export async function listDaemonLogFiles(limit: number = 50): Promise { +export async function listRunnerLogFiles(limit: number = 50): Promise { try { const logsDir = configuration.logsDir; if (!existsSync(logsDir)) { @@ -254,7 +254,7 @@ export async function listDaemonLogFiles(limit: number = 50): Promise file.endsWith('-daemon.log')) + .filter(file => file.endsWith('-runner.log')) .map(file => { const fullPath = join(logsDir, file); const stats = statSync(fullPath); @@ -262,19 +262,19 @@ export async function listDaemonLogFiles(limit: number = 50): Promise b.modified.getTime() - a.modified.getTime()); - // Prefer the path persisted by the daemon if present (return 0th element if present) + // Prefer the path persisted by the runner if present (return 0th element if present) try { - const state = await readDaemonState(); + const state = await readRunnerState(); if (!state) { return logs; } - if (state.daemonLogPath && existsSync(state.daemonLogPath)) { - const stats = statSync(state.daemonLogPath); + if (state.runnerLogPath && existsSync(state.runnerLogPath)) { + const stats = statSync(state.runnerLogPath); const persisted: LogFileInfo = { - file: basename(state.daemonLogPath), - path: state.daemonLogPath, + file: basename(state.runnerLogPath), + path: state.runnerLogPath, modified: stats.mtime }; const idx = logs.findIndex(l => l.path === persisted.path); @@ -286,7 +286,7 @@ export async function listDaemonLogFiles(limit: number = 50): Promise { - const [latest] = await listDaemonLogFiles(1); +export async function getLatestRunnerLog(): Promise { + const [latest] = await listRunnerLogFiles(1); return latest || null; } diff --git a/cli/src/utils/spawnHappyCLI.ts b/cli/src/utils/spawnHappyCLI.ts index 1b9fa8de..12557872 100644 --- a/cli/src/utils/spawnHappyCLI.ts +++ b/cli/src/utils/spawnHappyCLI.ts @@ -20,7 +20,7 @@ * * ## Cross-Platform Support * - * This utility handles spawning HAPI CLI subprocesses (for daemon processes) + * This utility handles spawning HAPI CLI subprocesses (for runner processes) * in a cross-platform way, detecting the current runtime mode and using * the appropriate command and arguments. */ diff --git a/cli/src/utils/time.ts b/cli/src/utils/time.ts index 9045ffd0..01f00326 100644 --- a/cli/src/utils/time.ts +++ b/cli/src/utils/time.ts @@ -61,7 +61,7 @@ export type RetryOptions = { * * Unlike createBackoff, this function: * - Supports a shouldRetry predicate to skip non-retryable errors - * - Has sensible defaults for daemon-style long-running processes + * - Has sensible defaults for runner-style long-running processes * - Uses clearer exponential backoff (2^n with jitter) */ export async function withRetry( diff --git a/cli/src/utils/worktreeEnv.ts b/cli/src/utils/worktreeEnv.ts index 79efd3e7..720883a8 100644 --- a/cli/src/utils/worktreeEnv.ts +++ b/cli/src/utils/worktreeEnv.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process'; import { realpathSync, statSync } from 'node:fs'; import { basename, dirname, isAbsolute, resolve } from 'node:path'; -import type { WorktreeInfo } from '@/daemon/worktree'; +import type { WorktreeInfo } from '@/runner/worktree'; import { logger } from '@/ui/logger'; export function readWorktreeEnv(): WorktreeInfo | null { diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 979d0cb3..f0754032 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -72,9 +72,9 @@ HAPI supports two methods: ### Can I start sessions remotely? -Yes, with daemon mode: +Yes, with runner mode: -1. Run `hapi daemon start` on your computer +1. Run `hapi runner start` on your computer 2. Your machine appears in the "Machines" list in the web app 3. Tap to spawn new sessions from anywhere @@ -123,17 +123,17 @@ Only if they have your access token. For additional security: - Check token matches in CLI and server - Verify `~/.hapi/settings.json` has correct `cliApiToken` -### Daemon won't start +### Runner won't start ```bash # Check status -hapi daemon status +hapi runner status # Clear stale lock file -rm ~/.hapi/daemon.state.json.lock +rm ~/.hapi/runner.state.json.lock # Check logs -hapi daemon logs +hapi runner logs ``` ### Claude Code not found diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index aedb5f55..fe99d89a 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -59,7 +59,7 @@ The CLI is a wrapper around AI coding agents (Claude Code, Codex, Gemini). It: hapi # Start Claude Code session hapi codex # Start OpenAI Codex session hapi gemini # Start Google Gemini session -hapi daemon start # Run background service for remote session spawning +hapi runner start # Run background service for remote session spawning ``` ### HAPI Server diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 5b748a36..354117e8 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -95,7 +95,7 @@ On first run, HAPI: ~/.hapi/ ā”œā”€ā”€ settings.json # Main configuration ā”œā”€ā”€ hapi.db # SQLite database (server) -ā”œā”€ā”€ daemon.state.json # Daemon process state +ā”œā”€ā”€ runner.state.json # Runner process state └── logs/ # Log files ``` @@ -201,18 +201,18 @@ hapi server Then message your bot with `/start`, open the app, and enter your `CLI_API_TOKEN`. -### Daemon setup +### Runner setup Run a background service for remote session spawning: ```bash -hapi daemon start -hapi daemon status -hapi daemon logs -hapi daemon stop +hapi runner start +hapi runner status +hapi runner logs +hapi runner stop ``` -With the daemon running: +With the runner running: - Your machine appears in the "Machines" list - You can spawn sessions remotely from the web app diff --git a/docs/guide/namespace.md b/docs/guide/namespace.md index 902d1c8f..00edc9e1 100644 --- a/docs/guide/namespace.md +++ b/docs/guide/namespace.md @@ -31,4 +31,4 @@ CLI_API_TOKEN="your-base-token:alice" - Namespaces are isolated: sessions, machines, and users are not visible across namespaces. - One machine ID cannot be reused across namespaces. - To run multiple namespaces on one machine, use a separate `HAPI_HOME` per namespace, or clear the machine ID with `hapi auth logout` before switching. -- Remote spawn is namespace-scoped. If you need remote spawning for multiple namespaces on the same machine, run a separate daemon per namespace (use separate `HAPI_HOME`). +- Remote spawn is namespace-scoped. If you need remote spawning for multiple namespaces on the same machine, run a separate runner per namespace (use separate `HAPI_HOME`). diff --git a/server/README.md b/server/README.md index 0fb98fbc..43f9839e 100644 --- a/server/README.md +++ b/server/README.md @@ -165,7 +165,7 @@ See `src/store/index.ts` for SQLite persistence: - Sessions with metadata and agent state. - Messages with pagination support. -- Machines with daemon state. +- Machines with runner state. - Todo extraction from messages. - Users table for Telegram bindings (includes namespace). diff --git a/server/src/config/settings.ts b/server/src/config/settings.ts index 207b238f..f383f0a1 100644 --- a/server/src/config/settings.ts +++ b/server/src/config/settings.ts @@ -5,7 +5,7 @@ import { dirname, join } from 'node:path' export interface Settings { machineId?: string machineIdConfirmedByServer?: boolean - daemonAutoStartWhenRunningHappy?: boolean + runnerAutoStartWhenRunningHappy?: boolean cliApiToken?: string vapidKeys?: { publicKey: string diff --git a/server/src/socket/handlers/cli/machineHandlers.ts b/server/src/socket/handlers/cli/machineHandlers.ts index 71f8635f..049e26e2 100644 --- a/server/src/socket/handlers/cli/machineHandlers.ts +++ b/server/src/socket/handlers/cli/machineHandlers.ts @@ -28,7 +28,7 @@ const machineUpdateMetadataSchema = z.object({ const machineUpdateStateSchema = z.object({ machineId: z.string(), expectedVersion: z.number().int(), - daemonState: z.unknown().nullable() + runnerState: z.unknown().nullable() }) export type MachineHandlersDeps = { @@ -86,7 +86,7 @@ export function registerMachineHandlers(socket: SocketWithData, deps: MachineHan t: 'update-machine' as const, machineId: id, metadata: { version: result.version, value: metadata }, - daemonState: null + runnerState: null } } socket.to(`machine:${id}`).emit('update', update) @@ -101,23 +101,23 @@ export function registerMachineHandlers(socket: SocketWithData, deps: MachineHan return } - const { machineId: id, daemonState, expectedVersion } = parsed.data + const { machineId: id, runnerState, expectedVersion } = parsed.data const machineAccess = resolveMachineAccess(id) if (!machineAccess.ok) { cb({ result: 'error', reason: machineAccess.reason }) return } - const result = store.machines.updateMachineDaemonState( + const result = store.machines.updateMachineRunnerState( id, - daemonState, + runnerState, expectedVersion, machineAccess.value.namespace ) if (result.result === 'success') { - cb({ result: 'success', version: result.version, daemonState: result.value }) + cb({ result: 'success', version: result.version, runnerState: result.value }) } else if (result.result === 'version-mismatch') { - cb({ result: 'version-mismatch', version: result.version, daemonState: result.value }) + cb({ result: 'version-mismatch', version: result.version, runnerState: result.value }) } else { cb({ result: 'error' }) } @@ -131,7 +131,7 @@ export function registerMachineHandlers(socket: SocketWithData, deps: MachineHan t: 'update-machine' as const, machineId: id, metadata: null, - daemonState: { version: result.version, value: daemonState } + runnerState: { version: result.version, value: runnerState } } } socket.to(`machine:${id}`).emit('update', update) diff --git a/server/src/store/index.ts b/server/src/store/index.ts index a2dcb0e7..b71c15f2 100644 --- a/server/src/store/index.ts +++ b/server/src/store/index.ts @@ -22,7 +22,7 @@ export { PushStore } from './pushStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION = 1 +const SCHEMA_VERSION = 2 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -87,6 +87,7 @@ export class Store { const currentVersion = this.getUserVersion() if (currentVersion === 0) { if (this.hasAnyUserTables()) { + this.migrateLegacySchemaIfNeeded() this.setUserVersion(SCHEMA_VERSION) return } @@ -96,6 +97,12 @@ export class Store { return } + if (currentVersion === 1 && SCHEMA_VERSION === 2) { + this.migrateFromV1ToV2() + this.setUserVersion(SCHEMA_VERSION) + return + } + if (currentVersion !== SCHEMA_VERSION) { throw this.buildSchemaMismatchError(currentVersion) } @@ -132,8 +139,8 @@ export class Store { updated_at INTEGER NOT NULL, metadata TEXT, metadata_version INTEGER DEFAULT 1, - daemon_state TEXT, - daemon_state_version INTEGER DEFAULT 1, + runner_state TEXT, + runner_state_version INTEGER DEFAULT 1, active INTEGER DEFAULT 0, active_at INTEGER, seq INTEGER DEFAULT 0 @@ -176,6 +183,97 @@ export class Store { `) } + private migrateLegacySchemaIfNeeded(): void { + const columns = this.getMachineColumnNames() + if (columns.size === 0) { + return + } + + const hasDaemon = columns.has('daemon_state') || columns.has('daemon_state_version') + const hasRunner = columns.has('runner_state') || columns.has('runner_state_version') + + if (hasDaemon && hasRunner) { + throw new Error('SQLite schema has both daemon_state and runner_state columns in machines; manual cleanup required.') + } + + if (hasDaemon && !hasRunner) { + this.migrateFromV1ToV2() + } + } + + private migrateFromV1ToV2(): void { + const columns = this.getMachineColumnNames() + if (columns.size === 0) { + throw new Error('SQLite schema missing machines table for v1 to v2 migration.') + } + + const hasDaemon = columns.has('daemon_state') && columns.has('daemon_state_version') + const hasRunner = columns.has('runner_state') && columns.has('runner_state_version') + + if (hasRunner && !hasDaemon) { + return + } + + if (!hasDaemon) { + throw new Error('SQLite schema missing daemon_state columns for v1 to v2 migration.') + } + + try { + this.db.exec('BEGIN') + this.db.exec('ALTER TABLE machines RENAME COLUMN daemon_state TO runner_state') + this.db.exec('ALTER TABLE machines RENAME COLUMN daemon_state_version TO runner_state_version') + this.db.exec('COMMIT') + return + } catch (error) { + this.db.exec('ROLLBACK') + } + + try { + this.db.exec('BEGIN') + this.db.exec(` + CREATE TABLE machines_new ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + runner_state TEXT, + runner_state_version INTEGER DEFAULT 1, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + `) + this.db.exec(` + INSERT INTO machines_new ( + id, namespace, created_at, updated_at, + metadata, metadata_version, + runner_state, runner_state_version, + active, active_at, seq + ) + SELECT id, namespace, created_at, updated_at, + metadata, metadata_version, + daemon_state, daemon_state_version, + active, active_at, seq + FROM machines; + `) + this.db.exec('DROP TABLE machines') + this.db.exec('ALTER TABLE machines_new RENAME TO machines') + this.db.exec('CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace)') + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + const message = error instanceof Error ? error.message : String(error) + throw new Error(`SQLite schema migration v1->v2 failed: ${message}`) + } + } + + private getMachineColumnNames(): Set { + const rows = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }> + return new Set(rows.map((row) => row.name)) + } + private getUserVersion(): number { const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number } | undefined return row?.user_version ?? 0 diff --git a/server/src/store/machineStore.ts b/server/src/store/machineStore.ts index cedba12a..0ae54f87 100644 --- a/server/src/store/machineStore.ts +++ b/server/src/store/machineStore.ts @@ -7,7 +7,7 @@ import { getMachines, getMachinesByNamespace, getOrCreateMachine, - updateMachineDaemonState, + updateMachineRunnerState, updateMachineMetadata } from './machines' @@ -18,8 +18,8 @@ export class MachineStore { this.db = db } - getOrCreateMachine(id: string, metadata: unknown, daemonState: unknown, namespace: string): StoredMachine { - return getOrCreateMachine(this.db, id, metadata, daemonState, namespace) + getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): StoredMachine { + return getOrCreateMachine(this.db, id, metadata, runnerState, namespace) } updateMachineMetadata( @@ -31,13 +31,13 @@ export class MachineStore { return updateMachineMetadata(this.db, id, metadata, expectedVersion, namespace) } - updateMachineDaemonState( + updateMachineRunnerState( id: string, - daemonState: unknown, + runnerState: unknown, expectedVersion: number, namespace: string ): VersionedUpdateResult { - return updateMachineDaemonState(this.db, id, daemonState, expectedVersion, namespace) + return updateMachineRunnerState(this.db, id, runnerState, expectedVersion, namespace) } getMachine(id: string): StoredMachine | null { diff --git a/server/src/store/machines.ts b/server/src/store/machines.ts index e3a6119c..01d61cb7 100644 --- a/server/src/store/machines.ts +++ b/server/src/store/machines.ts @@ -11,8 +11,8 @@ type DbMachineRow = { updated_at: number metadata: string | null metadata_version: number - daemon_state: string | null - daemon_state_version: number + runner_state: string | null + runner_state_version: number active: number active_at: number | null seq: number @@ -26,8 +26,8 @@ function toStoredMachine(row: DbMachineRow): StoredMachine { updatedAt: row.updated_at, metadata: safeJsonParse(row.metadata), metadataVersion: row.metadata_version, - daemonState: safeJsonParse(row.daemon_state), - daemonStateVersion: row.daemon_state_version, + runnerState: safeJsonParse(row.runner_state), + runnerStateVersion: row.runner_state_version, active: row.active === 1, activeAt: row.active_at, seq: row.seq @@ -38,7 +38,7 @@ export function getOrCreateMachine( db: Database, id: string, metadata: unknown, - daemonState: unknown, + runnerState: unknown, namespace: string ): StoredMachine { const existing = db.prepare('SELECT * FROM machines WHERE id = ?').get(id) as DbMachineRow | undefined @@ -52,18 +52,18 @@ export function getOrCreateMachine( const now = Date.now() const metadataJson = JSON.stringify(metadata) - const daemonStateJson = daemonState === null || daemonState === undefined ? null : JSON.stringify(daemonState) + const runnerStateJson = runnerState === null || runnerState === undefined ? null : JSON.stringify(runnerState) db.prepare(` INSERT INTO machines ( id, namespace, created_at, updated_at, metadata, metadata_version, - daemon_state, daemon_state_version, + runner_state, runner_state_version, active, active_at, seq ) VALUES ( @id, @namespace, @created_at, @updated_at, @metadata, 1, - @daemon_state, 1, + @runner_state, 1, 0, NULL, 0 ) `).run({ @@ -72,7 +72,7 @@ export function getOrCreateMachine( created_at: now, updated_at: now, metadata: metadataJson, - daemon_state: daemonStateJson + runner_state: runnerStateJson }) const row = getMachine(db, id) @@ -110,23 +110,23 @@ export function updateMachineMetadata( }) } -export function updateMachineDaemonState( +export function updateMachineRunnerState( db: Database, id: string, - daemonState: unknown, + runnerState: unknown, expectedVersion: number, namespace: string ): VersionedUpdateResult { const now = Date.now() - const normalized = daemonState ?? null + const normalized = runnerState ?? null return updateVersionedField({ db, table: 'machines', id, namespace, - field: 'daemon_state', - versionField: 'daemon_state_version', + field: 'runner_state', + versionField: 'runner_state_version', expectedVersion, value: normalized, encode: (value) => (value === null ? null : JSON.stringify(value)), diff --git a/server/src/store/types.ts b/server/src/store/types.ts index 91b6a6f2..9ef422ac 100644 --- a/server/src/store/types.ts +++ b/server/src/store/types.ts @@ -23,8 +23,8 @@ export type StoredMachine = { updatedAt: number metadata: unknown | null metadataVersion: number - daemonState: unknown | null - daemonStateVersion: number + runnerState: unknown | null + runnerStateVersion: number active: boolean activeAt: number | null seq: number diff --git a/server/src/sync/machineCache.ts b/server/src/sync/machineCache.ts index fecb9c15..87643820 100644 --- a/server/src/sync/machineCache.ts +++ b/server/src/sync/machineCache.ts @@ -26,8 +26,8 @@ export interface Machine { [key: string]: unknown } | null metadataVersion: number - daemonState: unknown | null - daemonStateVersion: number + runnerState: unknown | null + runnerStateVersion: number } export class MachineCache { @@ -68,8 +68,8 @@ export class MachineCache { return this.getMachinesByNamespace(namespace).filter((machine) => machine.active) } - getOrCreateMachine(id: string, metadata: unknown, daemonState: unknown, namespace: string): Machine { - const stored = this.store.machines.getOrCreateMachine(id, metadata, daemonState, namespace) + getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): Machine { + const stored = this.store.machines.getOrCreateMachine(id, metadata, runnerState, namespace) return this.refreshMachine(stored.id) ?? (() => { throw new Error('Failed to load machine') })() } @@ -110,8 +110,8 @@ export class MachineCache { activeAt: useStoredActivity ? storedActiveAt : (existingActiveAt || storedActiveAt), metadata, metadataVersion: stored.metadataVersion, - daemonState: stored.daemonState, - daemonStateVersion: stored.daemonStateVersion + runnerState: stored.runnerState, + runnerStateVersion: stored.runnerStateVersion } this.machines.set(machineId, machine) diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 65088725..bdab76c6 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -183,8 +183,8 @@ export class SyncEngine { return this.sessionCache.getOrCreateSession(tag, metadata, agentState, namespace) } - getOrCreateMachine(id: string, metadata: unknown, daemonState: unknown, namespace: string): Machine { - return this.machineCache.getOrCreateMachine(id, metadata, daemonState, namespace) + getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): Machine { + return this.machineCache.getOrCreateMachine(id, metadata, runnerState, namespace) } async sendMessage( diff --git a/server/src/web/routes/cli.ts b/server/src/web/routes/cli.ts index 815ade03..6f9343d7 100644 --- a/server/src/web/routes/cli.ts +++ b/server/src/web/routes/cli.ts @@ -16,7 +16,7 @@ const createOrLoadSessionSchema = z.object({ const createOrLoadMachineSchema = z.object({ id: z.string().min(1), metadata: z.unknown(), - daemonState: z.unknown().nullable().optional() + runnerState: z.unknown().nullable().optional() }) const getMessagesQuerySchema = z.object({ @@ -152,7 +152,7 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono