From 6f14033d9b66d3abfd1c5afed1c425b1a1db8ac5 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 31 Aug 2025 08:06:03 +0000 Subject: [PATCH] feat(cli): Add stats CLI command and daemon stats aggregation; fix process manager & wrapper state handling --- changelog.md | 10 +++++ ts/00_commitinfo_data.ts | 2 +- ts/cli/commands/default.ts | 1 + ts/cli/commands/stats.ts | 66 +++++++++++++++++++++++++++++++++ ts/cli/index.ts | 2 + ts/daemon/processmanager.ts | 19 ++++++++-- ts/daemon/processwrapper.ts | 12 +++++- ts/daemon/tspm.daemon.ts | 47 ++++++++++++++++++++++- ts/shared/protocol/ipc.types.ts | 14 +++++++ 9 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 ts/cli/commands/stats.ts diff --git a/changelog.md b/changelog.md index 4bae3ea..c7c8e29 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-08-31 - 5.7.0 - feat(cli) +Add 'stats' CLI command and daemon stats aggregation; fix process manager & wrapper state handling + +- Add new 'stats' CLI command to show daemon + process statistics (memory, CPU, uptime, logs in memory, paths, configs) and include it in the default help output +- Implement daemon-side aggregation for logs-in-memory, per-process log counts/bytes, and expose tspmDir/socket/pidFile and config counts in daemon:status +- Enhance startById handler to detect already-running monitors and return current status/pid instead of attempting to restart +- Improve ProcessManager start/restart/stop behavior: if an existing monitor exists but is not running, restart it; ensure PID and status are updated consistently (clear PID on stop) +- Fix ProcessWrapper lifecycle handling: clear internal process reference on exit, improve isRunning() and getPid() semantics to reflect actual runtime state +- Update IPC types to include optional metadata fields (paths, configs, logsInMemory) in DaemonStatusResponse + ## 2025-08-31 - 5.6.2 - fix(processmanager) Improve process lifecycle handling and cleanup in daemon, monitors and wrappers diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 9ad8a8b..0552694 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: '5.6.2', + version: '5.7.0', description: 'a no fuzz process manager' } diff --git a/ts/cli/commands/default.ts b/ts/cli/commands/default.ts index 8b266e5..2851156 100644 --- a/ts/cli/commands/default.ts +++ b/ts/cli/commands/default.ts @@ -39,6 +39,7 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) { ); console.log(' daemon stop Stop the daemon'); console.log(' daemon status Show daemon status'); + console.log(' stats Show daemon + process stats'); console.log( '\nUse tspm [command] --help for more information about a command.', ); diff --git a/ts/cli/commands/stats.ts b/ts/cli/commands/stats.ts new file mode 100644 index 0000000..bb399a5 --- /dev/null +++ b/ts/cli/commands/stats.ts @@ -0,0 +1,66 @@ +import * as plugins from '../plugins.js'; +import { tspmIpcClient } from '../../client/tspm.ipcclient.js'; +import type { CliArguments } from '../types.js'; +import { registerIpcCommand } from '../registration/index.js'; +import { pad } from '../helpers/formatting.js'; +import { formatMemory } from '../helpers/memory.js'; + +export function registerStatsCommand(smartcli: plugins.smartcli.Smartcli) { + registerIpcCommand( + smartcli, + 'stats', + async (_argvArg: CliArguments) => { + // Daemon status + const status = await tspmIpcClient.request('daemon:status', {}); + + console.log('TSPM Daemon:'); + console.log('─'.repeat(60)); + console.log(`Version: ${status.version || 'unknown'}`); + console.log(`PID: ${status.pid}`); + console.log(`Uptime: ${Math.floor((status.uptime || 0) / 1000)}s`); + console.log(`Processes: ${status.processCount}`); + if (typeof status.memoryUsage === 'number') { + console.log(`Memory: ${formatMemory(status.memoryUsage)}`); + } + if (typeof status.cpuUsage === 'number') { + console.log(`CPU (user): ${status.cpuUsage.toFixed(3)}s`); + } + if ((status as any).paths) { + const pathsInfo = (status as any).paths as { tspmDir?: string; socketPath?: string; pidFile?: string }; + console.log(`tspmDir: ${pathsInfo.tspmDir || '-'}`); + console.log(`Socket: ${pathsInfo.socketPath || '-'}`); + console.log(`PID File: ${pathsInfo.pidFile || '-'}`); + } + if ((status as any).configs) { + const cfg = (status as any).configs as { processConfigs?: number }; + console.log(`Configs: ${cfg.processConfigs ?? 0}`); + } + if ((status as any).logsInMemory) { + const lm = (status as any).logsInMemory as { totalCount: number; totalBytes: number }; + console.log(`Logs (mem): ${lm.totalCount} entries, ${formatMemory(lm.totalBytes)}`); + } + console.log(''); + + // Process list (reuse list view with CPU column) + const response = await tspmIpcClient.request('list', {}); + const processes = response.processes; + console.log('Process List:'); + console.log('┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐'); + console.log('│ ID │ Name │ Status │ PID │ Memory │ CPU │ Restarts │'); + console.log('├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┼──────────┤'); + for (const proc of processes) { + const statusColor = + proc.status === 'online' ? '\x1b[32m' : proc.status === 'errored' ? '\x1b[31m' : '\x1b[33m'; + const resetColor = '\x1b[0m'; + const cpuStr = typeof proc.cpu === 'number' && isFinite(proc.cpu) ? `${proc.cpu.toFixed(1)}%` : '-'; + const nameDisplay = String(proc.id); // name not carried in IProcessInfo + console.log( + `│ ${pad(String(proc.id), 7)} │ ${pad(nameDisplay, 11)} │ ${statusColor}${pad(proc.status, 9)}${resetColor} │ ${pad((proc.pid || '-').toString(), 9)} │ ${pad(formatMemory(proc.memory), 8)} │ ${pad(cpuStr, 8)} │ ${pad(proc.restarts.toString(), 8)} │`, + ); + } + console.log('└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘'); + }, + { actionLabel: 'get daemon stats' }, + ); +} + diff --git a/ts/cli/index.ts b/ts/cli/index.ts index 0534667..3ffb6fe 100644 --- a/ts/cli/index.ts +++ b/ts/cli/index.ts @@ -20,6 +20,7 @@ import { registerStartAllCommand } from './commands/batch/start-all.js'; import { registerStopAllCommand } from './commands/batch/stop-all.js'; import { registerRestartAllCommand } from './commands/batch/restart-all.js'; import { registerDaemonCommand } from './commands/daemon/index.js'; +import { registerStatsCommand } from './commands/stats.js'; import { registerEnableCommand } from './commands/service/enable.js'; import { registerDisableCommand } from './commands/service/disable.js'; import { registerResetCommand } from './commands/reset.js'; @@ -117,6 +118,7 @@ export const run = async (): Promise => { // Daemon commands registerDaemonCommand(smartcliInstance); + registerStatsCommand(smartcliInstance); // Service commands registerEnableCommand(smartcliInstance); diff --git a/ts/daemon/processmanager.ts b/ts/daemon/processmanager.ts index 1a74166..aad5bac 100644 --- a/ts/daemon/processmanager.ts +++ b/ts/daemon/processmanager.ts @@ -95,6 +95,16 @@ export class ProcessManager extends EventEmitter { // Check if process with this id already exists if (this.processes.has(config.id)) { + const existing = this.processes.get(config.id)!; + // If an existing monitor is present but not running, treat this as a fresh start via restart logic + if (!existing.isRunning()) { + this.logger.info( + `Existing monitor found for id '${config.id}' but not running. Restarting it...`, + ); + await this.restart(config.id); + return; + } + // Already running – surface a meaningful error throw new ValidationError( `Process with id '${config.id}' already exists`, 'ERR_DUPLICATE_PROCESS', @@ -246,7 +256,8 @@ export class ProcessManager extends EventEmitter { try { await monitor.stop(); - this.updateProcessInfo(id, { status: 'stopped' }); + // Ensure status and PID are reflected immediately + this.updateProcessInfo(id, { status: 'stopped', pid: undefined }); this.logger.info(`Successfully stopped process with id '${id}'`); } catch (error: Error | unknown) { const processError = new ProcessError( @@ -430,6 +441,8 @@ export class ProcessManager extends EventEmitter { const pid = monitor.getPid(); if (pid) { info.pid = pid; + } else { + info.pid = undefined; } // Update uptime if available @@ -449,9 +462,7 @@ export class ProcessManager extends EventEmitter { info.restarts = monitor.getRestartCount(); // Update status based on actual running state - if (monitor.isRunning()) { - info.status = 'online'; - } + info.status = monitor.isRunning() ? 'online' : 'stopped'; } } diff --git a/ts/daemon/processwrapper.ts b/ts/daemon/processwrapper.ts index b439351..17ab8b5 100644 --- a/ts/daemon/processwrapper.ts +++ b/ts/daemon/processwrapper.ts @@ -93,6 +93,9 @@ export class ProcessWrapper extends EventEmitter { this.stdoutRemainder = ''; this.stderrRemainder = ''; + // Mark process reference as gone so isRunning() reflects reality + this.process = null; + this.emit('exit', code, signal); }); @@ -269,6 +272,7 @@ export class ProcessWrapper extends EventEmitter { * Get the process ID if running */ public getPid(): number | null { + if (!this.isRunning()) return null; return this.process?.pid || null; } @@ -292,7 +296,13 @@ export class ProcessWrapper extends EventEmitter { * Check if the process is currently running */ public isRunning(): boolean { - return this.process !== null && typeof this.process.exitCode !== 'number'; + if (!this.process) return false; + // In Node, while the child is running: exitCode === null and signalCode === null/undefined + // After it exits: exitCode is a number OR signalCode is a string + const anyProc: any = this.process as any; + const exitCode = anyProc.exitCode; + const signalCode = anyProc.signalCode; + return exitCode === null && (signalCode === null || typeof signalCode === 'undefined'); } /** diff --git a/ts/daemon/tspm.daemon.ts b/ts/daemon/tspm.daemon.ts index 8e13bbd..c505fba 100644 --- a/ts/daemon/tspm.daemon.ts +++ b/ts/daemon/tspm.daemon.ts @@ -10,6 +10,7 @@ import type { DaemonStatusResponse, HeartbeatResponse, } from '../shared/protocol/ipc.types.js'; +import { LogPersistence } from './logpersistence.js'; /** * Central daemon server that manages all TSPM processes @@ -170,7 +171,22 @@ export class TspmDaemon { throw new Error(`Process ${id} not found`); } await this.tspmInstance.setDesiredState(id, 'online'); - await this.tspmInstance.start(config); + const existing = this.tspmInstance.processes.get(id); + if (existing) { + if (existing.isRunning()) { + // Already running; return current status/pid + const runningInfo = this.tspmInstance.processInfo.get(id); + return { + processId: id, + pid: runningInfo?.pid, + status: runningInfo?.status || 'online', + }; + } else { + await this.tspmInstance.restart(id); + } + } else { + await this.tspmInstance.start(config); + } const processInfo = this.tspmInstance.processInfo.get(id); return { processId: id, @@ -501,6 +517,28 @@ export class TspmDaemon { 'daemon:status', async (request: RequestForMethod<'daemon:status'>) => { const memUsage = process.memoryUsage(); + // Aggregate log stats from monitors + let totalLogCount = 0; + let totalLogBytes = 0; + const perProcess: Array<{ id: ProcessId; count: number; bytes: number }> = []; + for (const [id, monitor] of this.tspmInstance.processes.entries()) { + try { + const logs = monitor.getLogs(); + const count = logs.length; + const bytes = LogPersistence.calculateLogMemorySize(logs); + totalLogCount += count; + totalLogBytes += bytes; + perProcess.push({ id, count, bytes }); + } catch {} + } + const pathsInfo = { + tspmDir: paths.tspmDir, + socketPath: this.socketPath, + pidFile: this.daemonPidFile, + }; + const configsInfo = { + processConfigs: this.tspmInstance.processConfigs.size, + }; return { status: 'running', pid: process.pid, @@ -509,6 +547,13 @@ export class TspmDaemon { memoryUsage: memUsage.heapUsed, cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds version: this.version, + logsInMemory: { + totalCount: totalLogCount, + totalBytes: totalLogBytes, + perProcess, + }, + paths: pathsInfo, + configs: configsInfo, }; }, ); diff --git a/ts/shared/protocol/ipc.types.ts b/ts/shared/protocol/ipc.types.ts index 918d171..084039b 100644 --- a/ts/shared/protocol/ipc.types.ts +++ b/ts/shared/protocol/ipc.types.ts @@ -228,6 +228,20 @@ export interface DaemonStatusResponse { memoryUsage?: number; cpuUsage?: number; version?: string; + // Additional metadata (optional) + paths?: { + tspmDir?: string; + socketPath?: string; + pidFile?: string; + }; + configs?: { + processConfigs?: number; + }; + logsInMemory?: { + totalCount: number; + totalBytes: number; + perProcess: Array<{ id: ProcessId; count: number; bytes: number }>; + }; } // Daemon shutdown command