mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
docs: rewrite daemon README with current codebase state
Complete documentation refresh covering: - Version detection via CLI binary mtime (not package.json) - Multi-Agent Support section with token authentication for claude/codex/gemini - Directory creation approval flow for session spawning - HTTP Control Server endpoints with Fastify + Zod schemas - State persistence fields including startedWithCliMtimeMs - Integration Testing section with test environment and key scenarios - Typed shutdown sources (hapi-app, hapi-cli, os-signal, exception) - MachineMetadata with new happyLibDir field - WebSocket communication patterns and RPC methods - Machine sync architecture with separated metadata and daemon state Replaces CLAUDE.md with structured daemon documentation.
This commit is contained in:
@@ -14,42 +14,52 @@ Control Flow:
|
||||
3. New process calls `startDaemon()` from `src/daemon/run.ts`
|
||||
4. `startDaemon()` performs startup:
|
||||
- Sets up shutdown promise and handlers (SIGINT, SIGTERM, uncaughtException, unhandledRejection)
|
||||
- Version check: `isDaemonRunningSameVersion()` reads daemon.state.json, compares `startedWithCliVersion` with `configuration.currentCliVersion`
|
||||
- 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
|
||||
- Direct-connect setup: `authAndSetupMachineIfNeeded()` ensures `CLI_API_TOKEN` is set and `machineId` exists
|
||||
- State persistence: writes PID, version, HTTP port to daemon.state.json
|
||||
- HTTP server: starts on random port for local CLI control (list, stop, spawn)
|
||||
- State persistence: writes PID, version, HTTP port, mtime to daemon.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`, `requestShutdown` handlers
|
||||
- Heartbeat loop: every 60s (or HAPI_DAEMON_HEARTBEAT_INTERVAL) checks for version updates and prunes dead sessions
|
||||
- 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
|
||||
5. Awaits shutdown promise which resolves when:
|
||||
- OS signal received (SIGINT/SIGTERM)
|
||||
- HTTP `/stop` endpoint called
|
||||
- RPC `requestShutdown` invoked
|
||||
- Uncaught exception occurs
|
||||
- OS signal received (SIGINT/SIGTERM) - source: `os-signal`
|
||||
- HTTP `/stop` endpoint called - source: `hapi-cli`
|
||||
- RPC `stop-daemon` 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
|
||||
- Updates daemon state to "shutting-down" on backend with shutdown source
|
||||
- Disconnects WebSocket
|
||||
- Stops HTTP server
|
||||
- Deletes daemon.state.json
|
||||
- Releases lock file
|
||||
- Exits process
|
||||
|
||||
### Version Mismatch Auto-Update
|
||||
### Version Detection & Auto-Update
|
||||
|
||||
The daemon detects when `npm upgrade hapi` occurs:
|
||||
1. Heartbeat reads package.json from disk
|
||||
2. Compares `JSON.parse(package.json).version` with compiled `configuration.currentCliVersion`
|
||||
3. If mismatch detected:
|
||||
The daemon 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'])`
|
||||
- Hangs and waits to be killed
|
||||
4. New daemon starts, sees old daemon.state.json version != its compiled version
|
||||
- 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
|
||||
|
||||
### Heartbeat System
|
||||
|
||||
Every 60 seconds (configurable via `HAPI_DAEMON_HEARTBEAT_INTERVAL`):
|
||||
1. **Guard**: Skips if previous heartbeat still running (prevents concurrent heartbeats)
|
||||
2. **Session Pruning**: Checks each tracked PID with `process.kill(pid, 0)`, 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
|
||||
|
||||
### Stopping the Daemon
|
||||
|
||||
Command: `hapi daemon stop`
|
||||
@@ -57,15 +67,32 @@ Command: `hapi daemon stop`
|
||||
Control Flow:
|
||||
1. `stopDaemon()` in `controlClient.ts` reads daemon.state.json
|
||||
2. Attempts graceful shutdown via HTTP POST to `/stop`
|
||||
3. Daemon receives request, calls `cleanupAndShutdown()`:
|
||||
3. Daemon 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
|
||||
- Releases lock file
|
||||
4. If HTTP fails, falls back to `process.kill(pid, 'SIGKILL')`
|
||||
5. If HTTP fails, falls back to `process.kill(pid, 'SIGKILL')`
|
||||
|
||||
## 2. Session Management
|
||||
## 2. Multi-Agent Support
|
||||
|
||||
The daemon supports spawning sessions with different AI agents:
|
||||
|
||||
| Agent | Command | Token Environment |
|
||||
|-------|---------|-------------------|
|
||||
| `claude` (default) | `hapi claude` | `CLAUDE_CODE_OAUTH_TOKEN` |
|
||||
| `codex` | `hapi codex` | `CODEX_HOME` (temp directory with `auth.json`) |
|
||||
| `gemini` | `hapi gemini` | - |
|
||||
|
||||
### Token Authentication
|
||||
|
||||
When spawning a session with a token:
|
||||
- **Claude**: Sets `CLAUDE_CODE_OAUTH_TOKEN` environment variable
|
||||
- **Codex**: Creates temp directory at `os.tmpdir()/hapi-codex-*`, writes token to `auth.json`, sets `CODEX_HOME`
|
||||
|
||||
## 3. Session Management
|
||||
|
||||
### Daemon-Spawned Sessions (Remote)
|
||||
|
||||
@@ -73,10 +100,11 @@ Initiated by mobile app via backend RPC:
|
||||
1. Backend forwards RPC `spawn-happy-session` to daemon via WebSocket
|
||||
2. `ApiMachineClient` invokes `spawnSession()` handler
|
||||
3. `spawnSession()`:
|
||||
- Creates directory if needed
|
||||
- Validates/creates directory (with approval flow)
|
||||
- Configures agent-specific token environment
|
||||
- Spawns detached HAPI process with `--hapi-starting-mode remote --started-by daemon`
|
||||
- Adds to `pidToTrackedSession` map
|
||||
- Sets up 10-second awaiter for session webhook
|
||||
- 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`
|
||||
@@ -87,27 +115,150 @@ Initiated by mobile app via backend RPC:
|
||||
|
||||
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...'`
|
||||
2. HAPI process calls `notifyDaemonSessionStarted()`
|
||||
3. Daemon receives webhook, creates `TrackedSession` with `startedBy: 'hapi directly - likely by user from terminal'`
|
||||
4. Session tracked for health monitoring
|
||||
|
||||
### Directory Creation Approval
|
||||
|
||||
When spawning a session, directory handling:
|
||||
1. Check if directory exists with `fs.access()`
|
||||
2. If missing and `approvedNewDirectoryCreation = false`: returns `requestToApproveDirectoryCreation` (HTTP 409)
|
||||
3. If missing and approved: creates directory with `fs.mkdir({ recursive: true })`
|
||||
4. Error handling for directory creation:
|
||||
- `EACCES`: Permission denied
|
||||
- `ENOTDIR`: File exists at path
|
||||
- `ENOSPC`: Disk full
|
||||
- `EROFS`: Read-only filesystem
|
||||
|
||||
### Session Termination
|
||||
|
||||
Via RPC `stop-session` or health check:
|
||||
1. `stopSession()` finds session by `happySessionId`
|
||||
2. Sends SIGTERM to process
|
||||
Via RPC `stop-session` or HTTP `/stop-session`:
|
||||
1. `stopSession()` finds session by `happySessionId` or `PID-{pid}` format
|
||||
2. Sends SIGTERM to process (via `childProcess.kill()` or `process.kill(pid)`)
|
||||
3. `on('exit')` handler removes from tracking map
|
||||
|
||||
## 3. HTTP Control Server
|
||||
## 4. HTTP Control Server (Fastify)
|
||||
|
||||
Local HTTP server (127.0.0.1 only) provides:
|
||||
- `/session-started` - webhook for sessions to report themselves
|
||||
- `/list` - returns tracked sessions
|
||||
- `/stop-session` - terminates specific session
|
||||
- `/spawn-session` - creates new session (used by integration tests)
|
||||
- `/stop` - graceful daemon shutdown
|
||||
Local HTTP server using Fastify with `fastify-type-provider-zod` for type-safe request/response validation.
|
||||
|
||||
## 4. Process Discovery and Cleanup
|
||||
**Host:** 127.0.0.1 (localhost only)
|
||||
**Port:** Dynamic (system-assigned)
|
||||
|
||||
### Endpoints
|
||||
|
||||
#### POST `/session-started`
|
||||
Session webhook - reports itself after creation.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "sessionId": "string", "metadata": { ... } }
|
||||
```
|
||||
**Response (200):**
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
#### POST `/list`
|
||||
Returns all tracked sessions.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"children": [
|
||||
{ "startedBy": "daemon", "happySessionId": "uuid", "pid": 12345 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/stop-session`
|
||||
Terminates a specific session.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "sessionId": "string" }
|
||||
```
|
||||
**Response (200):**
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
#### POST `/spawn-session`
|
||||
Creates a new session.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "directory": "/path/to/dir", "sessionId": "optional-uuid" }
|
||||
```
|
||||
**Response (200) - Success:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"sessionId": "uuid",
|
||||
"approvedNewDirectoryCreation": true
|
||||
}
|
||||
```
|
||||
**Response (409) - Requires Approval:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"requiresUserApproval": true,
|
||||
"actionRequired": "CREATE_DIRECTORY",
|
||||
"directory": "/path/to/dir"
|
||||
}
|
||||
```
|
||||
**Response (500) - Error:**
|
||||
```json
|
||||
{ "success": false, "error": "Error message" }
|
||||
```
|
||||
|
||||
#### POST `/stop`
|
||||
Graceful daemon shutdown.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{ "status": "stopping" }
|
||||
```
|
||||
|
||||
## 5. State Persistence
|
||||
|
||||
### daemon.state.json
|
||||
```json
|
||||
{
|
||||
"pid": 12345,
|
||||
"httpPort": 50097,
|
||||
"startTime": "8/24/2025, 6:46:22 PM",
|
||||
"startedWithCliVersion": "0.9.0-6",
|
||||
"startedWithCliMtimeMs": 1724531182000,
|
||||
"lastHeartbeat": "8/24/2025, 6:47:22 PM",
|
||||
"daemonLogPath": "/path/to/daemon.log"
|
||||
}
|
||||
```
|
||||
|
||||
### Lock File
|
||||
- Created with O_EXCL flag for atomic acquisition
|
||||
- Contains PID for debugging
|
||||
- Prevents multiple daemon instances
|
||||
- Cleaned up on graceful shutdown
|
||||
|
||||
## 6. WebSocket Communication
|
||||
|
||||
`ApiMachineClient` handles bidirectional communication:
|
||||
|
||||
**Daemon to Server:**
|
||||
- `machine-alive` - 20-second heartbeat
|
||||
- `machine-update-metadata` - static machine info changes
|
||||
- `machine-update-state` - daemon status changes
|
||||
|
||||
**Server to Daemon:**
|
||||
- `rpc-request` with methods:
|
||||
- `spawn-happy-session` - spawn new session
|
||||
- `stop-session` - stop session by ID
|
||||
- `stop-daemon` - request shutdown
|
||||
|
||||
All data is plain JSON over TLS; authentication is `CLI_API_TOKEN` (no end-to-end encryption).
|
||||
|
||||
## 7. Process Discovery and Cleanup
|
||||
|
||||
### Doctor Command
|
||||
|
||||
@@ -125,60 +276,27 @@ Local HTTP server (127.0.0.1 only) provides:
|
||||
- Waits 1 second
|
||||
- Sends SIGKILL if still alive
|
||||
|
||||
## 5. State Persistence
|
||||
## 8. Integration Testing
|
||||
|
||||
### daemon.state.json
|
||||
```json
|
||||
{
|
||||
"pid": 12345,
|
||||
"httpPort": 50097,
|
||||
"startTime": "8/24/2025, 6:46:22 PM",
|
||||
"startedWithCliVersion": "0.9.0-6",
|
||||
"lastHeartbeat": "8/24/2025, 6:47:22 PM",
|
||||
"daemonLogPath": "/path/to/daemon.log"
|
||||
}
|
||||
```
|
||||
### Test Environment
|
||||
- Requires `.env.integration-test`
|
||||
- Uses local hapi-server (http://localhost:3006)
|
||||
- Separate `~/.hapi-dev-test` home directory
|
||||
|
||||
### Lock File
|
||||
- Created with O_EXCL flag for atomic acquisition
|
||||
- Contains PID for debugging
|
||||
- Prevents multiple daemon instances
|
||||
- Cleaned up on graceful shutdown
|
||||
|
||||
## 6. WebSocket Communication
|
||||
|
||||
`ApiMachineClient` handles bidirectional communication:
|
||||
- Daemon to Server: machine-alive, machine-update-metadata, machine-update-state
|
||||
- Server to Daemon: rpc-request (spawn-happy-session, stop-session, requestShutdown)
|
||||
- All data is plain JSON over TLS; authentication is `CLI_API_TOKEN` (no end-to-end encryption)
|
||||
|
||||
## 7. Integration Testing Challenges
|
||||
|
||||
Version mismatch test simulates npm upgrade:
|
||||
- Test modifies package.json, rebuilds with new version
|
||||
- Daemon's compiled version != package.json on disk
|
||||
- Critical timing: heartbeat interval must exceed rebuild time
|
||||
- pkgroll doesn't update compiled imports, must use full yarn build
|
||||
|
||||
# Improvements
|
||||
|
||||
I do not like how
|
||||
|
||||
- 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
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
### Key Test Scenarios
|
||||
- Session listing, spawning, stopping
|
||||
- External session webhook tracking
|
||||
- Graceful SIGTERM/SIGKILL shutdown
|
||||
- Multiple daemon prevention
|
||||
- Version mismatch detection
|
||||
- Directory creation approval flow
|
||||
- Concurrent session stress tests
|
||||
|
||||
---
|
||||
|
||||
# Machine Sync Architecture - Separated Metadata & Daemon State
|
||||
|
||||
> Direct-connect note: the “server” is `hapi-server`, payloads are plain JSON (no base64/encryption),
|
||||
> 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`).
|
||||
|
||||
## Data Structure (Similar to Session's metadata + agentState)
|
||||
@@ -188,9 +306,10 @@ I do not like how
|
||||
interface MachineMetadata {
|
||||
host: string; // hostname
|
||||
platform: string; // darwin, linux, win32
|
||||
happyCliVersion: string;
|
||||
homeDir: string;
|
||||
happyCliVersion: string;
|
||||
homeDir: string;
|
||||
happyHomeDir: string;
|
||||
happyLibDir: string; // runtime path
|
||||
}
|
||||
|
||||
// Dynamic daemon state (frequently updated)
|
||||
@@ -200,7 +319,7 @@ interface DaemonState {
|
||||
httpPort?: number;
|
||||
startedAt?: number;
|
||||
shutdownRequestedAt?: number;
|
||||
shutdownSource?: 'mobile-app' | 'cli' | 'os-signal' | 'unknown';
|
||||
shutdownSource?: 'hapi-app' | 'hapi-cli' | 'os-signal' | 'exception';
|
||||
}
|
||||
```
|
||||
|
||||
@@ -222,7 +341,8 @@ Checks if machine ID exists in settings:
|
||||
"platform": "darwin",
|
||||
"happyCliVersion": "1.0.0",
|
||||
"homeDir": "/Users/john",
|
||||
"happyHomeDir": "/Users/john/.hapi"
|
||||
"happyHomeDir": "/Users/john/.hapi",
|
||||
"happyLibDir": "/usr/local/lib/node_modules/hapi"
|
||||
},
|
||||
"daemonState": {
|
||||
"status": "running",
|
||||
@@ -287,7 +407,7 @@ socket.emit('machine-update-state', {
|
||||
"httpPort": 8080,
|
||||
"startedAt": 1703001234567,
|
||||
"shutdownRequestedAt": 1703001244567,
|
||||
"shutdownSource": "mobile-app"
|
||||
"shutdownSource": "hapi-app"
|
||||
},
|
||||
"expectedVersion": 1
|
||||
}, callback)
|
||||
@@ -395,7 +515,7 @@ Authorization: Bearer <CLI_API_TOKEN>
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Separation of Concerns**:
|
||||
1. **Separation of Concerns**:
|
||||
- `metadata`: Static machine info (host, platform, versions)
|
||||
- `daemonState`: Dynamic runtime state (status, pid, ports)
|
||||
|
||||
@@ -411,3 +531,18 @@ Authorization: Bearer <CLI_API_TOKEN>
|
||||
- Clients only receive updates for fields that changed
|
||||
|
||||
5. **RPC Pattern**: Machine-scoped RPC methods prefixed with machineId (like sessions)
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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
|
||||
Reference in New Issue
Block a user