import { loadRuntimeConfig } from './config-loader.mjs'; import { assertRuntimeAvailable, clearRuntimeStopRequest, isProcessAlive, readRuntimeStopRequest, readRuntimeState, runtimeStatePath, stopRuntimeProcess, writeRuntimeState, } from './runtime-state.mjs'; let activeRuntime = null; function mergeComponents(current = {}, patch = {}) { const output = { ...current }; for (const [name, value] of Object.entries(patch)) { output[name] = { ...(current[name] || {}), ...value, updatedAt: new Date().toISOString() }; } return output; } export async function startRuntime(options = {}) { if (activeRuntime) return activeRuntime; const loaded = await loadRuntimeConfig(options); const statePath = runtimeStatePath(options.statePath); assertRuntimeAvailable(statePath); clearRuntimeStopRequest(statePath); let state = writeRuntimeState({ pid: process.pid, status: 'starting', mode: loaded.mode, transport: loaded.mode === 'enterprise' ? 'server_relay' : 'local_polling', workspaceRoot: loaded.workspaceRoot, configPath: loaded.configPath, startedAt: new Date().toISOString(), components: {}, }, statePath); const updateComponents = patch => { state = writeRuntimeState({ ...state, components: mergeComponents(state.components, patch), }, statePath); }; let dashboard = null; if (options.dashboard !== false && loaded.config.dashboard.enabled) { const { startDashboard } = await import('./processor-bridge.mjs'); dashboard = await startDashboard(loaded.config.dashboard.port); updateComponents({ dashboard: { status: 'running', port: loaded.config.dashboard.port } }); } let controller; let portraitQueueWorker = null; if (loaded.mode === 'enterprise') { const { EnterpriseRelayRuntime } = await import('./enterprise-relay-client.mjs'); const { PortraitQueueWorker } = await import('./portrait-queue-worker.mjs'); controller = new EnterpriseRelayRuntime({ config: loaded.config.enterprise.relay, guid: options.guid || '', onState: updateComponents, }); portraitQueueWorker = new PortraitQueueWorker({ onState: updateComponents }); } else { const { PersonalRuntime } = await import('./personal-runtime.mjs'); controller = new PersonalRuntime({ config: loaded.config.personal.polling, workspaceRoot: loaded.workspaceRoot, onState: updateComponents, }); } if (options.dryRun !== true) { controller.start(); portraitQueueWorker?.start(); } state = writeRuntimeState({ ...state, status: options.dryRun ? 'ready' : 'running' }, statePath); let stopping = false; let stopWatcher = null; const stop = async reason => { if (stopping) return; stopping = true; if (stopWatcher) clearInterval(stopWatcher); clearRuntimeStopRequest(statePath); state = writeRuntimeState({ ...state, status: 'stopping', stopReason: reason || 'requested' }, statePath); await portraitQueueWorker?.stop(); await controller.stop(); if (dashboard?.server) { await new Promise(resolve => dashboard.server.close(resolve)); } state = writeRuntimeState({ ...state, status: 'stopped', stoppedAt: new Date().toISOString(), }, statePath); activeRuntime = null; }; stopWatcher = setInterval(() => { const request = readRuntimeStopRequest(statePath); if (!request.requestedAt) return; if (request.targetPid && Number(request.targetPid) !== process.pid) return; void stop('external-stop'); }, 500); activeRuntime = { mode: loaded.mode, transport: state.transport, config: loaded.config, configPath: loaded.configPath, workspaceRoot: loaded.workspaceRoot, statePath, controller, portraitQueueWorker, dashboard, stop, }; return activeRuntime; } function parseArgs(argv) { const command = argv.find(arg => !arg.startsWith('-')) || 'start'; const modeArg = argv.find(arg => arg.startsWith('--mode=')); const guidArg = argv.find(arg => arg.startsWith('--guid=')); return { command, forceMode: modeArg ? modeArg.slice('--mode='.length) : '', guid: guidArg ? guidArg.slice('--guid='.length) : '', dashboard: !argv.includes('--no-dashboard'), dryRun: argv.includes('--dry-run'), }; } export async function runCli(argv = process.argv.slice(2)) { const options = parseArgs(argv); if (options.command === 'status') { const state = readRuntimeState(); process.stdout.write(`${JSON.stringify({ ...state, alive: isProcessAlive(state.pid) }, null, 2)}\n`); return; } if (options.command === 'stop') { const result = stopRuntimeProcess(); let state = readRuntimeState(); for (let attempt = 0; result.requested && attempt < 50; attempt += 1) { await new Promise(resolve => setTimeout(resolve, 100)); state = readRuntimeState(); if (state.status === 'stopped' || !isProcessAlive(state.pid)) break; } process.stdout.write(`${JSON.stringify({ ...result, status: state.status || 'unknown' }, null, 2)}\n`); return; } if (options.command !== 'start') throw new Error('Use start, status, or stop.'); if (!options.dryRun) { const current = readRuntimeState(); if (['starting', 'running'].includes(current.status) && isProcessAlive(current.pid)) { process.stdout.write(`${JSON.stringify({ ...current, alive: true, alreadyRunning: true }, null, 2)}\n`); return; } } const runtime = await startRuntime(options); process.stdout.write(`${JSON.stringify({ status: options.dryRun ? 'ready' : 'running', pid: process.pid, mode: runtime.mode, transport: runtime.transport, dashboard: runtime.dashboard?.url || null, statePath: runtime.statePath, }, null, 2)}\n`); if (options.dryRun) await runtime.stop('dry-run'); const shutdown = signal => runtime.stop(signal).finally(() => process.exit(0)); process.once('SIGINT', () => shutdown('SIGINT')); process.once('SIGTERM', () => shutdown('SIGTERM')); } export { readRuntimeState };