Files
hapi/cli/src/persistence.ts
T
weishu 0c4abcb3d1 feat(codex): share sessions across terminal and web
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.

Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.

Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
2026-09-12 10:59:17 +08:00

261 lines
9.0 KiB
TypeScript

/**
* Minimal persistence functions for HAPI CLI
*
* Handles settings, encryption key, and runner state storage in ~/.hapi/ (or HAPI_HOME override)
*/
import { FileHandle } from 'node:fs/promises'
import { readFile, writeFile, mkdir, open, unlink, rename, chmod } from 'node:fs/promises'
import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
import { withSettingsFileLock } from '@hapi/protocol/settingsFileLock'
import { configuration } from '@/configuration'
import { isProcessAlive } from '@/utils/process';
interface Settings {
// This ID is used as the actual database ID on the server
// All machine operations use this ID
machineId?: string
machineIdConfirmedByServer?: boolean
runnerAutoStartWhenRunningHappy?: boolean
cliApiToken?: string
// API URL for server connections (priority: env HAPI_API_URL > this > default)
apiUrl?: string
// Extra headers for CLI -> hub requests (priority: env HAPI_EXTRA_HEADERS_JSON > this)
extraHeaders?: unknown
// Legacy field name (for migration, read-only)
serverUrl?: string
}
const defaultSettings: Settings = {}
/**
* Runner state persisted locally (different from API RunnerState)
* This is written to disk by the runner to track its local process state
*/
export interface RunnerLocallyPersistedState {
sharedCodexRuntime?: boolean;
pid: number;
httpPort: number;
startTime: string;
startedWithCliVersion: string;
startedWithCliMtimeMs?: number;
startedWithApiUrl?: string;
startedWithMachineId?: string;
startedWithCliApiTokenHash?: string;
// SHA-256 of canonicalized extra headers. Raw header values must never be persisted here.
startedWithExtraHeadersHash?: string;
/**
* Original process.argv.slice(2) of the runner process at start time, e.g.
* ['runner', 'start-sync', '--workspace-root', '/home/user/code'].
* Used by the self-restart handoff so the replacement runner inherits the
* same workspace-root / flag configuration instead of starting with defaults.
*/
startedWithArgv?: string[];
lastHeartbeat?: string;
runnerLogPath?: string;
/**
* Snapshot of HAPI_DISABLE_VERSION_HANDOFF=1 at the time this runner
* started. Lets a later `hapi runner start` invocation (from a shell where
* the env var is NOT set, e.g. operator's interactive terminal vs a
* systemd service that owns supervision) honour the running runner's
* opt-out instead of treating mtime drift as a reason to kill it.
*
* Codex review #814 [Major]: env-only check in controlClient meant the
* supervised use case (env set on service only) would still trigger a
* mid-rebuild stop. Persisting this fixes that.
*/
startedWithVersionHandoffDisabled?: boolean;
}
export async function readSettings(): Promise<Settings> {
if (!existsSync(configuration.settingsFile)) {
return { ...defaultSettings }
}
try {
const content = await readFile(configuration.settingsFile, 'utf8')
return JSON.parse(content)
} catch {
return { ...defaultSettings }
}
}
/** Strict read for locked updates — never treat corrupt/unreadable files as empty. */
async function readSettingsForUpdate(): Promise<Settings> {
if (!existsSync(configuration.settingsFile)) {
return { ...defaultSettings }
}
const content = await readFile(configuration.settingsFile, 'utf8')
const parsed: unknown = JSON.parse(content)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`Invalid settings file: ${configuration.settingsFile}`)
}
return parsed as Settings
}
export async function writeSettings(settings: Settings): Promise<void> {
if (!existsSync(configuration.happyHomeDir)) {
await mkdir(configuration.happyHomeDir, { recursive: true, mode: 0o700 })
}
await chmod(configuration.happyHomeDir, 0o700).catch(() => {})
await withSettingsFileLock(configuration.settingsFile, async () => {
const tmpFile = configuration.settingsFile + '.tmp'
await writeFile(tmpFile, JSON.stringify(settings, null, 2), { mode: 0o600 })
await chmod(tmpFile, 0o600).catch(() => {})
await rename(tmpFile, configuration.settingsFile)
await chmod(configuration.settingsFile, 0o600).catch(() => {})
})
}
/**
* Atomically update settings with multi-process safety via file locking
* @param updater Function that takes current settings and returns updated settings
* @returns The updated settings
*/
export async function updateSettings(
updater: (current: Settings) => Settings | Promise<Settings>
): Promise<Settings> {
if (!existsSync(configuration.happyHomeDir)) {
await mkdir(configuration.happyHomeDir, { recursive: true, mode: 0o700 })
}
await chmod(configuration.happyHomeDir, 0o700).catch(() => {})
return withSettingsFileLock(configuration.settingsFile, async () => {
const current = await readSettingsForUpdate()
const updated = await updater(current)
const tmpFile = configuration.settingsFile + '.tmp'
await writeFile(tmpFile, JSON.stringify(updated, null, 2), { mode: 0o600 })
await chmod(tmpFile, 0o600).catch(() => {})
await rename(tmpFile, configuration.settingsFile)
await chmod(configuration.settingsFile, 0o600).catch(() => {})
return updated
})
}
//
// Authentication
//
export async function writeCredentialsDataKey(credentials: { publicKey: Uint8Array, machineKey: Uint8Array, token: string }): Promise<void> {
if (!existsSync(configuration.happyHomeDir)) {
await mkdir(configuration.happyHomeDir, { recursive: true })
}
await writeFile(configuration.privateKeyFile, JSON.stringify({
encryption: { publicKey: Buffer.from(credentials.publicKey).toString('base64'), machineKey: Buffer.from(credentials.machineKey).toString('base64') },
token: credentials.token
}, null, 2));
}
export async function clearCredentials(): Promise<void> {
if (existsSync(configuration.privateKeyFile)) {
await unlink(configuration.privateKeyFile);
}
}
export async function clearMachineId(): Promise<void> {
await updateSettings(settings => ({
...settings,
machineId: undefined
}));
}
/**
* Read runner state from local file
*/
export async function readRunnerState(): Promise<RunnerLocallyPersistedState | null> {
try {
if (!existsSync(configuration.runnerStateFile)) {
return null;
}
const content = await readFile(configuration.runnerStateFile, 'utf-8');
return JSON.parse(content) as RunnerLocallyPersistedState;
} catch (error) {
// State corrupted somehow :(
console.error(`[PERSISTENCE] Runner state file corrupted: ${configuration.runnerStateFile}`, error);
return null;
}
}
/**
* Write runner state to local file (synchronously for atomic operation)
*/
export function writeRunnerState(state: RunnerLocallyPersistedState): void {
writeFileSync(configuration.runnerStateFile, JSON.stringify(state, null, 2), 'utf-8');
}
/**
* Clean up runner state file and lock file
*/
export async function clearRunnerState(): Promise<void> {
if (existsSync(configuration.runnerStateFile)) {
await unlink(configuration.runnerStateFile);
}
// Also clean up lock file if it exists (for stale cleanup)
if (existsSync(configuration.runnerLockFile)) {
try {
await unlink(configuration.runnerLockFile);
} catch {
// Lock file might be held by running runner, ignore error
}
}
}
/**
* 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 acquireRunnerLock(
maxAttempts: number = 5,
delayIncrementMs: number = 200
): Promise<FileHandle | null> {
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.runnerLockFile, 'wx');
// Write PID to lock file for debugging
await fileHandle.writeFile(String(process.pid));
return fileHandle;
} catch (error: any) {
if (error.code === 'EEXIST') {
// Lock file exists, check if process is still running
try {
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.runnerLockFile);
continue; // Retry acquisition
}
}
} catch {
// Can't read lock file, might be corrupted
}
}
if (attempt === maxAttempts) {
return null;
}
const delayMs = attempt * delayIncrementMs;
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return null;
}
/**
* Release runner lock by closing handle and deleting lock file
*/
export async function releaseRunnerLock(lockHandle: FileHandle): Promise<void> {
try {
await lockHandle.close();
} catch { }
try {
if (existsSync(configuration.runnerLockFile)) {
unlinkSync(configuration.runnerLockFile);
}
} catch { }
}