feat(cli): Add stats CLI command and daemon stats aggregation; fix process manager & wrapper state handling
This commit is contained in:
		
							
								
								
									
										10
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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'
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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.',
 | 
			
		||||
      );
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										66
									
								
								ts/cli/commands/stats.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								ts/cli/commands/stats.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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' },
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -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<void> => {
 | 
			
		||||
 | 
			
		||||
  // Daemon commands
 | 
			
		||||
  registerDaemonCommand(smartcliInstance);
 | 
			
		||||
  registerStatsCommand(smartcliInstance);
 | 
			
		||||
 | 
			
		||||
  // Service commands
 | 
			
		||||
  registerEnableCommand(smartcliInstance);
 | 
			
		||||
 
 | 
			
		||||
@@ -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';
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 
 | 
			
		||||
@@ -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');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
        };
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user