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

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## 2025-08-30 - 5.0.0 - BREAKING CHANGE(daemon)
Introduce persistent log storage, numeric ProcessId type, and improved process monitoring / IPC handling
- Add LogPersistence: persistent on-disk storage for process logs (save/load/delete/cleanup).
- Persist logs on process exit/error/stop and trim in-memory buffers to avoid excessive memory usage.
- Introduce a branded numeric ProcessId type and toProcessId helpers; migrate IPC types and internal maps from string ids to ProcessId.
- ProcessManager refactor: typed maps for processes/configs/info/logs, async start/stop/restart flows, improved PID/uptime/restart tracking, and desired state persistence handling.
- ProcessMonitor refactor: async lifecycle (start/stop), load persisted logs on startup, flush logs to disk on exit/error/stop, log memory capping, and improved event emissions.
- ProcessWrapper improvements: buffer stdout/stderr remainders, flush partial lines on stream end, clearer debug logging.
- IPC client/server changes: handlers now normalize ids with toProcessId, subscribe/unsubscribe accept numeric/string ids, getLogs/start/stop/restart/delete use typed ids.
- CLI tweaks: format process id output safely with String() to avoid formatting issues.
- Add dependency and plugin export for @push.rocks/smartfile and update package.json accordingly.
## 2025-08-29 - 4.4.2 - fix(daemon) ## 2025-08-29 - 4.4.2 - fix(daemon)
Fix daemon IPC id handling, reload configs on demand and correct CLI daemon start path Fix daemon IPC id handling, reload configs on demand and correct CLI daemon start path

View File

@@ -36,6 +36,7 @@
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/smartcli": "^4.0.11", "@push.rocks/smartcli": "^4.0.11",
"@push.rocks/smartdaemon": "^2.0.9", "@push.rocks/smartdaemon": "^2.0.9",
"@push.rocks/smartfile": "^11.2.7",
"@push.rocks/smartinteract": "^2.0.16", "@push.rocks/smartinteract": "^2.0.16",
"@push.rocks/smartipc": "^2.2.2", "@push.rocks/smartipc": "^2.2.2",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",

57
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@push.rocks/smartdaemon': '@push.rocks/smartdaemon':
specifier: ^2.0.9 specifier: ^2.0.9
version: 2.0.9 version: 2.0.9
'@push.rocks/smartfile':
specifier: ^11.2.7
version: 11.2.7
'@push.rocks/smartinteract': '@push.rocks/smartinteract':
specifier: ^2.0.16 specifier: ^2.0.16
version: 2.0.16 version: 2.0.16
@@ -844,9 +847,6 @@ packages:
'@push.rocks/smartfile@10.0.41': '@push.rocks/smartfile@10.0.41':
resolution: {integrity: sha512-xOOy0duI34M2qrJZggpk51EHGXmg9+mBL1Q55tNiQKXzfx89P3coY1EAZG8tvmep3qB712QEKe7T+u04t42Kjg==} resolution: {integrity: sha512-xOOy0duI34M2qrJZggpk51EHGXmg9+mBL1Q55tNiQKXzfx89P3coY1EAZG8tvmep3qB712QEKe7T+u04t42Kjg==}
'@push.rocks/smartfile@11.2.0':
resolution: {integrity: sha512-0Gw6DvCQ2D/BXNN6airSC7hoSBut0p/uNWf2+rqO+D6VLhIJ/QUBvF6xm/LnpPI/zcF8YlDn/GEriInB5DUtEw==}
'@push.rocks/smartfile@11.2.7': '@push.rocks/smartfile@11.2.7':
resolution: {integrity: sha512-8Yp7/sAgPpWJBHohV92ogHWKzRomI5MEbSG6b5W2n18tqwfAmjMed0rQvsvGrSBlnEWCKgoOrYIIZbLO61+J0Q==} resolution: {integrity: sha512-8Yp7/sAgPpWJBHohV92ogHWKzRomI5MEbSG6b5W2n18tqwfAmjMed0rQvsvGrSBlnEWCKgoOrYIIZbLO61+J0Q==}
@@ -2669,11 +2669,6 @@ packages:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
hasBin: true hasBin: true
glob@11.0.1:
resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==}
engines: {node: 20 || >=22}
hasBin: true
glob@11.0.3: glob@11.0.3:
resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -2989,10 +2984,6 @@ packages:
jackspeak@3.4.3: jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jackspeak@4.1.0:
resolution: {integrity: sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==}
engines: {node: 20 || >=22}
jackspeak@4.1.1: jackspeak@4.1.1:
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -3412,10 +3403,6 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'} engines: {node: '>=4'}
minimatch@10.0.1:
resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==}
engines: {node: 20 || >=22}
minimatch@10.0.3: minimatch@10.0.3:
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -5665,7 +5652,7 @@ snapshots:
'@git.zone/tsrun@1.3.3': '@git.zone/tsrun@1.3.3':
dependencies: dependencies:
'@push.rocks/smartfile': 11.2.0 '@push.rocks/smartfile': 11.2.7
'@push.rocks/smartshell': 3.2.3 '@push.rocks/smartshell': 3.2.3
tsx: 4.20.5 tsx: 4.20.5
@@ -6290,25 +6277,6 @@ snapshots:
glob: 10.4.5 glob: 10.4.5
js-yaml: 4.1.0 js-yaml: 4.1.0
'@push.rocks/smartfile@11.2.0':
dependencies:
'@push.rocks/lik': 6.1.0
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfile-interfaces': 1.0.7
'@push.rocks/smarthash': 3.0.4
'@push.rocks/smartjson': 5.0.20
'@push.rocks/smartmime': 2.0.4
'@push.rocks/smartpath': 5.1.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 2.0.23
'@push.rocks/smartstream': 3.2.5
'@types/fs-extra': 11.0.4
'@types/glob': 8.1.0
'@types/js-yaml': 4.0.9
fs-extra: 11.3.0
glob: 11.0.1
js-yaml: 4.1.0
'@push.rocks/smartfile@11.2.7': '@push.rocks/smartfile@11.2.7':
dependencies: dependencies:
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
@@ -8736,15 +8704,6 @@ snapshots:
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 1.11.1 path-scurry: 1.11.1
glob@11.0.1:
dependencies:
foreground-child: 3.3.1
jackspeak: 4.1.0
minimatch: 10.0.1
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 2.0.0
glob@11.0.3: glob@11.0.3:
dependencies: dependencies:
foreground-child: 3.3.1 foreground-child: 3.3.1
@@ -9093,10 +9052,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@pkgjs/parseargs': 0.11.0 '@pkgjs/parseargs': 0.11.0
jackspeak@4.1.0:
dependencies:
'@isaacs/cliui': 8.0.2
jackspeak@4.1.1: jackspeak@4.1.1:
dependencies: dependencies:
'@isaacs/cliui': 8.0.2 '@isaacs/cliui': 8.0.2
@@ -9729,10 +9684,6 @@ snapshots:
min-indent@1.0.1: {} min-indent@1.0.1: {}
minimatch@10.0.1:
dependencies:
brace-expansion: 2.0.1
minimatch@10.0.3: minimatch@10.0.3:
dependencies: dependencies:
'@isaacs/brace-expansion': 5.0.0 '@isaacs/brace-expansion': 5.0.0

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tspm', name: '@git.zone/tspm',
version: '4.4.2', version: '5.0.0',
description: 'a no fuzz process manager' description: 'a no fuzz process manager'
} }

View File

@@ -74,7 +74,7 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
const resetColor = '\x1b[0m'; const resetColor = '\x1b[0m';
console.log( console.log(
`${pad(proc.id, 7)}${pad(proc.id, 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad(formatMemory(proc.memory), 9)}${pad(proc.restarts.toString(), 8)}`, `${pad(String(proc.id), 7)}${pad(String(proc.id), 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad(formatMemory(proc.memory), 9)}${pad(proc.restarts.toString(), 8)}`,
); );
} }

View File

@@ -39,7 +39,7 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
const resetColor = '\x1b[0m'; const resetColor = '\x1b[0m';
console.log( console.log(
`${pad(proc.id, 7)}${pad(proc.id, 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad((proc.pid || '-').toString(), 9)}${pad(formatMemory(proc.memory), 8)}${pad(proc.restarts.toString(), 8)}`, `${pad(String(proc.id), 7)}${pad(String(proc.id), 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad((proc.pid || '-').toString(), 9)}${pad(formatMemory(proc.memory), 8)}${pad(proc.restarts.toString(), 8)}`,
); );
} }

View File

@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import { tspmIpcClient } from '../../../client/tspm.ipcclient.js'; import { tspmIpcClient } from '../../../client/tspm.ipcclient.js';
import { toProcessId } from '../../../shared/protocol/id.js';
import type { CliArguments } from '../../types.js'; import type { CliArguments } from '../../types.js';
import { registerIpcCommand } from '../../registration/index.js'; import { registerIpcCommand } from '../../registration/index.js';
@@ -34,7 +35,7 @@ export function registerRestartCommand(smartcli: plugins.smartcli.Smartcli) {
const id = String(arg); const id = String(arg);
console.log(`Restarting process: ${id}`); console.log(`Restarting process: ${id}`);
const response = await tspmIpcClient.request('restart', { id }); const response = await tspmIpcClient.request('restart', { id: toProcessId(id) });
console.log(`✓ Process restarted successfully`); console.log(`✓ Process restarted successfully`);
console.log(` ID: ${response.processId}`); console.log(` ID: ${response.processId}`);

View File

@@ -1,5 +1,7 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
import { toProcessId } from '../shared/protocol/id.js';
import type { ProcessId } from '../shared/protocol/id.js';
import type { import type {
IpcMethodMap, IpcMethodMap,
@@ -144,26 +146,28 @@ export class TspmIpcClient {
* Subscribe to log updates for a specific process * Subscribe to log updates for a specific process
*/ */
public async subscribe( public async subscribe(
processId: string, processId: ProcessId | number | string,
handler: (log: any) => void, handler: (log: any) => void,
): Promise<void> { ): Promise<void> {
if (!this.ipcClient || !this.isConnected) { if (!this.ipcClient || !this.isConnected) {
throw new Error('Not connected to daemon'); throw new Error('Not connected to daemon');
} }
const topic = `logs.${processId}`; const id = toProcessId(processId);
const topic = `logs.${id}`;
await this.ipcClient.subscribe(`topic:${topic}`, handler); await this.ipcClient.subscribe(`topic:${topic}`, handler);
} }
/** /**
* Unsubscribe from log updates for a specific process * Unsubscribe from log updates for a specific process
*/ */
public async unsubscribe(processId: string): Promise<void> { public async unsubscribe(processId: ProcessId | number | string): Promise<void> {
if (!this.ipcClient || !this.isConnected) { if (!this.ipcClient || !this.isConnected) {
throw new Error('Not connected to daemon'); throw new Error('Not connected to daemon');
} }
const topic = `logs.${processId}`; const id = toProcessId(processId);
const topic = `logs.${id}`;
await this.ipcClient.unsubscribe(`topic:${topic}`); await this.ipcClient.unsubscribe(`topic:${topic}`);
} }

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 { EventEmitter } from 'events';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
import { ProcessMonitor } from './processmonitor.js'; import { ProcessMonitor } from './processmonitor.js';
import { LogPersistence } from './logpersistence.js';
import { TspmConfig } from './tspm.config.js'; import { TspmConfig } from './tspm.config.js';
import { import {
Logger, Logger,
@@ -16,17 +17,20 @@ import type {
IProcessLog, IProcessLog,
IMonitorConfig IMonitorConfig
} from '../shared/protocol/ipc.types.js'; } 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 { export class ProcessManager extends EventEmitter {
public processes: Map<string, ProcessMonitor> = new Map(); public processes: Map<ProcessId, ProcessMonitor> = new Map();
public processConfigs: Map<string, IProcessConfig> = new Map(); public processConfigs: Map<ProcessId, IProcessConfig> = new Map();
public processInfo: Map<string, IProcessInfo> = new Map(); public processInfo: Map<ProcessId, IProcessInfo> = new Map();
private processLogs: Map<ProcessId, IProcessLog[]> = new Map();
private config: TspmConfig; private config: TspmConfig;
private configStorageKey = 'processes'; private configStorageKey = 'processes';
private desiredStateStorageKey = 'desiredStates'; private desiredStateStorageKey = 'desiredStates';
private desiredStates: Map<string, IProcessInfo['status']> = new Map(); private desiredStates: Map<ProcessId, IProcessInfo['status']> = new Map();
private logger: Logger; private logger: Logger;
constructor() { constructor() {
@@ -39,14 +43,14 @@ export class ProcessManager extends EventEmitter {
/** /**
* Add a process configuration without starting it. * 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 // Determine next numeric id
const nextId = this.getNextSequentialId(); const nextId = this.getNextSequentialId();
const config: IProcessConfig = { const config: IProcessConfig = {
id: String(nextId), id: nextId,
name: configInput.name || `process-${nextId}`, name: configInput.name || `process-${nextId}`,
command: configInput.command, command: configInput.command,
args: configInput.args, args: configInput.args,
@@ -111,7 +115,8 @@ export class ProcessManager extends EventEmitter {
// Create and start process monitor // Create and start process monitor
const monitor = new ProcessMonitor({ 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, projectDir: config.projectDir,
command: config.command, command: config.command,
args: config.args, args: config.args,
@@ -125,13 +130,43 @@ export class ProcessManager extends EventEmitter {
// Set up log event handler to re-emit for pub/sub // Set up log event handler to re-emit for pub/sub
monitor.on('log', (log: IProcessLog) => { 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 }); 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 // Wait a moment for the process to spawn and get its PID
this.updateProcessInfo(config.id, { status: 'online' }); 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 // Save updated configs
await this.saveProcessConfigs(); await this.saveProcessConfigs();
@@ -165,7 +200,7 @@ export class ProcessManager extends EventEmitter {
/** /**
* Stop a process by id * 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}'`); this.logger.info(`Stopping process with id '${id}'`);
const monitor = this.processes.get(id); const monitor = this.processes.get(id);
@@ -179,7 +214,7 @@ export class ProcessManager extends EventEmitter {
} }
try { try {
monitor.stop(); await monitor.stop();
this.updateProcessInfo(id, { status: 'stopped' }); this.updateProcessInfo(id, { status: 'stopped' });
this.logger.info(`Successfully stopped process with id '${id}'`); this.logger.info(`Successfully stopped process with id '${id}'`);
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
@@ -199,7 +234,7 @@ export class ProcessManager extends EventEmitter {
/** /**
* Restart a process by id * 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}'`); this.logger.info(`Restarting process with id '${id}'`);
const monitor = this.processes.get(id); const monitor = this.processes.get(id);
@@ -216,11 +251,12 @@ export class ProcessManager extends EventEmitter {
try { try {
// Stop and then start the process // Stop and then start the process
monitor.stop(); await monitor.stop();
// Create a new monitor instance // Create a new monitor instance
const newMonitor = new ProcessMonitor({ 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, projectDir: config.projectDir,
command: config.command, command: config.command,
args: config.args, args: config.args,
@@ -230,14 +266,37 @@ export class ProcessManager extends EventEmitter {
logBufferSize: config.logBufferSize, 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); 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); const info = this.processInfo.get(id);
if (info) { if (info) {
const pid = newMonitor.getPid();
this.updateProcessInfo(id, { this.updateProcessInfo(id, {
status: 'online', status: 'online',
pid: pid || undefined,
restarts: info.restarts + 1, restarts: info.restarts + 1,
}); });
} }
@@ -257,7 +316,7 @@ export class ProcessManager extends EventEmitter {
/** /**
* Delete a process by id * 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}'`); this.logger.info(`Deleting process with id '${id}'`);
// Check if process exists // Check if process exists
@@ -280,6 +339,11 @@ export class ProcessManager extends EventEmitter {
this.processes.delete(id); this.processes.delete(id);
this.processConfigs.delete(id); this.processConfigs.delete(id);
this.processInfo.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 // Save updated configs
await this.saveProcessConfigs(); await this.saveProcessConfigs();
@@ -292,6 +356,12 @@ export class ProcessManager extends EventEmitter {
this.processes.delete(id); this.processes.delete(id);
this.processConfigs.delete(id); this.processConfigs.delete(id);
this.processInfo.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.saveProcessConfigs();
await this.removeDesiredState(id); await this.removeDesiredState(id);
@@ -314,14 +384,42 @@ export class ProcessManager extends EventEmitter {
* Get a list of all process infos * Get a list of all process infos
*/ */
public list(): IProcessInfo[] { 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 * Get detailed info for a specific process
*/ */
public describe( public describe(
id: string, id: ProcessId,
): { config: IProcessConfig; info: IProcessInfo } | null { ): { config: IProcessConfig; info: IProcessInfo } | null {
const config = this.processConfigs.get(id); const config = this.processConfigs.get(id);
const info = this.processInfo.get(id); const info = this.processInfo.get(id);
@@ -336,13 +434,21 @@ export class ProcessManager extends EventEmitter {
/** /**
* Get process logs * 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); 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 * 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); const info = this.processInfo.get(id);
if (info) { if (info) {
this.processInfo.set(id, { ...info, ...update }); this.processInfo.set(id, { ...info, ...update });
@@ -387,15 +493,40 @@ export class ProcessManager extends EventEmitter {
/** /**
* Compute next sequential numeric id based on existing configs * Compute next sequential numeric id based on existing configs
*/ */
private getNextSequentialId(): number { /**
let maxId = 0; * Sync process stats from monitors to processInfo
for (const id of this.processConfigs.keys()) { */
const n = parseInt(id, 10); public syncProcessStats(): void {
if (!isNaN(n)) { for (const [id, monitor] of this.processes.entries()) {
maxId = Math.max(maxId, n); 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 { try {
const obj: Record<string, IProcessInfo['status']> = {}; const obj: Record<string, IProcessInfo['status']> = {};
for (const [id, state] of this.desiredStates.entries()) { for (const [id, state] of this.desiredStates.entries()) {
obj[id] = state; obj[String(id)] = state;
} }
await this.config.writeKey( await this.config.writeKey(
this.desiredStateStorageKey, this.desiredStateStorageKey,
@@ -444,7 +575,9 @@ export class ProcessManager extends EventEmitter {
const raw = await this.config.readKey(this.desiredStateStorageKey); const raw = await this.config.readKey(this.desiredStateStorageKey);
if (raw) { if (raw) {
const obj = JSON.parse(raw) as Record<string, IProcessInfo['status']>; 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( this.logger.debug(
`Loaded desired states for ${this.desiredStates.size} processes`, `Loaded desired states for ${this.desiredStates.size} processes`,
); );
@@ -457,14 +590,14 @@ export class ProcessManager extends EventEmitter {
} }
public async setDesiredState( public async setDesiredState(
id: string, id: ProcessId,
state: IProcessInfo['status'], state: IProcessInfo['status'],
): Promise<void> { ): Promise<void> {
this.desiredStates.set(id, state); this.desiredStates.set(id, state);
await this.saveDesiredStates(); await this.saveDesiredStates();
} }
public async removeDesiredState(id: string): Promise<void> { public async removeDesiredState(id: ProcessId): Promise<void> {
this.desiredStates.delete(id); this.desiredStates.delete(id);
await this.saveDesiredStates(); await this.saveDesiredStates();
} }
@@ -505,23 +638,35 @@ export class ProcessManager extends EventEmitter {
const configsJson = await this.config.readKey(this.configStorageKey); const configsJson = await this.config.readKey(this.configStorageKey);
if (configsJson) { if (configsJson) {
try { try {
const configs = JSON.parse(configsJson) as IProcessConfig[]; const parsed = JSON.parse(configsJson) as Array<any>;
this.logger.debug(`Loaded ${configs.length} process configurations`); this.logger.debug(`Loaded ${parsed.length} process configurations`);
for (const config of configs) { for (const raw of parsed) {
// Validate config // Convert legacy string IDs to ProcessId
if (!config.id || !config.command || !config.projectDir) { let id: ProcessId;
try {
id = toProcessId(raw.id);
} catch {
this.logger.warn( this.logger.warn(
`Skipping invalid process config for id '${config.id || 'unknown'}'`, `Skipping invalid process config with non-numeric id '${raw.id || 'unknown'}'`,
); );
continue; 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 // Initialize process info
this.processInfo.set(config.id, { this.processInfo.set(id, {
id: config.id, id: id,
status: 'stopped', status: 'stopped',
memory: 0, memory: 0,
restarts: 0, restarts: 0,
@@ -555,15 +700,15 @@ export class ProcessManager extends EventEmitter {
* Reset: stop all running processes and clear all saved configurations * Reset: stop all running processes and clear all saved configurations
*/ */
public async reset(): Promise<{ public async reset(): Promise<{
stopped: string[]; stopped: ProcessId[];
removed: string[]; removed: ProcessId[];
failed: Array<{ id: string; error: string }>; failed: Array<{ id: ProcessId; error: string }>;
}> { }> {
this.logger.info('Resetting TSPM: stopping all processes and clearing configs'); this.logger.info('Resetting TSPM: stopping all processes and clearing configs');
const removed = Array.from(this.processConfigs.keys()); const removed = Array.from(this.processConfigs.keys());
const stopped: string[] = []; const stopped: ProcessId[] = [];
const failed: Array<{ id: string; error: string }> = []; const failed: Array<{ id: ProcessId; error: string }> = [];
// Attempt to stop all currently running processes with per-id error collection // Attempt to stop all currently running processes with per-id error collection
for (const id of Array.from(this.processes.keys())) { for (const id of Array.from(this.processes.keys())) {

View File

@@ -1,8 +1,10 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { ProcessWrapper } from './processwrapper.js'; import { ProcessWrapper } from './processwrapper.js';
import { LogPersistence } from './logpersistence.js';
import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js'; import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js';
import type { IMonitorConfig, IProcessLog } from '../shared/protocol/ipc.types.js'; import type { IMonitorConfig, IProcessLog } from '../shared/protocol/ipc.types.js';
import type { ProcessId } from '../shared/protocol/id.js';
export class ProcessMonitor extends EventEmitter { export class ProcessMonitor extends EventEmitter {
private processWrapper: ProcessWrapper | null = null; 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 stopped: boolean = true; // Initially stopped until start() is called
private restartCount: number = 0; private restartCount: number = 0;
private logger: Logger; 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(); super();
this.config = config; this.config = config;
this.logger = new Logger(`ProcessMonitor:${config.name || 'unnamed'}`); 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. // Reset the stopped flag so that new processes can spawn.
this.stopped = false; this.stopped = false;
this.log(`Starting process monitor.`); this.log(`Starting process monitor.`);
@@ -57,6 +81,22 @@ export class ProcessMonitor extends EventEmitter {
// Set up event handlers // Set up event handlers
this.processWrapper.on('log', (log: IProcessLog): void => { 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 // Re-emit the log event for upstream handlers
this.emit('log', log); this.emit('log', log);
@@ -65,13 +105,31 @@ export class ProcessMonitor extends EventEmitter {
this.log(log.message); 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( this.processWrapper.on(
'exit', '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}.`; const exitMsg = `Process exited with code ${code}, signal ${signal}.`;
this.logger.info(exitMsg); this.logger.info(exitMsg);
this.log(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) { if (!this.stopped) {
this.logger.info('Restarting process...'); 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 = const errorMsg =
error instanceof ProcessError error instanceof ProcessError
? `Process error: ${error.toString()}` ? `Process error: ${error.toString()}`
@@ -95,6 +153,16 @@ export class ProcessMonitor extends EventEmitter {
this.logger.error(error); this.logger.error(error);
this.log(errorMsg); 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) { if (!this.stopped) {
this.logger.info('Restarting process due to error...'); this.logger.info('Restarting process due to error...');
this.log('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. * Stop the monitor and prevent any further respawns.
*/ */
public stop(): void { public async stop(): Promise<void> {
this.log('Stopping process monitor.'); this.log('Stopping process monitor.');
this.stopped = true; 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) { if (this.intervalId) {
clearInterval(this.intervalId); clearInterval(this.intervalId);
} }
@@ -254,10 +333,12 @@ export class ProcessMonitor extends EventEmitter {
* Get the current logs from the process * Get the current logs from the process
*/ */
public getLogs(limit?: number): IProcessLog[] { public getLogs(limit?: number): IProcessLog[] {
if (!this.processWrapper) { console.error(`[ProcessMonitor:${this.config.name}] getLogs called, logs.length=${this.logs.length}, limit=${limit}`);
return []; 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 logger: Logger;
private nextSeq: number = 0; private nextSeq: number = 0;
private runId: string = ''; private runId: string = '';
private stdoutRemainder: string = '';
private stderrRemainder: string = '';
constructor(options: IProcessWrapperOptions) { constructor(options: IProcessWrapperOptions) {
super(); super();
@@ -66,6 +68,11 @@ export class ProcessWrapper extends EventEmitter {
const exitMessage = `Process exited with code ${code}, signal ${signal}`; const exitMessage = `Process exited with code ${code}, signal ${signal}`;
this.logger.info(exitMessage); this.logger.info(exitMessage);
this.addSystemLog(exitMessage); this.addSystemLog(exitMessage);
// Clear remainder buffers on exit
this.stdoutRemainder = '';
this.stderrRemainder = '';
this.emit('exit', code, signal); this.emit('exit', code, signal);
}); });
@@ -83,24 +90,57 @@ export class ProcessWrapper extends EventEmitter {
// Capture stdout // Capture stdout
if (this.process.stdout) { if (this.process.stdout) {
console.error(`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`);
this.process.stdout.on('data', (data) => { 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) { for (const line of lines) {
if (line.trim()) { console.error(`[ProcessWrapper] Processing stdout line: ${line}`);
this.addLog('stdout', 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 // Capture stderr
if (this.process.stderr) { if (this.process.stderr) {
this.process.stderr.on('data', (data) => { 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) { 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 plugins from '../plugins.js';
import * as paths from '../paths.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 { ProcessManager } from './processmanager.js';
import type { import type {
IpcMethodMap, IpcMethodMap,
@@ -141,7 +143,7 @@ export class TspmDaemon {
'startById', 'startById',
async (request: RequestForMethod<'startById'>) => { async (request: RequestForMethod<'startById'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
let config = this.tspmInstance.processConfigs.get(id); let config = this.tspmInstance.processConfigs.get(id);
if (!config) { if (!config) {
// Try to reload configs if not found (handles races or stale state) // Try to reload configs if not found (handles races or stale state)
@@ -169,7 +171,7 @@ export class TspmDaemon {
'stop', 'stop',
async (request: RequestForMethod<'stop'>) => { async (request: RequestForMethod<'stop'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
await this.tspmInstance.setDesiredState(id, 'stopped'); await this.tspmInstance.setDesiredState(id, 'stopped');
await this.tspmInstance.stop(id); await this.tspmInstance.stop(id);
return { return {
@@ -186,7 +188,7 @@ export class TspmDaemon {
'restart', 'restart',
async (request: RequestForMethod<'restart'>) => { async (request: RequestForMethod<'restart'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
await this.tspmInstance.setDesiredState(id, 'online'); await this.tspmInstance.setDesiredState(id, 'online');
await this.tspmInstance.restart(id); await this.tspmInstance.restart(id);
const processInfo = this.tspmInstance.processInfo.get(id); const processInfo = this.tspmInstance.processInfo.get(id);
@@ -205,7 +207,7 @@ export class TspmDaemon {
'delete', 'delete',
async (request: RequestForMethod<'delete'>) => { async (request: RequestForMethod<'delete'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
await this.tspmInstance.delete(id); await this.tspmInstance.delete(id);
return { return {
success: true, success: true,
@@ -235,7 +237,7 @@ export class TspmDaemon {
'remove', 'remove',
async (request: RequestForMethod<'remove'>) => { async (request: RequestForMethod<'remove'>) => {
try { try {
const id = String(request.id).trim(); const id = toProcessId(request.id);
await this.tspmInstance.delete(id); await this.tspmInstance.delete(id);
return { success: true, message: `Process ${id} deleted successfully` }; return { success: true, message: `Process ${id} deleted successfully` };
} catch (error) { } catch (error) {
@@ -255,7 +257,7 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'describe', 'describe',
async (request: RequestForMethod<'describe'>) => { async (request: RequestForMethod<'describe'>) => {
const id = String(request.id).trim(); const id = toProcessId(request.id);
const result = await this.tspmInstance.describe(id); const result = await this.tspmInstance.describe(id);
if (!result) { if (!result) {
throw new Error(`Process ${id} not found`); throw new Error(`Process ${id} not found`);
@@ -271,7 +273,7 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'getLogs', 'getLogs',
async (request: RequestForMethod<'getLogs'>) => { async (request: RequestForMethod<'getLogs'>) => {
const logs = await this.tspmInstance.getLogs(request.id); const logs = await this.tspmInstance.getLogs(toProcessId(request.id));
return { logs }; return { logs };
}, },
); );
@@ -280,8 +282,8 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'startAll', 'startAll',
async (request: RequestForMethod<'startAll'>) => { async (request: RequestForMethod<'startAll'>) => {
const started: string[] = []; const started: ProcessId[] = [];
const failed: Array<{ id: string; error: string }> = []; const failed: Array<{ id: ProcessId; error: string }> = [];
await this.tspmInstance.setDesiredStateForAll('online'); await this.tspmInstance.setDesiredStateForAll('online');
await this.tspmInstance.startAll(); await this.tspmInstance.startAll();
@@ -302,8 +304,8 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'stopAll', 'stopAll',
async (request: RequestForMethod<'stopAll'>) => { async (request: RequestForMethod<'stopAll'>) => {
const stopped: string[] = []; const stopped: ProcessId[] = [];
const failed: Array<{ id: string; error: string }> = []; const failed: Array<{ id: ProcessId; error: string }> = [];
await this.tspmInstance.setDesiredStateForAll('stopped'); await this.tspmInstance.setDesiredStateForAll('stopped');
await this.tspmInstance.stopAll(); await this.tspmInstance.stopAll();
@@ -324,8 +326,8 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'restartAll', 'restartAll',
async (request: RequestForMethod<'restartAll'>) => { async (request: RequestForMethod<'restartAll'>) => {
const restarted: string[] = []; const restarted: ProcessId[] = [];
const failed: Array<{ id: string; error: string }> = []; const failed: Array<{ id: ProcessId; error: string }> = [];
await this.tspmInstance.restartAll(); await this.tspmInstance.restartAll();
@@ -556,3 +558,11 @@ export const startDaemon = async (): Promise<void> => {
// Keep the process alive // Keep the process alive
await new Promise(() => {}); 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);
});
}

View File

@@ -10,12 +10,13 @@ import * as npmextra from '@push.rocks/npmextra';
import * as projectinfo from '@push.rocks/projectinfo'; import * as projectinfo from '@push.rocks/projectinfo';
import * as smartcli from '@push.rocks/smartcli'; import * as smartcli from '@push.rocks/smartcli';
import * as smartdaemon from '@push.rocks/smartdaemon'; import * as smartdaemon from '@push.rocks/smartdaemon';
import * as smartfile from '@push.rocks/smartfile';
import * as smartipc from '@push.rocks/smartipc'; import * as smartipc from '@push.rocks/smartipc';
import * as smartpath from '@push.rocks/smartpath'; import * as smartpath from '@push.rocks/smartpath';
import * as smartinteract from '@push.rocks/smartinteract'; import * as smartinteract from '@push.rocks/smartinteract';
// Export with explicit module types // Export with explicit module types
export { npmextra, projectinfo, smartcli, smartdaemon, smartipc, smartpath, smartinteract }; export { npmextra, projectinfo, smartcli, smartdaemon, smartfile, smartipc, smartpath, smartinteract };
// third-party scope // third-party scope
import psTree from 'ps-tree'; import psTree from 'ps-tree';

56
ts/shared/protocol/id.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* Branded type for process IDs to ensure type safety
*/
export type ProcessId = number & { readonly __brand: 'ProcessId' };
/**
* Input type that accepts various ID formats for backward compatibility
*/
export type ProcessIdInput = ProcessId | number | string;
/**
* Normalizes various ID input formats to a ProcessId
* @param input - The ID in various formats (string, number, or ProcessId)
* @returns A normalized ProcessId
* @throws Error if the input is not a valid process ID
*/
export function toProcessId(input: ProcessIdInput): ProcessId {
let num: number;
if (typeof input === 'string') {
const trimmed = input.trim();
if (!/^\d+$/.test(trimmed)) {
throw new Error(`Invalid process ID: "${input}" is not a numeric string`);
}
num = parseInt(trimmed, 10);
} else if (typeof input === 'number') {
num = input;
} else {
// Already a ProcessId
return input;
}
if (!Number.isInteger(num) || num < 1) {
throw new Error(`Invalid process ID: ${input} must be a positive integer`);
}
return num as ProcessId;
}
/**
* Type guard to check if a value is a ProcessId
*/
export function isProcessId(value: unknown): value is ProcessId {
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
}
/**
* Gets the next sequential ID given existing IDs
*/
export function getNextProcessId(existingIds: Iterable<ProcessId>): ProcessId {
let maxId = 0;
for (const id of existingIds) {
maxId = Math.max(maxId, id);
}
return (maxId + 1) as ProcessId;
}

View File

@@ -1,3 +1,5 @@
import type { ProcessId } from './id.js';
// Process-related interfaces (used in IPC communication) // Process-related interfaces (used in IPC communication)
export interface IMonitorConfig { export interface IMonitorConfig {
name?: string; // Optional name to identify the instance name?: string; // Optional name to identify the instance
@@ -11,14 +13,14 @@ export interface IMonitorConfig {
} }
export interface IProcessConfig extends IMonitorConfig { export interface IProcessConfig extends IMonitorConfig {
id: string; // Unique identifier for the process id: ProcessId; // Unique identifier for the process
autorestart: boolean; // Whether to restart the process automatically on crash autorestart: boolean; // Whether to restart the process automatically on crash
watch?: boolean; // Whether to watch for file changes and restart watch?: boolean; // Whether to watch for file changes and restart
watchPaths?: string[]; // Paths to watch for changes watchPaths?: string[]; // Paths to watch for changes
} }
export interface IProcessInfo { export interface IProcessInfo {
id: string; id: ProcessId;
pid?: number; pid?: number;
status: 'online' | 'stopped' | 'errored'; status: 'online' | 'stopped' | 'errored';
memory: number; memory: number;
@@ -61,25 +63,25 @@ export interface StartRequest {
} }
export interface StartResponse { export interface StartResponse {
processId: string; processId: ProcessId;
pid?: number; pid?: number;
status: 'online' | 'stopped' | 'errored'; status: 'online' | 'stopped' | 'errored';
} }
// Start by id (server resolves config) // Start by id (server resolves config)
export interface StartByIdRequest { export interface StartByIdRequest {
id: string; id: ProcessId;
} }
export interface StartByIdResponse { export interface StartByIdResponse {
processId: string; processId: ProcessId;
pid?: number; pid?: number;
status: 'online' | 'stopped' | 'errored'; status: 'online' | 'stopped' | 'errored';
} }
// Stop command // Stop command
export interface StopRequest { export interface StopRequest {
id: string; id: ProcessId;
} }
export interface StopResponse { export interface StopResponse {
@@ -89,18 +91,18 @@ export interface StopResponse {
// Restart command // Restart command
export interface RestartRequest { export interface RestartRequest {
id: string; id: ProcessId;
} }
export interface RestartResponse { export interface RestartResponse {
processId: string; processId: ProcessId;
pid?: number; pid?: number;
status: 'online' | 'stopped' | 'errored'; status: 'online' | 'stopped' | 'errored';
} }
// Delete command // Delete command
export interface DeleteRequest { export interface DeleteRequest {
id: string; id: ProcessId;
} }
export interface DeleteResponse { export interface DeleteResponse {
@@ -119,7 +121,7 @@ export interface ListResponse {
// Describe command // Describe command
export interface DescribeRequest { export interface DescribeRequest {
id: string; id: ProcessId;
} }
export interface DescribeResponse { export interface DescribeResponse {
@@ -129,7 +131,7 @@ export interface DescribeResponse {
// Get logs command // Get logs command
export interface GetLogsRequest { export interface GetLogsRequest {
id: string; id: ProcessId;
lines?: number; lines?: number;
} }
@@ -143,9 +145,9 @@ export interface StartAllRequest {
} }
export interface StartAllResponse { export interface StartAllResponse {
started: string[]; started: ProcessId[];
failed: Array<{ failed: Array<{
id: string; id: ProcessId;
error: string; error: string;
}>; }>;
} }
@@ -156,9 +158,9 @@ export interface StopAllRequest {
} }
export interface StopAllResponse { export interface StopAllResponse {
stopped: string[]; stopped: ProcessId[];
failed: Array<{ failed: Array<{
id: string; id: ProcessId;
error: string; error: string;
}>; }>;
} }
@@ -169,9 +171,9 @@ export interface RestartAllRequest {
} }
export interface RestartAllResponse { export interface RestartAllResponse {
restarted: string[]; restarted: ProcessId[];
failed: Array<{ failed: Array<{
id: string; id: ProcessId;
error: string; error: string;
}>; }>;
} }
@@ -182,10 +184,10 @@ export interface ResetRequest {
} }
export interface ResetResponse { export interface ResetResponse {
stopped: string[]; stopped: ProcessId[];
removed: string[]; removed: ProcessId[];
failed: Array<{ failed: Array<{
id: string; id: ProcessId;
error: string; error: string;
}>; }>;
} }
@@ -229,17 +231,17 @@ export interface HeartbeatResponse {
// Add (register config without starting) // Add (register config without starting)
export interface AddRequest { export interface AddRequest {
// Optional id is ignored server-side if present; server assigns sequential id // Optional id is ignored server-side if present; server assigns sequential id
config: Omit<IProcessConfig, 'id'> & { id?: string }; config: Omit<IProcessConfig, 'id'> & { id?: ProcessId };
} }
export interface AddResponse { export interface AddResponse {
id: string; id: ProcessId;
config: IProcessConfig; config: IProcessConfig;
} }
// Remove (delete config and stop if running) // Remove (delete config and stop if running)
export interface RemoveRequest { export interface RemoveRequest {
id: string; id: ProcessId;
} }
export interface RemoveResponse { export interface RemoveResponse {