BREAKING CHANGE(daemon): Introduce persistent log storage, numeric ProcessId type, and improved process monitoring / IPC handling

This commit is contained in:
2025-08-30 13:47:14 +00:00
parent e507b75c40
commit 538f282b62
16 changed files with 589 additions and 167 deletions

117
ts/daemon/logpersistence.ts Normal file
View File

@@ -0,0 +1,117 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import type { IProcessLog } from '../shared/protocol/ipc.types.js';
import type { ProcessId } from '../shared/protocol/id.js';
/**
* Manages persistent log storage for processes
*/
export class LogPersistence {
private logsDir: string;
constructor() {
this.logsDir = plugins.path.join(paths.tspmDir, 'logs');
}
/**
* Get the log file path for a process
*/
private getLogFilePath(processId: ProcessId): string {
return plugins.path.join(this.logsDir, `process-${processId}.json`);
}
/**
* Ensure the logs directory exists
*/
private async ensureLogsDir(): Promise<void> {
await plugins.smartfile.fs.ensureDir(this.logsDir);
}
/**
* Save logs to disk
*/
public async saveLogs(processId: ProcessId, logs: IProcessLog[]): Promise<void> {
await this.ensureLogsDir();
const filePath = this.getLogFilePath(processId);
// Write logs as JSON
await plugins.smartfile.memory.toFs(
JSON.stringify(logs, null, 2),
filePath
);
}
/**
* Load logs from disk
*/
public async loadLogs(processId: ProcessId): Promise<IProcessLog[]> {
const filePath = this.getLogFilePath(processId);
try {
const exists = await plugins.smartfile.fs.fileExists(filePath);
if (!exists) {
return [];
}
const content = await plugins.smartfile.fs.toStringSync(filePath);
const logs = JSON.parse(content) as IProcessLog[];
// Convert date strings back to Date objects
return logs.map(log => ({
...log,
timestamp: new Date(log.timestamp)
}));
} catch (error) {
console.error(`Failed to load logs for process ${processId}:`, error);
return [];
}
}
/**
* Delete logs from disk after loading
*/
public async deleteLogs(processId: ProcessId): Promise<void> {
const filePath = this.getLogFilePath(processId);
try {
const exists = await plugins.smartfile.fs.fileExists(filePath);
if (exists) {
await plugins.smartfile.fs.remove(filePath);
}
} catch (error) {
console.error(`Failed to delete logs for process ${processId}:`, error);
}
}
/**
* Calculate approximate memory size of logs in bytes
*/
public static calculateLogMemorySize(logs: IProcessLog[]): number {
// Estimate based on JSON string size
// This is an approximation but good enough for our purposes
return JSON.stringify(logs).length;
}
/**
* Clean up old log files (for maintenance)
*/
public async cleanupOldLogs(): Promise<void> {
try {
await this.ensureLogsDir();
const files = await plugins.smartfile.fs.listFileTree(this.logsDir, '*.json');
for (const file of files) {
const filePath = plugins.path.join(this.logsDir, file);
const stats = await plugins.smartfile.fs.stat(filePath);
// Delete files older than 7 days
const ageInDays = (Date.now() - stats.mtime.getTime()) / (1000 * 60 * 60 * 24);
if (ageInDays > 7) {
await plugins.smartfile.fs.remove(filePath);
}
}
} catch (error) {
console.error('Failed to cleanup old logs:', error);
}
}
}

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
import { EventEmitter } from 'events';
import * as paths from '../paths.js';
import { ProcessMonitor } from './processmonitor.js';
import { LogPersistence } from './logpersistence.js';
import { TspmConfig } from './tspm.config.js';
import {
Logger,
@@ -16,17 +17,20 @@ import type {
IProcessLog,
IMonitorConfig
} from '../shared/protocol/ipc.types.js';
import { toProcessId, getNextProcessId } from '../shared/protocol/id.js';
import type { ProcessId } from '../shared/protocol/id.js';
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();
public processes: Map<ProcessId, ProcessMonitor> = new Map();
public processConfigs: Map<ProcessId, IProcessConfig> = new Map();
public processInfo: Map<ProcessId, IProcessInfo> = new Map();
private processLogs: Map<ProcessId, IProcessLog[]> = new Map();
private config: TspmConfig;
private configStorageKey = 'processes';
private desiredStateStorageKey = 'desiredStates';
private desiredStates: Map<string, IProcessInfo['status']> = new Map();
private desiredStates: Map<ProcessId, IProcessInfo['status']> = new Map();
private logger: Logger;
constructor() {
@@ -39,14 +43,14 @@ export class ProcessManager extends EventEmitter {
/**
* Add a process configuration without starting it.
* Returns the assigned numeric sequential id as string.
* Returns the assigned numeric sequential id.
*/
public async add(configInput: Omit<IProcessConfig, 'id'> & { id?: string }): Promise<string> {
public async add(configInput: Omit<IProcessConfig, 'id'> & { id?: ProcessId }): Promise<ProcessId> {
// Determine next numeric id
const nextId = this.getNextSequentialId();
const config: IProcessConfig = {
id: String(nextId),
id: nextId,
name: configInput.name || `process-${nextId}`,
command: configInput.command,
args: configInput.args,
@@ -111,7 +115,8 @@ export class ProcessManager extends EventEmitter {
// Create and start process monitor
const monitor = new ProcessMonitor({
name: config.name || config.id,
id: config.id, // Pass the ProcessId for log persistence
name: config.name || String(config.id),
projectDir: config.projectDir,
command: config.command,
args: config.args,
@@ -125,13 +130,43 @@ export class ProcessManager extends EventEmitter {
// Set up log event handler to re-emit for pub/sub
monitor.on('log', (log: IProcessLog) => {
// Store log in our persistent storage
if (!this.processLogs.has(config.id)) {
this.processLogs.set(config.id, []);
}
const logs = this.processLogs.get(config.id)!;
logs.push(log);
// Trim logs if they exceed buffer size (default 1000)
const bufferSize = config.logBufferSize || 1000;
if (logs.length > bufferSize) {
this.processLogs.set(config.id, logs.slice(-bufferSize));
}
this.emit('process:log', { processId: config.id, log });
});
// Set up event handler to track PID when process starts
monitor.on('start', (pid: number) => {
this.updateProcessInfo(config.id, { pid });
});
// Set up event handler to clear PID when process exits
monitor.on('exit', () => {
this.updateProcessInfo(config.id, { pid: undefined });
});
monitor.start();
await monitor.start();
// Update process info
this.updateProcessInfo(config.id, { status: 'online' });
// Wait a moment for the process to spawn and get its PID
await new Promise(resolve => setTimeout(resolve, 100));
// Update process info with PID
const pid = monitor.getPid();
this.updateProcessInfo(config.id, {
status: 'online',
pid: pid || undefined
});
// Save updated configs
await this.saveProcessConfigs();
@@ -165,7 +200,7 @@ export class ProcessManager extends EventEmitter {
/**
* Stop a process by id
*/
public async stop(id: string): Promise<void> {
public async stop(id: ProcessId): Promise<void> {
this.logger.info(`Stopping process with id '${id}'`);
const monitor = this.processes.get(id);
@@ -179,7 +214,7 @@ export class ProcessManager extends EventEmitter {
}
try {
monitor.stop();
await monitor.stop();
this.updateProcessInfo(id, { status: 'stopped' });
this.logger.info(`Successfully stopped process with id '${id}'`);
} catch (error: Error | unknown) {
@@ -199,7 +234,7 @@ export class ProcessManager extends EventEmitter {
/**
* Restart a process by id
*/
public async restart(id: string): Promise<void> {
public async restart(id: ProcessId): Promise<void> {
this.logger.info(`Restarting process with id '${id}'`);
const monitor = this.processes.get(id);
@@ -216,11 +251,12 @@ export class ProcessManager extends EventEmitter {
try {
// Stop and then start the process
monitor.stop();
await monitor.stop();
// Create a new monitor instance
const newMonitor = new ProcessMonitor({
name: config.name || config.id,
id: config.id, // Pass the ProcessId for log persistence
name: config.name || String(config.id),
projectDir: config.projectDir,
command: config.command,
args: config.args,
@@ -230,14 +266,37 @@ export class ProcessManager extends EventEmitter {
logBufferSize: config.logBufferSize,
});
// Set up log event handler for the new monitor
newMonitor.on('log', (log: IProcessLog) => {
// Store log in our persistent storage
if (!this.processLogs.has(id)) {
this.processLogs.set(id, []);
}
const logs = this.processLogs.get(id)!;
logs.push(log);
// Trim logs if they exceed buffer size (default 1000)
const bufferSize = config.logBufferSize || 1000;
if (logs.length > bufferSize) {
this.processLogs.set(id, logs.slice(-bufferSize));
}
this.emit('process:log', { processId: id, log });
});
this.processes.set(id, newMonitor);
newMonitor.start();
await newMonitor.start();
// Update restart count
// Wait a moment for the process to spawn and get its PID
await new Promise(resolve => setTimeout(resolve, 100));
// Update restart count and PID
const info = this.processInfo.get(id);
if (info) {
const pid = newMonitor.getPid();
this.updateProcessInfo(id, {
status: 'online',
pid: pid || undefined,
restarts: info.restarts + 1,
});
}
@@ -257,7 +316,7 @@ export class ProcessManager extends EventEmitter {
/**
* Delete a process by id
*/
public async delete(id: string): Promise<void> {
public async delete(id: ProcessId): Promise<void> {
this.logger.info(`Deleting process with id '${id}'`);
// Check if process exists
@@ -280,6 +339,11 @@ export class ProcessManager extends EventEmitter {
this.processes.delete(id);
this.processConfigs.delete(id);
this.processInfo.delete(id);
this.processLogs.delete(id);
// Delete persisted logs from disk
const logPersistence = new LogPersistence();
await logPersistence.deleteLogs(id);
// Save updated configs
await this.saveProcessConfigs();
@@ -292,6 +356,12 @@ export class ProcessManager extends EventEmitter {
this.processes.delete(id);
this.processConfigs.delete(id);
this.processInfo.delete(id);
this.processLogs.delete(id);
// Delete persisted logs from disk even if stop failed
const logPersistence = new LogPersistence();
await logPersistence.deleteLogs(id);
await this.saveProcessConfigs();
await this.removeDesiredState(id);
@@ -314,14 +384,42 @@ export class ProcessManager extends EventEmitter {
* Get a list of all process infos
*/
public list(): IProcessInfo[] {
return Array.from(this.processInfo.values());
const infos = Array.from(this.processInfo.values());
// Enrich with live data from monitors
for (const info of infos) {
const monitor = this.processes.get(info.id);
if (monitor) {
// Update with current PID if the monitor is running
const pid = monitor.getPid();
if (pid) {
info.pid = pid;
}
// Update uptime if available
const uptime = monitor.getUptime();
if (uptime !== null) {
info.uptime = uptime;
}
// Update restart count
info.restarts = monitor.getRestartCount();
// Update status based on actual running state
if (monitor.isRunning()) {
info.status = 'online';
}
}
}
return infos;
}
/**
* Get detailed info for a specific process
*/
public describe(
id: string,
id: ProcessId,
): { config: IProcessConfig; info: IProcessInfo } | null {
const config = this.processConfigs.get(id);
const info = this.processInfo.get(id);
@@ -336,13 +434,21 @@ export class ProcessManager extends EventEmitter {
/**
* Get process logs
*/
public getLogs(id: string, limit?: number): IProcessLog[] {
public getLogs(id: ProcessId, limit?: number): IProcessLog[] {
// Get logs from the ProcessMonitor instance
const monitor = this.processes.get(id);
if (!monitor) {
return [];
if (monitor) {
const logs = monitor.getLogs(limit);
return logs;
}
return monitor.getLogs(limit);
// Fallback to stored logs if monitor doesn't exist
const logs = this.processLogs.get(id) || [];
if (limit && limit > 0) {
return logs.slice(-limit);
}
return logs;
}
/**
@@ -377,7 +483,7 @@ export class ProcessManager extends EventEmitter {
/**
* Update the info for a process
*/
private updateProcessInfo(id: string, update: Partial<IProcessInfo>): void {
private updateProcessInfo(id: ProcessId, update: Partial<IProcessInfo>): void {
const info = this.processInfo.get(id);
if (info) {
this.processInfo.set(id, { ...info, ...update });
@@ -387,15 +493,40 @@ export class ProcessManager extends EventEmitter {
/**
* Compute next sequential numeric id based on existing configs
*/
private getNextSequentialId(): number {
let maxId = 0;
for (const id of this.processConfigs.keys()) {
const n = parseInt(id, 10);
if (!isNaN(n)) {
maxId = Math.max(maxId, n);
/**
* Sync process stats from monitors to processInfo
*/
public syncProcessStats(): void {
for (const [id, monitor] of this.processes.entries()) {
const info = this.processInfo.get(id);
if (info) {
const pid = monitor.getPid();
const updates: Partial<IProcessInfo> = {};
// Update PID if available
if (pid) {
updates.pid = pid;
}
// Update uptime if available
const uptime = monitor.getUptime();
if (uptime !== null) {
updates.uptime = uptime;
}
// Update restart count
updates.restarts = monitor.getRestartCount();
// Update status based on actual running state
updates.status = monitor.isRunning() ? 'online' : 'stopped';
this.updateProcessInfo(id, updates);
}
}
return maxId + 1;
}
private getNextSequentialId(): ProcessId {
return getNextProcessId(this.processConfigs.keys());
}
/**
@@ -426,7 +557,7 @@ export class ProcessManager extends EventEmitter {
try {
const obj: Record<string, IProcessInfo['status']> = {};
for (const [id, state] of this.desiredStates.entries()) {
obj[id] = state;
obj[String(id)] = state;
}
await this.config.writeKey(
this.desiredStateStorageKey,
@@ -444,7 +575,9 @@ export class ProcessManager extends EventEmitter {
const raw = await this.config.readKey(this.desiredStateStorageKey);
if (raw) {
const obj = JSON.parse(raw) as Record<string, IProcessInfo['status']>;
this.desiredStates = new Map(Object.entries(obj));
this.desiredStates = new Map(
Object.entries(obj).map(([k, v]) => [toProcessId(k), v] as const)
);
this.logger.debug(
`Loaded desired states for ${this.desiredStates.size} processes`,
);
@@ -457,14 +590,14 @@ export class ProcessManager extends EventEmitter {
}
public async setDesiredState(
id: string,
id: ProcessId,
state: IProcessInfo['status'],
): Promise<void> {
this.desiredStates.set(id, state);
await this.saveDesiredStates();
}
public async removeDesiredState(id: string): Promise<void> {
public async removeDesiredState(id: ProcessId): Promise<void> {
this.desiredStates.delete(id);
await this.saveDesiredStates();
}
@@ -505,23 +638,35 @@ export class ProcessManager extends EventEmitter {
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`);
const parsed = JSON.parse(configsJson) as Array<any>;
this.logger.debug(`Loaded ${parsed.length} process configurations`);
for (const config of configs) {
// Validate config
if (!config.id || !config.command || !config.projectDir) {
for (const raw of parsed) {
// Convert legacy string IDs to ProcessId
let id: ProcessId;
try {
id = toProcessId(raw.id);
} catch {
this.logger.warn(
`Skipping invalid process config for id '${config.id || 'unknown'}'`,
`Skipping invalid process config with non-numeric id '${raw.id || 'unknown'}'`,
);
continue;
}
this.processConfigs.set(config.id, config);
// Validate config
if (!id || !raw.command || !raw.projectDir) {
this.logger.warn(
`Skipping invalid process config for id '${id || 'unknown'}'`,
);
continue;
}
const config: IProcessConfig = { ...raw, id };
this.processConfigs.set(id, config);
// Initialize process info
this.processInfo.set(config.id, {
id: config.id,
this.processInfo.set(id, {
id: id,
status: 'stopped',
memory: 0,
restarts: 0,
@@ -555,15 +700,15 @@ export class ProcessManager extends EventEmitter {
* Reset: stop all running processes and clear all saved configurations
*/
public async reset(): Promise<{
stopped: string[];
removed: string[];
failed: Array<{ id: string; error: string }>;
stopped: ProcessId[];
removed: ProcessId[];
failed: Array<{ id: ProcessId; error: string }>;
}> {
this.logger.info('Resetting TSPM: stopping all processes and clearing configs');
const removed = Array.from(this.processConfigs.keys());
const stopped: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
const stopped: ProcessId[] = [];
const failed: Array<{ id: ProcessId; error: string }> = [];
// Attempt to stop all currently running processes with per-id error collection
for (const id of Array.from(this.processes.keys())) {

View File

@@ -1,8 +1,10 @@
import * as plugins from '../plugins.js';
import { EventEmitter } from 'events';
import { ProcessWrapper } from './processwrapper.js';
import { LogPersistence } from './logpersistence.js';
import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js';
import type { IMonitorConfig, IProcessLog } from '../shared/protocol/ipc.types.js';
import type { ProcessId } from '../shared/protocol/id.js';
export class ProcessMonitor extends EventEmitter {
private processWrapper: ProcessWrapper | null = null;
@@ -11,14 +13,36 @@ export class ProcessMonitor extends EventEmitter {
private stopped: boolean = true; // Initially stopped until start() is called
private restartCount: number = 0;
private logger: Logger;
private logs: IProcessLog[] = [];
private logPersistence: LogPersistence;
private processId?: ProcessId;
private currentLogMemorySize: number = 0;
private readonly MAX_LOG_MEMORY_SIZE = 10 * 1024 * 1024; // 10MB
constructor(config: IMonitorConfig) {
constructor(config: IMonitorConfig & { id?: ProcessId }) {
super();
this.config = config;
this.logger = new Logger(`ProcessMonitor:${config.name || 'unnamed'}`);
this.logs = [];
this.logPersistence = new LogPersistence();
this.processId = config.id;
this.currentLogMemorySize = 0;
}
public start(): void {
public async start(): Promise<void> {
// Load previously persisted logs if available
if (this.processId) {
const persistedLogs = await this.logPersistence.loadLogs(this.processId);
if (persistedLogs.length > 0) {
this.logs = persistedLogs;
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs);
this.logger.info(`Loaded ${persistedLogs.length} persisted logs from disk`);
// Delete the persisted file after loading
await this.logPersistence.deleteLogs(this.processId);
}
}
// Reset the stopped flag so that new processes can spawn.
this.stopped = false;
this.log(`Starting process monitor.`);
@@ -57,6 +81,22 @@ export class ProcessMonitor extends EventEmitter {
// Set up event handlers
this.processWrapper.on('log', (log: IProcessLog): void => {
// Store the log in our buffer
this.logs.push(log);
console.error(`[ProcessMonitor:${this.config.name}] Received log (type=${log.type}): ${log.message}`);
console.error(`[ProcessMonitor:${this.config.name}] Logs array now has ${this.logs.length} items`);
this.logger.debug(`ProcessMonitor received log: ${log.message}`);
// Update memory size tracking
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs);
// Trim logs if they exceed memory limit (10MB)
while (this.currentLogMemorySize > this.MAX_LOG_MEMORY_SIZE && this.logs.length > 1) {
// Remove oldest logs until we're under the memory limit
this.logs.shift();
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs);
}
// Re-emit the log event for upstream handlers
this.emit('log', log);
@@ -65,13 +105,31 @@ export class ProcessMonitor extends EventEmitter {
this.log(log.message);
}
});
// Re-emit start event with PID for upstream handlers
this.processWrapper.on('start', (pid: number): void => {
this.emit('start', pid);
});
this.processWrapper.on(
'exit',
(code: number | null, signal: string | null): void => {
async (code: number | null, signal: string | null): Promise<void> => {
const exitMsg = `Process exited with code ${code}, signal ${signal}.`;
this.logger.info(exitMsg);
this.log(exitMsg);
// Flush logs to disk on exit
if (this.processId && this.logs.length > 0) {
try {
await this.logPersistence.saveLogs(this.processId, this.logs);
this.logger.debug(`Flushed ${this.logs.length} logs to disk on exit`);
} catch (error) {
this.logger.error(`Failed to flush logs to disk on exit: ${error}`);
}
}
// Re-emit exit event for upstream handlers
this.emit('exit', code, signal);
if (!this.stopped) {
this.logger.info('Restarting process...');
@@ -86,7 +144,7 @@ export class ProcessMonitor extends EventEmitter {
},
);
this.processWrapper.on('error', (error: Error | ProcessError): void => {
this.processWrapper.on('error', async (error: Error | ProcessError): Promise<void> => {
const errorMsg =
error instanceof ProcessError
? `Process error: ${error.toString()}`
@@ -95,6 +153,16 @@ export class ProcessMonitor extends EventEmitter {
this.logger.error(error);
this.log(errorMsg);
// Flush logs to disk on error
if (this.processId && this.logs.length > 0) {
try {
await this.logPersistence.saveLogs(this.processId, this.logs);
this.logger.debug(`Flushed ${this.logs.length} logs to disk on error`);
} catch (flushError) {
this.logger.error(`Failed to flush logs to disk on error: ${flushError}`);
}
}
if (!this.stopped) {
this.logger.info('Restarting process due to error...');
this.log('Restarting process due to error...');
@@ -239,9 +307,20 @@ export class ProcessMonitor extends EventEmitter {
/**
* Stop the monitor and prevent any further respawns.
*/
public stop(): void {
public async stop(): Promise<void> {
this.log('Stopping process monitor.');
this.stopped = true;
// Flush logs to disk before stopping
if (this.processId && this.logs.length > 0) {
try {
await this.logPersistence.saveLogs(this.processId, this.logs);
this.logger.info(`Flushed ${this.logs.length} logs to disk on stop`);
} catch (error) {
this.logger.error(`Failed to flush logs to disk on stop: ${error}`);
}
}
if (this.intervalId) {
clearInterval(this.intervalId);
}
@@ -254,10 +333,12 @@ export class ProcessMonitor extends EventEmitter {
* Get the current logs from the process
*/
public getLogs(limit?: number): IProcessLog[] {
if (!this.processWrapper) {
return [];
console.error(`[ProcessMonitor:${this.config.name}] getLogs called, logs.length=${this.logs.length}, limit=${limit}`);
this.logger.debug(`Getting logs, total stored: ${this.logs.length}`);
if (limit && limit > 0) {
return this.logs.slice(-limit);
}
return this.processWrapper.getLogs(limit);
return this.logs;
}
/**

View File

@@ -21,6 +21,8 @@ export class ProcessWrapper extends EventEmitter {
private logger: Logger;
private nextSeq: number = 0;
private runId: string = '';
private stdoutRemainder: string = '';
private stderrRemainder: string = '';
constructor(options: IProcessWrapperOptions) {
super();
@@ -66,6 +68,11 @@ export class ProcessWrapper extends EventEmitter {
const exitMessage = `Process exited with code ${code}, signal ${signal}`;
this.logger.info(exitMessage);
this.addSystemLog(exitMessage);
// Clear remainder buffers on exit
this.stdoutRemainder = '';
this.stderrRemainder = '';
this.emit('exit', code, signal);
});
@@ -83,24 +90,57 @@ export class ProcessWrapper extends EventEmitter {
// Capture stdout
if (this.process.stdout) {
console.error(`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`);
this.process.stdout.on('data', (data) => {
const lines = data.toString().split('\n');
console.error(`[ProcessWrapper] Received stdout data from PID ${this.process?.pid}: ${data.toString().substring(0, 100)}`);
// Add data to remainder buffer and split by newlines
const text = this.stdoutRemainder + data.toString();
const lines = text.split('\n');
// The last element might be a partial line
this.stdoutRemainder = lines.pop() || '';
// Process complete lines
for (const line of lines) {
if (line.trim()) {
this.addLog('stdout', line);
}
console.error(`[ProcessWrapper] Processing stdout line: ${line}`);
this.logger.debug(`Captured stdout: ${line}`);
this.addLog('stdout', line);
}
});
// Flush remainder on stream end
this.process.stdout.on('end', () => {
if (this.stdoutRemainder) {
this.logger.debug(`Flushing stdout remainder: ${this.stdoutRemainder}`);
this.addLog('stdout', this.stdoutRemainder);
this.stdoutRemainder = '';
}
});
} else {
this.logger.warn('Process stdout is null');
}
// Capture stderr
if (this.process.stderr) {
this.process.stderr.on('data', (data) => {
const lines = data.toString().split('\n');
// Add data to remainder buffer and split by newlines
const text = this.stderrRemainder + data.toString();
const lines = text.split('\n');
// The last element might be a partial line
this.stderrRemainder = lines.pop() || '';
// Process complete lines
for (const line of lines) {
if (line.trim()) {
this.addLog('stderr', line);
}
this.addLog('stderr', line);
}
});
// Flush remainder on stream end
this.process.stderr.on('end', () => {
if (this.stderrRemainder) {
this.addLog('stderr', this.stderrRemainder);
this.stderrRemainder = '';
}
});
}

View File

@@ -1,5 +1,7 @@
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,
@@ -141,7 +143,7 @@ export class TspmDaemon {
'startById',
async (request: RequestForMethod<'startById'>) => {
try {
const id = String(request.id).trim();
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)
@@ -169,7 +171,7 @@ export class TspmDaemon {
'stop',
async (request: RequestForMethod<'stop'>) => {
try {
const id = String(request.id).trim();
const id = toProcessId(request.id);
await this.tspmInstance.setDesiredState(id, 'stopped');
await this.tspmInstance.stop(id);
return {
@@ -186,7 +188,7 @@ export class TspmDaemon {
'restart',
async (request: RequestForMethod<'restart'>) => {
try {
const id = String(request.id).trim();
const id = toProcessId(request.id);
await this.tspmInstance.setDesiredState(id, 'online');
await this.tspmInstance.restart(id);
const processInfo = this.tspmInstance.processInfo.get(id);
@@ -205,7 +207,7 @@ export class TspmDaemon {
'delete',
async (request: RequestForMethod<'delete'>) => {
try {
const id = String(request.id).trim();
const id = toProcessId(request.id);
await this.tspmInstance.delete(id);
return {
success: true,
@@ -235,7 +237,7 @@ export class TspmDaemon {
'remove',
async (request: RequestForMethod<'remove'>) => {
try {
const id = String(request.id).trim();
const id = toProcessId(request.id);
await this.tspmInstance.delete(id);
return { success: true, message: `Process ${id} deleted successfully` };
} catch (error) {
@@ -255,7 +257,7 @@ export class TspmDaemon {
this.ipcServer.onMessage(
'describe',
async (request: RequestForMethod<'describe'>) => {
const id = String(request.id).trim();
const id = toProcessId(request.id);
const result = await this.tspmInstance.describe(id);
if (!result) {
throw new Error(`Process ${id} not found`);
@@ -271,7 +273,7 @@ export class TspmDaemon {
this.ipcServer.onMessage(
'getLogs',
async (request: RequestForMethod<'getLogs'>) => {
const logs = await this.tspmInstance.getLogs(request.id);
const logs = await this.tspmInstance.getLogs(toProcessId(request.id));
return { logs };
},
);
@@ -280,8 +282,8 @@ export class TspmDaemon {
this.ipcServer.onMessage(
'startAll',
async (request: RequestForMethod<'startAll'>) => {
const started: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
const started: ProcessId[] = [];
const failed: Array<{ id: ProcessId; error: string }> = [];
await this.tspmInstance.setDesiredStateForAll('online');
await this.tspmInstance.startAll();
@@ -302,8 +304,8 @@ export class TspmDaemon {
this.ipcServer.onMessage(
'stopAll',
async (request: RequestForMethod<'stopAll'>) => {
const stopped: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
const stopped: ProcessId[] = [];
const failed: Array<{ id: ProcessId; error: string }> = [];
await this.tspmInstance.setDesiredStateForAll('stopped');
await this.tspmInstance.stopAll();
@@ -324,8 +326,8 @@ export class TspmDaemon {
this.ipcServer.onMessage(
'restartAll',
async (request: RequestForMethod<'restartAll'>) => {
const restarted: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
const restarted: ProcessId[] = [];
const failed: Array<{ id: ProcessId; error: string }> = [];
await this.tspmInstance.restartAll();
@@ -556,3 +558,11 @@ export const startDaemon = async (): Promise<void> => {
// Keep the process alive
await new Promise(() => {});
};
// If this file is run directly (not imported), start the daemon
if (process.env.TSPM_DAEMON_MODE === 'true') {
startDaemon().catch((error) => {
console.error('Failed to start TSPM daemon:', error);
process.exit(1);
});
}