import * as plugins from '../plugins.js'; import * as paths from '../paths.js'; import { toProcessId } from '../shared/protocol/id.js'; import type { ProcessId } from '../shared/protocol/id.js'; import { ProcessManager } from './processmanager.js'; import type { IpcMethodMap, RequestForMethod, ResponseForMethod, DaemonStatusResponse, HeartbeatResponse, } from '../shared/protocol/ipc.types.js'; /** * Central daemon server that manages all TSPM processes */ export class TspmDaemon { private tspmInstance: ProcessManager; private ipcServer: plugins.smartipc.IpcServer; private startTime: number; private isShuttingDown: boolean = false; private socketPath: string; private heartbeatInterval: NodeJS.Timeout | null = null; private daemonPidFile: string; private version: string; constructor() { this.tspmInstance = new ProcessManager(); this.socketPath = plugins.path.join(paths.tspmDir, 'tspm.sock'); this.daemonPidFile = plugins.path.join(paths.tspmDir, 'daemon.pid'); this.startTime = Date.now(); // Determine daemon version from package metadata try { const proj = new plugins.projectinfo.ProjectInfo(paths.packageDir); this.version = proj.npm.version || 'unknown'; } catch { this.version = 'unknown'; } } /** * Start the daemon server */ public async start(): Promise { console.log('Starting TSPM daemon...'); // Ensure the TSPM directory exists const fs = await import('fs/promises'); await fs.mkdir(paths.tspmDir, { recursive: true }); // Check if another daemon is already running if (await this.isDaemonRunning()) { throw new Error('Another TSPM daemon instance is already running'); } // Initialize IPC server this.ipcServer = plugins.smartipc.SmartIpc.createServer({ id: 'tspm-daemon', socketPath: this.socketPath, autoCleanupSocketFile: true, // Clean up stale sockets socketMode: 0o600, // Set proper permissions heartbeat: true, heartbeatInterval: 5000, heartbeatTimeout: 20000, heartbeatInitialGracePeriodMs: 10000, // Grace period for startup heartbeatThrowOnTimeout: false, // Don't throw, emit events instead }); // Debug hooks for connection troubleshooting this.ipcServer.on('clientConnect', (clientId: string) => { console.log(`[IPC] client connected: ${clientId}`); }); this.ipcServer.on('clientDisconnect', (clientId: string) => { console.log(`[IPC] client disconnected: ${clientId}`); }); this.ipcServer.on('error', (err: any) => { console.error('[IPC] server error:', err?.message || err); }); // Register message handlers this.registerHandlers(); // Start the IPC server and wait until ready to accept connections await this.ipcServer.start({ readyWhen: 'accepting' }); // Write PID file await this.writePidFile(); // Start heartbeat monitoring this.startHeartbeatMonitoring(); // Load existing process configurations await this.tspmInstance.loadProcessConfigs(); await this.tspmInstance.loadDesiredStates(); // Set up log publishing this.tspmInstance.on('process:log', ({ processId, log }) => { // Publish to topic for this process const topic = `logs.${processId}`; // Deliver only to subscribed clients if (this.ipcServer) { try { const topicIndex = (this.ipcServer as any).topicIndex as Map> | undefined; const subscribers = topicIndex?.get(topic); if (subscribers && subscribers.size > 0) { // Send directly to subscribers for this topic for (const clientId of subscribers) { this.ipcServer .sendToClient(clientId, `topic:${topic}`, log) .catch((err: any) => { // Surface but don't fail the loop console.error('[IPC] sendToClient error:', err?.message || err); }); } } } catch (err: any) { console.error('[IPC] Topic delivery error:', err?.message || err); } } }); // Set up graceful shutdown handlers this.setupShutdownHandlers(); // Start processes that should be online per desired state await this.tspmInstance.startDesired(); console.log(`TSPM daemon started successfully on ${this.socketPath}`); console.log(`PID: ${process.pid}`); } /** * Register all IPC message handlers */ private registerHandlers(): void { // Process management handlers this.ipcServer.onMessage( 'start', async (request: RequestForMethod<'start'>) => { try { await this.tspmInstance.setDesiredState(request.config.id, 'online'); await this.tspmInstance.start(request.config); const processInfo = this.tspmInstance.processInfo.get( request.config.id, ); return { processId: request.config.id, pid: processInfo?.pid, status: processInfo?.status || 'stopped', }; } catch (error) { throw new Error(`Failed to start process: ${error.message}`); } }, ); // Start by id (resolve config on server) this.ipcServer.onMessage( 'startById', async (request: RequestForMethod<'startById'>) => { try { const id = toProcessId(request.id); let config = this.tspmInstance.processConfigs.get(id); if (!config) { // Try to reload configs if not found (handles races or stale state) await this.tspmInstance.loadProcessConfigs(); config = this.tspmInstance.processConfigs.get(id) || null as any; } if (!config) { throw new Error(`Process ${id} not found`); } await this.tspmInstance.setDesiredState(id, 'online'); await this.tspmInstance.start(config); const processInfo = this.tspmInstance.processInfo.get(id); return { processId: id, pid: processInfo?.pid, status: processInfo?.status || 'stopped', }; } catch (error) { throw new Error(`Failed to start process: ${error.message}`); } }, ); this.ipcServer.onMessage( 'stop', async (request: RequestForMethod<'stop'>) => { try { const id = toProcessId(request.id); await this.tspmInstance.setDesiredState(id, 'stopped'); await this.tspmInstance.stop(id); return { success: true, message: `Process ${id} stopped successfully`, }; } catch (error) { throw new Error(`Failed to stop process: ${error.message}`); } }, ); this.ipcServer.onMessage( 'restart', async (request: RequestForMethod<'restart'>) => { try { const id = toProcessId(request.id); await this.tspmInstance.setDesiredState(id, 'online'); await this.tspmInstance.restart(id); const processInfo = this.tspmInstance.processInfo.get(id); return { processId: id, pid: processInfo?.pid, status: processInfo?.status || 'stopped', }; } catch (error) { throw new Error(`Failed to restart process: ${error.message}`); } }, ); this.ipcServer.onMessage( 'delete', async (request: RequestForMethod<'delete'>) => { try { const id = toProcessId(request.id); // Ensure desired state reflects stopped before deletion await this.tspmInstance.setDesiredState(id, 'stopped'); await this.tspmInstance.delete(id); return { success: true, message: `Process ${id} deleted successfully`, }; } catch (error) { throw new Error(`Failed to delete process: ${error.message}`); } }, ); // Query handlers this.ipcServer.onMessage( 'add', async (request: RequestForMethod<'add'>) => { try { const id = await this.tspmInstance.add(request.config as any); const config = this.tspmInstance.processConfigs.get(id)!; return { id, config }; } catch (error) { throw new Error(`Failed to add process: ${error.message}`); } }, ); this.ipcServer.onMessage( 'update', async (request: RequestForMethod<'update'>) => { try { const id = toProcessId(request.id); const updated = await this.tspmInstance.update(id, request.updates as any); return { id, config: updated }; } catch (error) { throw new Error(`Failed to update process: ${error.message}`); } }, ); // Note: 'remove' is only a CLI alias. Daemon exposes 'delete' only. this.ipcServer.onMessage( 'list', async (request: RequestForMethod<'list'>) => { const processes = await this.tspmInstance.list(); return { processes }; }, ); this.ipcServer.onMessage( 'describe', async (request: RequestForMethod<'describe'>) => { const id = toProcessId(request.id); const result = await this.tspmInstance.describe(id); if (!result) { throw new Error(`Process ${id} not found`); } // Return correctly shaped response return { processInfo: result.info, config: result.config, }; }, ); this.ipcServer.onMessage( 'getLogs', async (request: RequestForMethod<'getLogs'>) => { const id = toProcessId(request.id); const logs = await this.tspmInstance.getLogs(id, request.lines); return { logs }; }, ); // Stream backlog logs and let client subscribe to live topic separately this.ipcServer.onMessage( 'logs:subscribe', async ( request: RequestForMethod<'logs:subscribe'>, clientId: string, ) => { const id = toProcessId(request.id); // Determine backlog set const allLogs = await this.tspmInstance.getLogs(id); let filtered = allLogs; if (request.types && request.types.length) { filtered = filtered.filter((l) => request.types!.includes(l.type)); } if (request.sinceTime && request.sinceTime > 0) { filtered = filtered.filter( (l) => new Date(l.timestamp).getTime() >= request.sinceTime!, ); } const lines = request.lines && request.lines > 0 ? request.lines : 0; if (lines > 0 && filtered.length > lines) { filtered = filtered.slice(-lines); } // Send backlog entries directly to the requesting client as topic messages // in small batches to avoid overwhelming the transport or client. const chunkSize = 200; for (let i = 0; i < filtered.length; i += chunkSize) { const chunk = filtered.slice(i, i + chunkSize); await Promise.allSettled( chunk.map((entry) => this.ipcServer.sendToClient( clientId, `topic:logs.backlog.${id}`, { ...entry, timestamp: new Date(entry.timestamp).getTime(), }, ), ), ); // Yield a bit between chunks await new Promise((r) => setTimeout(r, 5)); } return { ok: true } as any; }, ); // Inspect subscribers for a process log topic this.ipcServer.onMessage( 'logs:subscribers', async ( request: RequestForMethod<'logs:subscribers'>, clientId: string, ) => { const id = toProcessId(request.id); const topic = `logs.${id}`; try { const topicIndex = (this.ipcServer as any).topicIndex as Map> | undefined; const subs = Array.from(topicIndex?.get(topic) || []); // Also include the requesting clientId if it has a local handler without subscription return { topic, subscribers: subs, count: subs.length } as any; } catch (err: any) { return { topic, subscribers: [], count: 0 } as any; } }, ); // Resolve target (id:n | name:foo | numeric string) to ProcessId this.ipcServer.onMessage( 'resolveTarget', async (request: RequestForMethod<'resolveTarget'>) => { const raw = String(request.target || '').trim(); if (!raw) { throw new Error('Empty target'); } // id: if (/^id:\s*\d+$/i.test(raw)) { const idNum = raw.split(':')[1].trim(); const id = toProcessId(idNum); const config = this.tspmInstance.processConfigs.get(id); if (!config) throw new Error(`Process ${id} not found`); return { id, name: config.name } as ResponseForMethod<'resolveTarget'>; } // name: