feat(daemon): Reorganize and refactor core into client/daemon/shared modules; add IPC protocol and tests

This commit is contained in:
2025-08-28 18:17:41 +00:00
parent 1516185c4d
commit ece16b75e2
23 changed files with 83 additions and 32 deletions

9
ts/daemon/index.ts Normal file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env node
import { startDaemon } from './tspm.daemon.js';
// Start the daemon
startDaemon().catch((error) => {
console.error('Failed to start daemon:', error);
process.exit(1);
});

436
ts/daemon/processmanager.ts Normal file
View File

@@ -0,0 +1,436 @@
import * as plugins from '../plugins.js';
import { EventEmitter } from 'events';
import * as paths from '../paths.js';
import {
ProcessMonitor,
type IMonitorConfig,
} from './processmonitor.js';
import { type IProcessLog } from './processwrapper.js';
import { TspmConfig } from './tspm.config.js';
import {
Logger,
ProcessError,
ConfigError,
ValidationError,
handleError,
} from '../shared/common/utils.errorhandler.js';
export interface IProcessConfig extends IMonitorConfig {
id: string; // Unique identifier for the process
autorestart: boolean; // Whether to restart the process automatically on crash
watch?: boolean; // Whether to watch for file changes and restart
watchPaths?: string[]; // Paths to watch for changes
}
export interface IProcessInfo {
id: string;
pid?: number;
status: 'online' | 'stopped' | 'errored';
memory: number;
cpu?: number;
uptime?: number;
restarts: number;
}
export class ProcessManager extends EventEmitter {
public processes: Map<string, ProcessMonitor> = new Map();
public processConfigs: Map<string, IProcessConfig> = new Map();
public processInfo: Map<string, IProcessInfo> = new Map();
private config: TspmConfig;
private configStorageKey = 'processes';
private logger: Logger;
constructor() {
super();
this.logger = new Logger('Tspm');
this.config = new TspmConfig();
this.loadProcessConfigs();
}
/**
* Start a new process with the given configuration
*/
public async start(config: IProcessConfig): Promise<void> {
this.logger.info(`Starting process with id '${config.id}'`);
// Validate config
if (!config.id || !config.command || !config.projectDir) {
throw new ValidationError(
'Invalid process configuration: missing required fields',
'ERR_INVALID_CONFIG',
{ config },
);
}
// Check if process with this id already exists
if (this.processes.has(config.id)) {
throw new ValidationError(
`Process with id '${config.id}' already exists`,
'ERR_DUPLICATE_PROCESS',
);
}
try {
// Create and store process config
this.processConfigs.set(config.id, config);
// Initialize process info
this.processInfo.set(config.id, {
id: config.id,
status: 'stopped',
memory: 0,
restarts: 0,
});
// Create and start process monitor
const monitor = new ProcessMonitor({
name: config.name || config.id,
projectDir: config.projectDir,
command: config.command,
args: config.args,
memoryLimitBytes: config.memoryLimitBytes,
monitorIntervalMs: config.monitorIntervalMs,
env: config.env,
logBufferSize: config.logBufferSize,
});
this.processes.set(config.id, monitor);
// Set up log event handler to re-emit for pub/sub
monitor.on('log', (log: IProcessLog) => {
this.emit('process:log', { processId: config.id, log });
});
monitor.start();
// Update process info
this.updateProcessInfo(config.id, { status: 'online' });
// Save updated configs
await this.saveProcessConfigs();
this.logger.info(`Successfully started process with id '${config.id}'`);
} catch (error: Error | unknown) {
// Clean up in case of error
this.processConfigs.delete(config.id);
this.processInfo.delete(config.id);
this.processes.delete(config.id);
if (error instanceof Error) {
this.logger.error(error);
throw new ProcessError(
`Failed to start process: ${error.message}`,
'ERR_PROCESS_START_FAILED',
{ id: config.id, command: config.command },
);
} else {
const genericError = new ProcessError(
`Failed to start process: ${String(error)}`,
'ERR_PROCESS_START_FAILED',
{ id: config.id },
);
this.logger.error(genericError);
throw genericError;
}
}
}
/**
* Stop a process by id
*/
public async stop(id: string): Promise<void> {
this.logger.info(`Stopping process with id '${id}'`);
const monitor = this.processes.get(id);
if (!monitor) {
const error = new ValidationError(
`Process with id '${id}' not found`,
'ERR_PROCESS_NOT_FOUND',
);
this.logger.error(error);
throw error;
}
try {
monitor.stop();
this.updateProcessInfo(id, { status: 'stopped' });
this.logger.info(`Successfully stopped process with id '${id}'`);
} catch (error: Error | unknown) {
const processError = new ProcessError(
`Failed to stop process: ${error instanceof Error ? error.message : String(error)}`,
'ERR_PROCESS_STOP_FAILED',
{ id },
);
this.logger.error(processError);
throw processError;
}
// Don't remove from the maps, just mark as stopped
// This allows it to be restarted later
}
/**
* Restart a process by id
*/
public async restart(id: string): Promise<void> {
this.logger.info(`Restarting process with id '${id}'`);
const monitor = this.processes.get(id);
const config = this.processConfigs.get(id);
if (!monitor || !config) {
const error = new ValidationError(
`Process with id '${id}' not found`,
'ERR_PROCESS_NOT_FOUND',
);
this.logger.error(error);
throw error;
}
try {
// Stop and then start the process
monitor.stop();
// Create a new monitor instance
const newMonitor = new ProcessMonitor({
name: config.name || config.id,
projectDir: config.projectDir,
command: config.command,
args: config.args,
memoryLimitBytes: config.memoryLimitBytes,
monitorIntervalMs: config.monitorIntervalMs,
env: config.env,
logBufferSize: config.logBufferSize,
});
this.processes.set(id, newMonitor);
newMonitor.start();
// Update restart count
const info = this.processInfo.get(id);
if (info) {
this.updateProcessInfo(id, {
status: 'online',
restarts: info.restarts + 1,
});
}
this.logger.info(`Successfully restarted process with id '${id}'`);
} catch (error: Error | unknown) {
const processError = new ProcessError(
`Failed to restart process: ${error instanceof Error ? error.message : String(error)}`,
'ERR_PROCESS_RESTART_FAILED',
{ id },
);
this.logger.error(processError);
throw processError;
}
}
/**
* Delete a process by id
*/
public async delete(id: string): Promise<void> {
this.logger.info(`Deleting process with id '${id}'`);
// Check if process exists
if (!this.processConfigs.has(id)) {
const error = new ValidationError(
`Process with id '${id}' not found`,
'ERR_PROCESS_NOT_FOUND',
);
this.logger.error(error);
throw error;
}
// Stop the process if it's running
try {
if (this.processes.has(id)) {
await this.stop(id);
}
// Remove from all maps
this.processes.delete(id);
this.processConfigs.delete(id);
this.processInfo.delete(id);
// Save updated configs
await this.saveProcessConfigs();
this.logger.info(`Successfully deleted process with id '${id}'`);
} catch (error: Error | unknown) {
// Even if stop fails, we should still try to delete the configuration
try {
this.processes.delete(id);
this.processConfigs.delete(id);
this.processInfo.delete(id);
await this.saveProcessConfigs();
this.logger.info(
`Successfully deleted process with id '${id}' after stopping failure`,
);
} catch (deleteError: Error | unknown) {
const configError = new ConfigError(
`Failed to delete process configuration: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`,
'ERR_CONFIG_DELETE_FAILED',
{ id },
);
this.logger.error(configError);
throw configError;
}
}
}
/**
* Get a list of all process infos
*/
public list(): IProcessInfo[] {
return Array.from(this.processInfo.values());
}
/**
* Get detailed info for a specific process
*/
public describe(
id: string,
): { config: IProcessConfig; info: IProcessInfo } | null {
const config = this.processConfigs.get(id);
const info = this.processInfo.get(id);
if (!config || !info) {
return null;
}
return { config, info };
}
/**
* Get process logs
*/
public getLogs(id: string, limit?: number): IProcessLog[] {
const monitor = this.processes.get(id);
if (!monitor) {
return [];
}
return monitor.getLogs(limit);
}
/**
* Start all saved processes
*/
public async startAll(): Promise<void> {
for (const [id, config] of this.processConfigs.entries()) {
if (!this.processes.has(id)) {
await this.start(config);
}
}
}
/**
* Stop all running processes
*/
public async stopAll(): Promise<void> {
for (const id of this.processes.keys()) {
await this.stop(id);
}
}
/**
* Restart all processes
*/
public async restartAll(): Promise<void> {
for (const id of this.processes.keys()) {
await this.restart(id);
}
}
/**
* Update the info for a process
*/
private updateProcessInfo(id: string, update: Partial<IProcessInfo>): void {
const info = this.processInfo.get(id);
if (info) {
this.processInfo.set(id, { ...info, ...update });
}
}
/**
* Save all process configurations to config storage
*/
private async saveProcessConfigs(): Promise<void> {
this.logger.debug('Saving process configurations to storage');
try {
const configs = Array.from(this.processConfigs.values());
await this.config.writeKey(
this.configStorageKey,
JSON.stringify(configs),
);
this.logger.debug(`Saved ${configs.length} process configurations`);
} catch (error: Error | unknown) {
const configError = new ConfigError(
`Failed to save process configurations: ${error instanceof Error ? error.message : String(error)}`,
'ERR_CONFIG_SAVE_FAILED',
);
this.logger.error(configError);
throw configError;
}
}
/**
* Load process configurations from config storage
*/
public async loadProcessConfigs(): Promise<void> {
this.logger.debug('Loading process configurations from storage');
try {
const configsJson = await this.config.readKey(this.configStorageKey);
if (configsJson) {
try {
const configs = JSON.parse(configsJson) as IProcessConfig[];
this.logger.debug(`Loaded ${configs.length} process configurations`);
for (const config of configs) {
// Validate config
if (!config.id || !config.command || !config.projectDir) {
this.logger.warn(
`Skipping invalid process config for id '${config.id || 'unknown'}'`,
);
continue;
}
this.processConfigs.set(config.id, config);
// Initialize process info
this.processInfo.set(config.id, {
id: config.id,
status: 'stopped',
memory: 0,
restarts: 0,
});
}
} catch (parseError: Error | unknown) {
const configError = new ConfigError(
`Failed to parse process configurations: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
'ERR_CONFIG_PARSE_FAILED',
);
this.logger.error(configError);
throw configError;
}
} else {
this.logger.info('No saved process configurations found');
}
} catch (error: Error | unknown) {
// Only throw if it's not the "no configs found" case
if (error instanceof ConfigError) {
throw error;
}
// If no configs found or error reading, just continue with empty configs
this.logger.info(
'No saved process configurations found or error reading them',
);
}
}
}

308
ts/daemon/processmonitor.ts Normal file
View File

@@ -0,0 +1,308 @@
import * as plugins from '../plugins.js';
import { EventEmitter } from 'events';
import { ProcessWrapper, type IProcessLog } from './processwrapper.js';
import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js';
export interface IMonitorConfig {
name?: string; // Optional name to identify the instance
projectDir: string; // Directory where the command will run
command: string; // Full command to run (e.g., "npm run xyz")
args?: string[]; // Optional: arguments for the command
memoryLimitBytes: number; // Maximum allowed memory (in bytes) for the process group
monitorIntervalMs?: number; // Interval (in ms) at which memory is checked (default: 5000)
env?: NodeJS.ProcessEnv; // Optional: custom environment variables
logBufferSize?: number; // Optional: number of log lines to keep (default: 100)
}
export class ProcessMonitor extends EventEmitter {
private processWrapper: ProcessWrapper | null = null;
private config: IMonitorConfig;
private intervalId: NodeJS.Timeout | null = null;
private stopped: boolean = true; // Initially stopped until start() is called
private restartCount: number = 0;
private logger: Logger;
constructor(config: IMonitorConfig) {
super();
this.config = config;
this.logger = new Logger(`ProcessMonitor:${config.name || 'unnamed'}`);
}
public start(): void {
// Reset the stopped flag so that new processes can spawn.
this.stopped = false;
this.log(`Starting process monitor.`);
this.spawnProcess();
// Set the monitoring interval.
const interval = this.config.monitorIntervalMs || 5000;
this.intervalId = setInterval((): void => {
if (this.processWrapper && this.processWrapper.getPid()) {
this.monitorProcessGroup(
this.processWrapper.getPid()!,
this.config.memoryLimitBytes,
);
}
}, interval);
}
private spawnProcess(): void {
// Don't spawn if the monitor has been stopped.
if (this.stopped) {
this.logger.debug('Not spawning process because monitor is stopped');
return;
}
this.logger.info(`Spawning process: ${this.config.command}`);
// Create a new process wrapper
this.processWrapper = new ProcessWrapper({
name: this.config.name || 'unnamed-process',
command: this.config.command,
args: this.config.args,
cwd: this.config.projectDir,
env: this.config.env,
logBuffer: this.config.logBufferSize,
});
// Set up event handlers
this.processWrapper.on('log', (log: IProcessLog): void => {
// Re-emit the log event for upstream handlers
this.emit('log', log);
// Log system messages to the console
if (log.type === 'system') {
this.log(log.message);
}
});
this.processWrapper.on(
'exit',
(code: number | null, signal: string | null): void => {
const exitMsg = `Process exited with code ${code}, signal ${signal}.`;
this.logger.info(exitMsg);
this.log(exitMsg);
if (!this.stopped) {
this.logger.info('Restarting process...');
this.log('Restarting process...');
this.restartCount++;
this.spawnProcess();
} else {
this.logger.debug(
'Not restarting process because monitor is stopped',
);
}
},
);
this.processWrapper.on('error', (error: Error | ProcessError): void => {
const errorMsg =
error instanceof ProcessError
? `Process error: ${error.toString()}`
: `Process error: ${error.message}`;
this.logger.error(error);
this.log(errorMsg);
if (!this.stopped) {
this.logger.info('Restarting process due to error...');
this.log('Restarting process due to error...');
this.restartCount++;
this.spawnProcess();
} else {
this.logger.debug('Not restarting process because monitor is stopped');
}
});
// Start the process
try {
this.processWrapper.start();
} catch (error: Error | unknown) {
// The process wrapper will handle logging the error
// Just prevent it from bubbling up further
this.logger.error(
`Failed to start process: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Monitor the process group's memory usage. If the total memory exceeds the limit,
* kill the process group so that the 'exit' handler can restart it.
*/
private async monitorProcessGroup(
pid: number,
memoryLimit: number,
): Promise<void> {
try {
const memoryUsage = await this.getProcessGroupMemory(pid);
this.logger.debug(
`Memory usage for PID ${pid}: ${this.humanReadableBytes(memoryUsage)} (${memoryUsage} bytes)`,
);
// Only log to the process log at longer intervals to avoid spamming
this.log(
`Current memory usage for process group (PID ${pid}): ${this.humanReadableBytes(
memoryUsage,
)} (${memoryUsage} bytes)`,
);
if (memoryUsage > memoryLimit) {
const memoryLimitMsg = `Memory usage ${this.humanReadableBytes(
memoryUsage,
)} exceeds limit of ${this.humanReadableBytes(memoryLimit)}. Restarting process.`;
this.logger.warn(memoryLimitMsg);
this.log(memoryLimitMsg);
// Stop the process wrapper, which will trigger the exit handler and restart
if (this.processWrapper) {
this.processWrapper.stop();
}
}
} catch (error: Error | unknown) {
const processError = new ProcessError(
error instanceof Error ? error.message : String(error),
'ERR_MEMORY_MONITORING_FAILED',
{ pid },
);
this.logger.error(processError);
this.log(`Error monitoring process group: ${processError.toString()}`);
}
}
/**
* Get the total memory usage (in bytes) for the process group (the main process and its children).
*/
private getProcessGroupMemory(pid: number): Promise<number> {
return new Promise((resolve, reject) => {
this.logger.debug(
`Getting memory usage for process group with PID ${pid}`,
);
plugins.psTree(
pid,
(err: Error | null, children: Array<{ PID: string }>) => {
if (err) {
const processError = new ProcessError(
`Failed to get process tree: ${err.message}`,
'ERR_PSTREE_FAILED',
{ pid },
);
this.logger.debug(`psTree error: ${err.message}`);
return reject(processError);
}
// Include the main process and its children.
const pids: number[] = [
pid,
...children.map((child) => Number(child.PID)),
];
this.logger.debug(
`Found ${pids.length} processes in group with parent PID ${pid}`,
);
plugins.pidusage(
pids,
(err: Error | null, stats: Record<string, { memory: number }>) => {
if (err) {
const processError = new ProcessError(
`Failed to get process usage stats: ${err.message}`,
'ERR_PIDUSAGE_FAILED',
{ pids },
);
this.logger.debug(`pidusage error: ${err.message}`);
return reject(processError);
}
let totalMemory = 0;
for (const key in stats) {
totalMemory += stats[key].memory;
}
this.logger.debug(
`Total memory for process group: ${this.humanReadableBytes(totalMemory)}`,
);
resolve(totalMemory);
},
);
},
);
});
}
/**
* Convert a number of bytes into a human-readable string (e.g. "1.23 MB").
*/
private humanReadableBytes(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Stop the monitor and prevent any further respawns.
*/
public stop(): void {
this.log('Stopping process monitor.');
this.stopped = true;
if (this.intervalId) {
clearInterval(this.intervalId);
}
if (this.processWrapper) {
this.processWrapper.stop();
}
}
/**
* Get the current logs from the process
*/
public getLogs(limit?: number): IProcessLog[] {
if (!this.processWrapper) {
return [];
}
return this.processWrapper.getLogs(limit);
}
/**
* Get the number of times the process has been restarted
*/
public getRestartCount(): number {
return this.restartCount;
}
/**
* Get the process ID if running
*/
public getPid(): number | null {
return this.processWrapper?.getPid() || null;
}
/**
* Get process uptime in milliseconds
*/
public getUptime(): number {
return this.processWrapper?.getUptime() || 0;
}
/**
* Check if the process is currently running
*/
public isRunning(): boolean {
return this.processWrapper?.isRunning() || false;
}
/**
* Helper method for logging messages with the instance name.
*/
private log(message: string): void {
const prefix = this.config.name ? `[${this.config.name}] ` : '';
console.log(prefix + message);
}
}

262
ts/daemon/processwrapper.ts Normal file
View File

@@ -0,0 +1,262 @@
import * as plugins from '../plugins.js';
import { EventEmitter } from 'events';
import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js';
export interface IProcessWrapperOptions {
command: string;
args?: string[];
cwd: string;
env?: NodeJS.ProcessEnv;
name: string;
logBuffer?: number; // Number of log lines to keep in memory (default: 100)
}
export interface IProcessLog {
timestamp: Date;
type: 'stdout' | 'stderr' | 'system';
message: string;
seq: number;
runId: string;
}
export class ProcessWrapper extends EventEmitter {
private process: plugins.childProcess.ChildProcess | null = null;
private options: IProcessWrapperOptions;
private logs: IProcessLog[] = [];
private logBufferSize: number;
private startTime: Date | null = null;
private logger: Logger;
private nextSeq: number = 0;
private runId: string = '';
constructor(options: IProcessWrapperOptions) {
super();
this.options = options;
this.logBufferSize = options.logBuffer || 100;
this.logger = new Logger(`ProcessWrapper:${options.name}`);
this.runId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Start the wrapped process
*/
public start(): void {
this.addSystemLog('Starting process...');
try {
this.logger.debug(`Starting process: ${this.options.command}`);
if (this.options.args && this.options.args.length > 0) {
this.process = plugins.childProcess.spawn(
this.options.command,
this.options.args,
{
cwd: this.options.cwd,
env: this.options.env || process.env,
stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr
},
);
} else {
// Use shell mode to allow a full command string
this.process = plugins.childProcess.spawn(this.options.command, {
cwd: this.options.cwd,
env: this.options.env || process.env,
stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr
shell: true,
});
}
this.startTime = new Date();
// Handle process exit
this.process.on('exit', (code, signal) => {
const exitMessage = `Process exited with code ${code}, signal ${signal}`;
this.logger.info(exitMessage);
this.addSystemLog(exitMessage);
this.emit('exit', code, signal);
});
// Handle errors
this.process.on('error', (error) => {
const processError = new ProcessError(
error.message,
'ERR_PROCESS_EXECUTION',
{ command: this.options.command, pid: this.process?.pid },
);
this.logger.error(processError);
this.addSystemLog(`Process error: ${processError.toString()}`);
this.emit('error', processError);
});
// Capture stdout
if (this.process.stdout) {
this.process.stdout.on('data', (data) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
this.addLog('stdout', line);
}
}
});
}
// Capture stderr
if (this.process.stderr) {
this.process.stderr.on('data', (data) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
this.addLog('stderr', line);
}
}
});
}
this.addSystemLog(`Process started with PID ${this.process.pid}`);
this.logger.info(`Process started with PID ${this.process.pid}`);
this.emit('start', this.process.pid);
} catch (error: Error | unknown) {
const processError =
error instanceof ProcessError
? error
: new ProcessError(
error instanceof Error ? error.message : String(error),
'ERR_PROCESS_START_FAILED',
{ command: this.options.command },
);
this.logger.error(processError);
this.addSystemLog(`Failed to start process: ${processError.toString()}`);
this.emit('error', processError);
throw processError;
}
}
/**
* Stop the wrapped process
*/
public stop(): void {
if (!this.process) {
this.logger.debug('Stop called but no process is running');
this.addSystemLog('No process running');
return;
}
this.logger.info('Stopping process...');
this.addSystemLog('Stopping process...');
// First try SIGTERM for graceful shutdown
if (this.process.pid) {
try {
this.logger.debug(`Sending SIGTERM to process ${this.process.pid}`);
process.kill(this.process.pid, 'SIGTERM');
// Give it 5 seconds to shut down gracefully
setTimeout((): void => {
if (this.process && this.process.pid) {
this.logger.warn(
`Process ${this.process.pid} did not exit gracefully, force killing...`,
);
this.addSystemLog(
'Process did not exit gracefully, force killing...',
);
try {
process.kill(this.process.pid, 'SIGKILL');
} catch (error: Error | unknown) {
// Process might have exited between checks
this.logger.debug(
`Failed to send SIGKILL, process probably already exited: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}, 5000);
} catch (error: Error | unknown) {
const processError = new ProcessError(
error instanceof Error ? error.message : String(error),
'ERR_PROCESS_STOP_FAILED',
{ pid: this.process.pid },
);
this.logger.error(processError);
this.addSystemLog(`Error stopping process: ${processError.toString()}`);
}
}
}
/**
* Get the process ID if running
*/
public getPid(): number | null {
return this.process?.pid || null;
}
/**
* Get the current logs
*/
public getLogs(limit: number = this.logBufferSize): IProcessLog[] {
// Return the most recent logs up to the limit
return this.logs.slice(-limit);
}
/**
* Get uptime in milliseconds
*/
public getUptime(): number {
if (!this.startTime) return 0;
return Date.now() - this.startTime.getTime();
}
/**
* Check if the process is currently running
*/
public isRunning(): boolean {
return this.process !== null && typeof this.process.exitCode !== 'number';
}
/**
* Add a log entry from stdout or stderr
*/
private addLog(type: 'stdout' | 'stderr', message: string): void {
const log: IProcessLog = {
timestamp: new Date(),
type,
message,
seq: this.nextSeq++,
runId: this.runId,
};
this.logs.push(log);
// Trim logs if they exceed buffer size
if (this.logs.length > this.logBufferSize) {
this.logs = this.logs.slice(-this.logBufferSize);
}
// Emit log event for potential handlers
this.emit('log', log);
}
/**
* Add a system log entry (not from the process itself)
*/
private addSystemLog(message: string): void {
const log: IProcessLog = {
timestamp: new Date(),
type: 'system',
message,
seq: this.nextSeq++,
runId: this.runId,
};
this.logs.push(log);
// Trim logs if they exceed buffer size
if (this.logs.length > this.logBufferSize) {
this.logs = this.logs.slice(-this.logBufferSize);
}
// Emit log event for potential handlers
this.emit('log', log);
}
}

20
ts/daemon/tspm.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import * as plugins from '../plugins.js';
export class TspmConfig {
public npmextraInstance = new plugins.npmextra.KeyValueStore({
identityArg: '@git.zone__tspm',
typeArg: 'userHomeDir',
});
public async readKey(keyArg: string): Promise<string> {
return await this.npmextraInstance.readKey(keyArg);
}
public async writeKey(keyArg: string, value: string): Promise<void> {
return await this.npmextraInstance.writeKey(keyArg, value);
}
public async deleteKey(keyArg: string): Promise<void> {
return await this.npmextraInstance.deleteKey(keyArg);
}
}

462
ts/daemon/tspm.daemon.ts Normal file
View File

@@ -0,0 +1,462 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.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;
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();
}
/**
* Start the daemon server
*/
public async start(): Promise<void> {
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
});
// 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();
// Set up log publishing
this.tspmInstance.on('process:log', ({ processId, log }) => {
// Publish to topic for this process
const topic = `logs.${processId}`;
// Broadcast to all connected clients subscribed to this topic
if (this.ipcServer) {
this.ipcServer.broadcast(`topic:${topic}`, log);
}
});
// Set up graceful shutdown handlers
this.setupShutdownHandlers();
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.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}`);
}
},
);
this.ipcServer.onMessage(
'stop',
async (request: RequestForMethod<'stop'>) => {
try {
await this.tspmInstance.stop(request.id);
return {
success: true,
message: `Process ${request.id} stopped successfully`,
};
} catch (error) {
throw new Error(`Failed to stop process: ${error.message}`);
}
},
);
this.ipcServer.onMessage(
'restart',
async (request: RequestForMethod<'restart'>) => {
try {
await this.tspmInstance.restart(request.id);
const processInfo = this.tspmInstance.processInfo.get(request.id);
return {
processId: request.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 {
await this.tspmInstance.delete(request.id);
return {
success: true,
message: `Process ${request.id} deleted successfully`,
};
} catch (error) {
throw new Error(`Failed to delete process: ${error.message}`);
}
},
);
// Query handlers
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 processInfo = await this.tspmInstance.describe(request.id);
const config = this.tspmInstance.processConfigs.get(request.id);
if (!processInfo || !config) {
throw new Error(`Process ${request.id} not found`);
}
return {
processInfo,
config,
};
},
);
this.ipcServer.onMessage(
'getLogs',
async (request: RequestForMethod<'getLogs'>) => {
const logs = await this.tspmInstance.getLogs(request.id);
return { logs };
},
);
// Batch operations handlers
this.ipcServer.onMessage(
'startAll',
async (request: RequestForMethod<'startAll'>) => {
const started: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
await this.tspmInstance.startAll();
// Get status of all processes
for (const [id, processInfo] of this.tspmInstance.processInfo) {
if (processInfo.status === 'online') {
started.push(id);
} else {
failed.push({ id, error: 'Failed to start' });
}
}
return { started, failed };
},
);
this.ipcServer.onMessage(
'stopAll',
async (request: RequestForMethod<'stopAll'>) => {
const stopped: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
await this.tspmInstance.stopAll();
// Get status of all processes
for (const [id, processInfo] of this.tspmInstance.processInfo) {
if (processInfo.status === 'stopped') {
stopped.push(id);
} else {
failed.push({ id, error: 'Failed to stop' });
}
}
return { stopped, failed };
},
);
this.ipcServer.onMessage(
'restartAll',
async (request: RequestForMethod<'restartAll'>) => {
const restarted: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
await this.tspmInstance.restartAll();
// Get status of all processes
for (const [id, processInfo] of this.tspmInstance.processInfo) {
if (processInfo.status === 'online') {
restarted.push(id);
} else {
failed.push({ id, error: 'Failed to restart' });
}
}
return { restarted, failed };
},
);
// Daemon management handlers
this.ipcServer.onMessage(
'daemon:status',
async (request: RequestForMethod<'daemon:status'>) => {
const memUsage = process.memoryUsage();
return {
status: 'running',
pid: process.pid,
uptime: Date.now() - this.startTime,
processCount: this.tspmInstance.processes.size,
memoryUsage: memUsage.heapUsed,
cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds
};
},
);
this.ipcServer.onMessage(
'daemon:shutdown',
async (request: RequestForMethod<'daemon:shutdown'>) => {
if (this.isShuttingDown) {
return {
success: false,
message: 'Daemon is already shutting down',
};
}
// Schedule shutdown
const graceful = request.graceful !== false;
const timeout = request.timeout || 10000;
if (graceful) {
setTimeout(() => this.shutdown(true), 100);
} else {
setTimeout(() => this.shutdown(false), 100);
}
return {
success: true,
message: `Daemon will shutdown ${graceful ? 'gracefully' : 'immediately'} in ${timeout}ms`,
};
},
);
// Heartbeat handler
this.ipcServer.onMessage(
'heartbeat',
async (request: RequestForMethod<'heartbeat'>) => {
return {
timestamp: Date.now(),
status: this.isShuttingDown ? 'degraded' : 'healthy',
};
},
);
}
/**
* Start heartbeat monitoring
*/
private startHeartbeatMonitoring(): void {
// Send heartbeat every 30 seconds
this.heartbeatInterval = setInterval(() => {
// This is where we could implement health checks
// For now, just log that the daemon is alive
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
console.log(
`[Heartbeat] Daemon alive - Uptime: ${uptime}s, Processes: ${this.tspmInstance.processes.size}`,
);
}, 30000);
}
/**
* Set up graceful shutdown handlers
*/
private setupShutdownHandlers(): void {
const shutdownHandler = async (signal: string) => {
console.log(`\nReceived ${signal}, initiating graceful shutdown...`);
await this.shutdown(true);
};
process.on('SIGTERM', () => shutdownHandler('SIGTERM'));
process.on('SIGINT', () => shutdownHandler('SIGINT'));
process.on('SIGHUP', () => shutdownHandler('SIGHUP'));
// Handle uncaught errors
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
this.shutdown(false);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
// Don't exit on unhandled rejection, just log it
});
}
/**
* Shutdown the daemon
*/
public async shutdown(graceful: boolean = true): Promise<void> {
if (this.isShuttingDown) {
return;
}
this.isShuttingDown = true;
console.log('Shutting down TSPM daemon...');
// Clear heartbeat interval
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (graceful) {
// Stop all processes gracefully
try {
console.log('Stopping all managed processes...');
await this.tspmInstance.stopAll();
} catch (error) {
console.error('Error stopping processes:', error);
}
}
// Stop IPC server
if (this.ipcServer) {
try {
await this.ipcServer.stop();
} catch (error) {
console.error('Error stopping IPC server:', error);
}
}
// Remove PID file
await this.removePidFile();
// Remove socket file if it exists
try {
const fs = await import('fs');
await fs.promises.unlink(this.socketPath).catch(() => {});
} catch (error) {
// Ignore errors
}
console.log('TSPM daemon shutdown complete');
process.exit(0);
}
/**
* Check if another daemon instance is running
*/
private async isDaemonRunning(): Promise<boolean> {
try {
const fs = await import('fs');
const pidContent = await fs.promises.readFile(
this.daemonPidFile,
'utf-8',
);
const pid = parseInt(pidContent.trim(), 10);
// Check if process is running
try {
process.kill(pid, 0);
return true; // Process exists
} catch {
// Process doesn't exist, clean up stale PID file
await this.removePidFile();
return false;
}
} catch {
// PID file doesn't exist
return false;
}
}
/**
* Write the daemon PID to a file
*/
private async writePidFile(): Promise<void> {
const fs = await import('fs');
await fs.promises.writeFile(this.daemonPidFile, process.pid.toString());
}
/**
* Remove the daemon PID file
*/
private async removePidFile(): Promise<void> {
try {
const fs = await import('fs');
await fs.promises.unlink(this.daemonPidFile);
} catch {
// Ignore if file doesn't exist
}
}
}
/**
* Main entry point for the daemon
*/
export const startDaemon = async (): Promise<void> => {
const daemon = new TspmDaemon();
await daemon.start();
// Keep the process alive
await new Promise(() => {});
};