diff --git a/changelog.md b/changelog.md index e638fc9..2475ebb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-08-29 - 4.4.0 - feat(daemon) +Persist desired process states and add daemon restart command + +- Persist desired process states: ProcessManager now stores desiredStates to user storage (desiredStates key) and reloads them on startup. +- Start/stop operations update desired state: IPC handlers in the daemon now set desired state when processes are started, stopped, restarted or when batch start/stop is invoked. +- Resume desired state on daemon start: Daemon loads desired states and calls startDesired() to bring processes to their desired 'online' state after startup. +- Remove desired state on deletion/reset: Deleting a process or resetting clears its desired state; reset clears all desired states as well. +- CLI: Added 'tspm daemon restart' — stops the daemon (gracefully) and restarts it in the foreground for the current session, with checks and informative output. + ## 2025-08-29 - 4.3.1 - fix(daemon) Fix daemon describe handler to return correct process info and config; bump @push.rocks/smartipc to ^2.2.2 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index d7863e3..363d4e3 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tspm', - version: '4.3.1', + version: '4.4.0', description: 'a no fuzz process manager' } diff --git a/ts/cli/commands/daemon/index.ts b/ts/cli/commands/daemon/index.ts index 4fa3bd9..e00ca2a 100644 --- a/ts/cli/commands/daemon/index.ts +++ b/ts/cli/commands/daemon/index.ts @@ -80,6 +80,48 @@ export function registerDaemonCommand(smartcli: plugins.smartcli.Smartcli) { } break; + case 'restart': + try { + console.log('Restarting TSPM daemon...'); + await tspmIpcClient.stopDaemon(true); + + // Reuse the manual start logic from 'start' + const statusAfterStop = await tspmIpcClient.getDaemonStatus(); + if (statusAfterStop) { + console.warn('Daemon still appears to be running; proceeding to start anyway.'); + } + + console.log('Starting TSPM daemon manually...'); + const { spawn } = await import('child_process'); + const daemonScript = plugins.path.join( + paths.packageDir, + 'dist_ts', + 'daemon.js', + ); + const daemonProcess = spawn(process.execPath, [daemonScript], { + detached: true, + stdio: process.env.TSPM_DEBUG === 'true' ? 'inherit' : 'ignore', + env: { ...process.env, TSPM_DAEMON_MODE: 'true' }, + }); + daemonProcess.unref(); + console.log(`Started daemon with PID: ${daemonProcess.pid}`); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + const newStatus = await tspmIpcClient.getDaemonStatus(); + if (newStatus) { + console.log('✓ TSPM daemon restarted successfully'); + console.log(` PID: ${newStatus.pid}`); + } else { + console.warn('\n⚠️ Warning: Daemon restart attempted but status is unavailable.'); + } + + await tspmIpcClient.disconnect(); + } catch (error) { + console.error('Error restarting daemon:', (error as any).message || String(error)); + process.exit(1); + } + break; + case 'start-service': // This is called by systemd - start the daemon directly console.log('Starting TSPM daemon for systemd service...'); @@ -135,6 +177,7 @@ export function registerDaemonCommand(smartcli: plugins.smartcli.Smartcli) { console.log('Usage: tspm daemon '); console.log('\nCommands:'); console.log(' start Start the TSPM daemon'); + console.log(' restart Restart the TSPM daemon'); console.log(' stop Stop the TSPM daemon'); console.log(' status Show daemon status'); break; diff --git a/ts/daemon/processmanager.ts b/ts/daemon/processmanager.ts index 488feaf..969988b 100644 --- a/ts/daemon/processmanager.ts +++ b/ts/daemon/processmanager.ts @@ -25,6 +25,8 @@ export class ProcessManager extends EventEmitter { public processInfo: Map = new Map(); private config: TspmConfig; private configStorageKey = 'processes'; + private desiredStateStorageKey = 'desiredStates'; + private desiredStates: Map = new Map(); private logger: Logger; constructor() { @@ -32,6 +34,7 @@ export class ProcessManager extends EventEmitter { this.logger = new Logger('Tspm'); this.config = new TspmConfig(); this.loadProcessConfigs(); + this.loadDesiredStates(); } /** @@ -67,6 +70,7 @@ export class ProcessManager extends EventEmitter { }); await this.saveProcessConfigs(); + await this.setDesiredState(config.id, 'stopped'); return config.id; } @@ -279,6 +283,7 @@ export class ProcessManager extends EventEmitter { // Save updated configs await this.saveProcessConfigs(); + await this.removeDesiredState(id); this.logger.info(`Successfully deleted process with id '${id}'`); } catch (error: Error | unknown) { @@ -288,6 +293,7 @@ export class ProcessManager extends EventEmitter { this.processConfigs.delete(id); this.processInfo.delete(id); await this.saveProcessConfigs(); + await this.removeDesiredState(id); this.logger.info( `Successfully deleted process with id '${id}' after stopping failure`, @@ -415,6 +421,80 @@ export class ProcessManager extends EventEmitter { } } + // === Desired state persistence === + private async saveDesiredStates(): Promise { + try { + const obj: Record = {}; + for (const [id, state] of this.desiredStates.entries()) { + obj[id] = state; + } + await this.config.writeKey( + this.desiredStateStorageKey, + JSON.stringify(obj), + ); + } catch (error: any) { + this.logger.warn( + `Failed to save desired states: ${error?.message || String(error)}`, + ); + } + } + + public async loadDesiredStates(): Promise { + try { + const raw = await this.config.readKey(this.desiredStateStorageKey); + if (raw) { + const obj = JSON.parse(raw) as Record; + this.desiredStates = new Map(Object.entries(obj)); + this.logger.debug( + `Loaded desired states for ${this.desiredStates.size} processes`, + ); + } + } catch (error: any) { + this.logger.warn( + `Failed to load desired states: ${error?.message || String(error)}`, + ); + } + } + + public async setDesiredState( + id: string, + state: IProcessInfo['status'], + ): Promise { + this.desiredStates.set(id, state); + await this.saveDesiredStates(); + } + + public async removeDesiredState(id: string): Promise { + this.desiredStates.delete(id); + await this.saveDesiredStates(); + } + + public async setDesiredStateForAll( + state: IProcessInfo['status'], + ): Promise { + for (const id of this.processConfigs.keys()) { + this.desiredStates.set(id, state); + } + await this.saveDesiredStates(); + } + + public async startDesired(): Promise { + for (const [id, config] of this.processConfigs.entries()) { + const desired = this.desiredStates.get(id); + if (desired === 'online' && !this.processes.has(id)) { + try { + await this.start(config); + } catch (e) { + this.logger.warn( + `Failed to start desired process ${id}: ${ + (e as Error)?.message || String(e) + }`, + ); + } + } + } + } + /** * Load process configurations from config storage */ @@ -499,10 +579,12 @@ export class ProcessManager extends EventEmitter { this.processes.clear(); this.processInfo.clear(); this.processConfigs.clear(); + this.desiredStates.clear(); // Remove persisted configs try { await this.config.deleteKey(this.configStorageKey); + await this.config.deleteKey(this.desiredStateStorageKey).catch(() => {}); this.logger.debug('Cleared persisted process configurations'); } catch (error) { // Fallback: write empty list if deleteKey fails for any reason diff --git a/ts/daemon/tspm.daemon.ts b/ts/daemon/tspm.daemon.ts index f7ea3b8..ea3b34d 100644 --- a/ts/daemon/tspm.daemon.ts +++ b/ts/daemon/tspm.daemon.ts @@ -81,6 +81,7 @@ export class TspmDaemon { // Load existing process configurations await this.tspmInstance.loadProcessConfigs(); + await this.tspmInstance.loadDesiredStates(); // Set up log publishing this.tspmInstance.on('process:log', ({ processId, log }) => { @@ -95,6 +96,9 @@ export class TspmDaemon { // 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}`); } @@ -108,6 +112,7 @@ export class TspmDaemon { '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, @@ -127,6 +132,7 @@ export class TspmDaemon { 'stop', async (request: RequestForMethod<'stop'>) => { try { + await this.tspmInstance.setDesiredState(request.id, 'stopped'); await this.tspmInstance.stop(request.id); return { success: true, @@ -142,6 +148,7 @@ export class TspmDaemon { 'restart', async (request: RequestForMethod<'restart'>) => { try { + await this.tspmInstance.setDesiredState(request.id, 'online'); await this.tspmInstance.restart(request.id); const processInfo = this.tspmInstance.processInfo.get(request.id); return { @@ -234,6 +241,7 @@ export class TspmDaemon { const started: string[] = []; const failed: Array<{ id: string; error: string }> = []; + await this.tspmInstance.setDesiredStateForAll('online'); await this.tspmInstance.startAll(); // Get status of all processes @@ -255,6 +263,7 @@ export class TspmDaemon { const stopped: string[] = []; const failed: Array<{ id: string; error: string }> = []; + await this.tspmInstance.setDesiredStateForAll('stopped'); await this.tspmInstance.stopAll(); // Get status of all processes