feat(cli/daemon/processmonitor): Add flexible target resolution and search command; improve restart/backoff and error handling

This commit is contained in:
2025-08-30 16:55:10 +00:00
parent 22a43204d4
commit ebc20a9232
17 changed files with 327 additions and 109 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## 2025-08-30 - 5.3.0 - feat(cli/daemon/processmonitor)
Add flexible target resolution and search command; improve restart/backoff and error handling
- Add new cli command `search` to find processes by id or name fragment.
- Allow flexible process targets in CLI commands (accepts numeric id, id:<n>, or name:<label>) for start/stop/restart/delete/describe/logs/edit commands.
- Introduce a new daemon IPC method `resolveTarget` to normalize user-provided targets to ProcessId (supports id:<n>, name:<label>, or bare numeric id).
- Keep `remove` as a CLI alias but daemon exposes `delete` only; CLI resolves targets and always calls daemon `delete`.
- Implement scheduled restart/backoff in ProcessMonitor with incremental debounce, max retries, and a 1-hour reset window.
- Emit a `failed` event from ProcessMonitor when max restart attempts are exceeded; ProcessManager listens and marks processes as `errored` and clears pid.
- Ensure desired state is set to `stopped` before deleting a process to avoid race conditions.
- Improve cli output messages to include resolved names alongside numeric ids where available.
## 2025-08-30 - 5.2.0 - feat(cli) ## 2025-08-30 - 5.2.0 - feat(cli)
Preserve CLI environment when adding processes, simplify edit flow, and refresh README docs Preserve CLI environment when adding processes, simplify edit flow, and refresh README docs

View File

@@ -38,21 +38,23 @@ npm install --save-dev @git.zone/tspm
# Add a process (creates config without starting) # Add a process (creates config without starting)
tspm add "node server.js" --name my-server --memory 1GB tspm add "node server.js" --name my-server --memory 1GB
# Start the process # Start the process (by name or id)
tspm start my-server tspm start name:my-server
# or
tspm start id:1
# Or add and start in one go # Or add and start in one go
tspm add "node app.js" --name my-app tspm add "node app.js" --name my-app
tspm start my-app tspm start name:my-app
# List all processes # List all processes
tspm list tspm list
# View logs # View logs
tspm logs my-app tspm logs name:my-app
# Stop a process # Stop a process
tspm stop my-app tspm stop name:my-app
``` ```
## 📋 Commands ## 📋 Commands
@@ -86,38 +88,38 @@ tspm add "tsx watch src/index.ts" --name dev-server --watch --watch-paths "src,c
tspm add "node worker.js" --name one-time-job --autorestart false tspm add "node worker.js" --name one-time-job --autorestart false
``` ```
#### `tspm start <id>` #### `tspm start <id|id:N|name:LABEL>`
Start a previously added process by its ID or name. Start a previously added process by its ID or name.
```bash ```bash
tspm start my-server tspm start name:my-server
tspm start 1 # Can also use numeric ID tspm start id:1 # Or a bare numeric id: tspm start 1
``` ```
#### `tspm stop <id>` #### `tspm stop <id|id:N|name:LABEL>`
Gracefully stop a running process (SIGTERM → SIGKILL after timeout). Gracefully stop a running process (SIGTERM → SIGKILL after timeout).
```bash ```bash
tspm stop my-server tspm stop name:my-server
``` ```
#### `tspm restart <id>` #### `tspm restart <id|id:N|name:LABEL>`
Stop and restart a process with the same configuration. Stop and restart a process with the same configuration.
```bash ```bash
tspm restart my-server tspm restart name:my-server
``` ```
#### `tspm delete <id>` / `tspm remove <id>` #### `tspm delete <id|id:N|name:LABEL>` / `tspm remove <id|id:N|name:LABEL>`
Stop and remove a process from TSPM management. Also deletes persisted logs. Stop and remove a process from TSPM management. Also deletes persisted logs.
```bash ```bash
tspm delete old-server tspm delete name:old-server
tspm remove old-server # Alias for delete tspm remove name:old-server # Alias for delete (daemon handles delete)
``` ```
#### `tspm edit <id>` #### `tspm edit <id>`
@@ -148,12 +150,12 @@ tspm list
└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘ └─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘
``` ```
#### `tspm describe <id>` #### `tspm describe <id|id:N|name:LABEL>`
Get detailed information about a specific process. Get detailed information about a specific process.
```bash ```bash
tspm describe my-server tspm describe name:my-server
# Output: # Output:
Process Details: my-server Process Details: my-server
@@ -173,7 +175,7 @@ Auto-restart: true
Watch: disabled Watch: disabled
``` ```
#### `tspm logs <id> [options]` #### `tspm logs <id|id:N|name:LABEL> [options]`
View process logs (stdout and stderr combined). View process logs (stdout and stderr combined).
@@ -183,13 +185,13 @@ View process logs (stdout and stderr combined).
```bash ```bash
# View last 50 lines # View last 50 lines
tspm logs my-server tspm logs name:my-server
# View last 100 lines # View last 100 lines
tspm logs my-server --lines 100 tspm logs name:my-server --lines 100
# Follow logs in real-time # Follow logs in real-time
tspm logs my-server --follow tspm logs name:my-server --follow
``` ```
### Batch Operations ### Batch Operations
@@ -496,7 +498,31 @@ Common issues:
- **"Daemon not running"**: Run `tspm daemon start` or `tspm enable` - **"Daemon not running"**: Run `tspm daemon start` or `tspm enable`
- **"Permission denied"**: Check socket permissions in `~/.tspm/` - **"Permission denied"**: Check socket permissions in `~/.tspm/`
- **"Process won't start"**: Check logs with `tspm logs <id>` - **"Process won't start"**: Check logs with `tspm logs <id|id:N|name:LABEL>`
## 🎯 Targeting Processes (IDs and Names)
Most process commands accept the following target formats:
- Numeric ID: `tspm start 1`
- Explicit ID: `tspm start id:1`
- Explicit name: `tspm start name:api-server`
Notes:
- Names must be used with the `name:` prefix.
- If multiple processes share the same name, the CLI will report the ambiguous matches. Use `id:N` to disambiguate.
- Use `tspm search <query>` to discover IDs by name or ID fragments.
### `tspm search <query>`
Search processes by name or ID substring and print matching IDs (and names when available):
```bash
tspm search api
# Matches for "api":
# - id:3 name:api-server
```
- **"Memory limit exceeded"**: Increase limit with `tspm edit <id>` - **"Memory limit exceeded"**: Increase limit with `tspm edit <id>`
## 🤝 Why Choose TSPM? ## 🤝 Why Choose TSPM?
@@ -515,6 +541,20 @@ Common issues:
### Perfect For ### Perfect For
### Restart Backoff and Failure Handling
TSPM automatically restarts crashed processes with an incremental backoff:
- Debounce delay grows linearly from 1s up to 10s for consecutive retries.
- After the 10th retry, the process is marked as failed (status: "errored") and auto-restarts stop.
- The retry counter resets if no retry happens for 1 hour since the last attempt.
You can manually restart a failed process at any time:
```bash
tspm restart id:1
```
- 🚀 **Production Node.js apps** - Reliable process management - 🚀 **Production Node.js apps** - Reliable process management
- 🔧 **Microservices** - Manage multiple services easily - 🔧 **Microservices** - Manage multiple services easily
- 👨💻 **Development** - File watching and auto-restart - 👨💻 **Development** - File watching and auto-restart
@@ -538,4 +578,4 @@ Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc. For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

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

View File

@@ -22,13 +22,14 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
); );
console.log(' disable Disable TSPM system service'); console.log(' disable Disable TSPM system service');
console.log('\nProcess Commands:'); console.log('\nProcess Commands:');
console.log(' start <script> Start a process'); console.log(' start <id|id:N|name:LBL> Start a process');
console.log(' list List all processes'); console.log(' list List all processes');
console.log(' stop <id> Stop a process'); console.log(' stop <id|id:N|name:LBL> Stop a process');
console.log(' restart <id> Restart a process'); console.log(' restart <id|id:N|name:LBL> Restart a process');
console.log(' delete <id> Delete a process'); console.log(' delete <id|id:N|name:LBL> Delete a process');
console.log(' describe <id> Show details for a process'); console.log(' describe <id|id:N|name:LBL> Show details for a process');
console.log(' logs <id> Show logs for a process'); console.log(' logs <id|id:N|name:LBL> Show logs for a process');
console.log(' search <query> Find processes by id/name');
console.log(' start-all Start all saved processes'); console.log(' start-all Start all saved processes');
console.log(' stop-all Stop all processes'); console.log(' stop-all Stop all processes');
console.log(' restart-all Restart all processes'); console.log(' restart-all Restart all processes');

View File

@@ -8,23 +8,25 @@ export function registerDeleteCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
['delete', 'remove'], ['delete', 'remove'],
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm delete <id> | tspm remove <id>'); console.log('Usage: tspm delete <id|id:N|name:LABEL> | tspm remove <id|id:N|name:LABEL>');
return; return;
} }
// Determine if command was 'remove' to use the new IPC route, otherwise 'delete' // Determine if command was 'remove' to use the new IPC route, otherwise 'delete'
const cmd = String(argvArg._[0]); const cmd = String(argvArg._[0]);
const useRemove = cmd === 'remove'; const isRemoveAlias = cmd === 'remove';
console.log(`${useRemove ? 'Removing' : 'Deleting'} process: ${id}`); console.log(`${isRemoveAlias ? 'Removing' : 'Deleting'} process: ${target}`);
const response = await tspmIpcClient.request(useRemove ? 'remove' : 'delete', { id } as any); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
// Always call daemon 'delete'; 'remove' is CLI alias only
const response = await tspmIpcClient.request('delete', { id: resolved.id } as any);
if (response.success) { if (response.success) {
console.log(`${response.message || (useRemove ? 'Removed successfully' : 'Deleted successfully')}`); console.log(`${response.message || (isRemoveAlias ? 'Removed successfully' : 'Deleted successfully')}`);
} else { } else {
console.error(`✗ Failed to ${useRemove ? 'remove' : 'delete'} process: ${response.message}`); console.error(`✗ Failed to ${isRemoveAlias ? 'remove' : 'delete'} process: ${response.message}`);
} }
}, },
{ actionLabel: 'delete/remove process' }, { actionLabel: 'delete/remove process' },

View File

@@ -9,16 +9,17 @@ export function registerDescribeCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'describe', 'describe',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm describe <id>'); console.log('Usage: tspm describe <id | id:N | name:LABEL>');
return; return;
} }
const response = await tspmIpcClient.request('describe', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const response = await tspmIpcClient.request('describe', { id: resolved.id });
console.log(`Process Details: ${id}`); console.log(`Process Details: ${response.config.name || resolved.id}`);
console.log('─'.repeat(40)); console.log('─'.repeat(40));
console.log(`Status: ${response.processInfo.status}`); console.log(`Status: ${response.processInfo.status}`);
console.log(`PID: ${response.processInfo.pid || 'N/A'}`); console.log(`PID: ${response.processInfo.pid || 'N/A'}`);

View File

@@ -9,17 +9,16 @@ export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'edit', 'edit',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const idRaw = argvArg._[1]; const target = argvArg._[1];
if (!idRaw) { if (!target) {
console.error('Error: Please provide a process ID to edit'); console.error('Error: Please provide a process target to edit');
console.log('Usage: tspm edit <id>'); console.log('Usage: tspm edit <id | id:N | name:LABEL>');
return; return;
} }
const id = idRaw; // Resolve and load current config
const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
// Load current config const { config } = await tspmIpcClient.request('describe', { id: resolved.id });
const { config } = await tspmIpcClient.request('describe', { id });
// Interactive editing is temporarily disabled - needs smartinteract API update // Interactive editing is temporarily disabled - needs smartinteract API update
console.log('Interactive editing is temporarily disabled.'); console.log('Interactive editing is temporarily disabled.');
@@ -63,7 +62,7 @@ export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
}; };
const updateResponse = await tspmIpcClient.request('update', { const updateResponse = await tspmIpcClient.request('update', {
id, id: resolved.id,
updates, updates,
}); });
@@ -73,4 +72,3 @@ export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
{ actionLabel: 'edit process config' }, { actionLabel: 'edit process config' },
); );
} }

View File

@@ -11,10 +11,10 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'logs', 'logs',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm logs <id> [options]'); console.log('Usage: tspm logs <id | id:N | name:LABEL> [options]');
console.log('\nOptions:'); console.log('\nOptions:');
console.log(' --lines <n> Number of lines to show (default: 50)'); console.log(' --lines <n> Number of lines to show (default: 50)');
console.log(' --follow Stream logs in real-time (like tail -f)'); console.log(' --follow Stream logs in real-time (like tail -f)');
@@ -24,6 +24,8 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
const lines = getNumber(argvArg, 'lines', 50); const lines = getNumber(argvArg, 'lines', 50);
const follow = getBool(argvArg, 'follow', 'f'); const follow = getBool(argvArg, 'follow', 'f');
const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const id = resolved.id;
const response = await tspmIpcClient.request('getLogs', { id, lines }); const response = await tspmIpcClient.request('getLogs', { id, lines });
if (!follow) { if (!follow) {
@@ -44,7 +46,7 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
} }
// Streaming mode // Streaming mode
console.log(`Logs for process: ${id} (streaming...)`); console.log(`Logs for process: ${resolved.name || id} (streaming...)`);
console.log('─'.repeat(60)); console.log('─'.repeat(60));
let lastSeq = 0; let lastSeq = 0;

View File

@@ -1,6 +1,5 @@
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';
@@ -11,9 +10,9 @@ export function registerRestartCommand(smartcli: plugins.smartcli.Smartcli) {
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const arg = argvArg._[1]; const arg = argvArg._[1];
if (!arg) { if (!arg) {
console.error('Error: Please provide a process ID or "all"'); console.error('Error: Please provide a process target or "all"');
console.log('Usage:'); console.log('Usage:');
console.log(' tspm restart <id>'); console.log(' tspm restart <id | id:N | name:LABEL>');
console.log(' tspm restart all'); console.log(' tspm restart all');
return; return;
} }
@@ -33,12 +32,13 @@ export function registerRestartCommand(smartcli: plugins.smartcli.Smartcli) {
return; return;
} }
const id = String(arg); const target = String(arg);
console.log(`Restarting process: ${id}`); console.log(`Restarting process: ${target}`);
const response = await tspmIpcClient.request('restart', { id: toProcessId(id) }); const resolved = await tspmIpcClient.request('resolveTarget', { target });
const response = await tspmIpcClient.request('restart', { id: resolved.id });
console.log(`✓ Process restarted successfully`); console.log(`✓ Process restarted successfully`);
console.log(` ID: ${response.processId}`); console.log(` ID: ${response.processId}${resolved.name ? ` (name: ${resolved.name})` : ''}`);
console.log(` PID: ${response.pid || 'N/A'}`); console.log(` PID: ${response.pid || 'N/A'}`);
console.log(` Status: ${response.status}`); console.log(` Status: ${response.status}`);
}, },

View File

@@ -0,0 +1,62 @@
import * as plugins from '../../plugins.js';
import { tspmIpcClient } from '../../../client/tspm.ipcclient.js';
import type { CliArguments } from '../../types.js';
import { registerIpcCommand } from '../../registration/index.js';
export function registerSearchCommand(smartcli: plugins.smartcli.Smartcli) {
registerIpcCommand(
smartcli,
'search',
async (argvArg: CliArguments) => {
const query = String(argvArg._[1] || '').trim();
if (!query) {
console.error('Error: Please provide a search query');
console.log('Usage: tspm search <name-fragment | id-fragment>');
return;
}
// Fetch list of processes, then enrich with names via describe
const listRes = await tspmIpcClient.request('list', {});
const processes = listRes.processes;
// If there are no processes, short-circuit
if (processes.length === 0) {
console.log('No processes found.');
return;
}
const lowerQ = query.toLowerCase();
const matches: Array<{ id: number; name?: string }> = [];
// Collect describe calls to obtain names
for (const proc of processes) {
try {
const desc = await tspmIpcClient.request('describe', { id: proc.id });
const name = desc.config.name || '';
const idStr = String(proc.id);
if (name.toLowerCase().includes(lowerQ) || idStr.includes(query)) {
matches.push({ id: proc.id, name });
}
} catch {
// Ignore describe errors for individual processes
}
}
if (matches.length === 0) {
console.log(`No matches for "${query}"`);
return;
}
console.log(`Matches for "${query}":`);
for (const m of matches) {
if (m.name) {
console.log(`- id:${m.id}\tname:${m.name}`);
} else {
console.log(`- id:${m.id}`);
}
}
},
{ actionLabel: 'search processes' },
);
}

View File

@@ -10,17 +10,18 @@ export function registerStartCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'start', 'start',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID to start'); console.error('Error: Please provide a process target to start');
console.log('Usage: tspm start <id>'); console.log('Usage: tspm start <id | id:N | name:LABEL>');
return; return;
} }
console.log(`Starting process id ${id}...`); console.log(`Starting process: ${target}...`);
const response = await tspmIpcClient.request('startById', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const response = await tspmIpcClient.request('startById', { id: resolved.id });
console.log('✓ Process started'); console.log('✓ Process started');
console.log(` ID: ${response.processId}`); console.log(` ID: ${response.processId}${resolved.name ? ` (name: ${resolved.name})` : ''}`);
console.log(` PID: ${response.pid || 'N/A'}`); console.log(` PID: ${response.pid || 'N/A'}`);
console.log(` Status: ${response.status}`); console.log(` Status: ${response.status}`);
}, },

View File

@@ -8,15 +8,16 @@ export function registerStopCommand(smartcli: plugins.smartcli.Smartcli) {
smartcli, smartcli,
'stop', 'stop',
async (argvArg: CliArguments) => { async (argvArg: CliArguments) => {
const id = argvArg._[1]; const target = argvArg._[1];
if (!id) { if (!target) {
console.error('Error: Please provide a process ID'); console.error('Error: Please provide a process target');
console.log('Usage: tspm stop <id>'); console.log('Usage: tspm stop <id | id:N | name:LABEL>');
return; return;
} }
console.log(`Stopping process: ${id}`); console.log(`Stopping process: ${target}`);
const response = await tspmIpcClient.request('stop', { id }); const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
const response = await tspmIpcClient.request('stop', { id: resolved.id });
if (response.success) { if (response.success) {
console.log(`${response.message}`); console.log(`${response.message}`);

View File

@@ -10,6 +10,7 @@ import { registerAddCommand } from './commands/process/add.js';
import { registerStopCommand } from './commands/process/stop.js'; import { registerStopCommand } from './commands/process/stop.js';
import { registerRestartCommand } from './commands/process/restart.js'; import { registerRestartCommand } from './commands/process/restart.js';
import { registerDeleteCommand } from './commands/process/delete.js'; import { registerDeleteCommand } from './commands/process/delete.js';
import { registerSearchCommand } from './commands/process/search.js';
import { registerListCommand } from './commands/process/list.js'; import { registerListCommand } from './commands/process/list.js';
import { registerDescribeCommand } from './commands/process/describe.js'; import { registerDescribeCommand } from './commands/process/describe.js';
import { registerLogsCommand } from './commands/process/logs.js'; import { registerLogsCommand } from './commands/process/logs.js';
@@ -74,6 +75,7 @@ export const run = async (): Promise<void> => {
registerDescribeCommand(smartcliInstance); registerDescribeCommand(smartcliInstance);
registerLogsCommand(smartcliInstance); registerLogsCommand(smartcliInstance);
registerEditCommand(smartcliInstance); registerEditCommand(smartcliInstance);
registerSearchCommand(smartcliInstance);
// Batch commands // Batch commands
registerStartAllCommand(smartcliInstance); registerStartAllCommand(smartcliInstance);

View File

@@ -156,6 +156,11 @@ export class ProcessManager extends EventEmitter {
this.updateProcessInfo(config.id, { pid: undefined }); this.updateProcessInfo(config.id, { pid: undefined });
}); });
// Set up failure handler to mark process as errored
monitor.on('failed', () => {
this.updateProcessInfo(config.id, { status: 'errored', pid: undefined });
});
await monitor.start(); await monitor.start();
// Wait a moment for the process to spawn and get its PID // Wait a moment for the process to spawn and get its PID
@@ -327,6 +332,11 @@ export class ProcessManager extends EventEmitter {
}); });
} }
// Mark errored on failure events
newMonitor.on('failed', () => {
this.updateProcessInfo(id, { status: 'errored', pid: undefined });
});
this.logger.info(`Successfully restarted process with id '${id}'`); this.logger.info(`Successfully restarted process with id '${id}'`);
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
const processError = new ProcessError( const processError = new ProcessError(

View File

@@ -18,6 +18,10 @@ export class ProcessMonitor extends EventEmitter {
private processId?: ProcessId; private processId?: ProcessId;
private currentLogMemorySize: number = 0; private currentLogMemorySize: number = 0;
private readonly MAX_LOG_MEMORY_SIZE = 10 * 1024 * 1024; // 10MB private readonly MAX_LOG_MEMORY_SIZE = 10 * 1024 * 1024; // 10MB
private restartTimer: NodeJS.Timeout | null = null;
private lastRetryAt: number | null = null;
private readonly MAX_RETRIES = 10;
private readonly RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour
constructor(config: IMonitorConfig & { id?: ProcessId }) { constructor(config: IMonitorConfig & { id?: ProcessId }) {
super(); super();
@@ -132,10 +136,7 @@ export class ProcessMonitor extends EventEmitter {
this.emit('exit', code, signal); this.emit('exit', code, signal);
if (!this.stopped) { if (!this.stopped) {
this.logger.info('Restarting process...'); this.scheduleRestart('exit');
this.log('Restarting process...');
this.restartCount++;
this.spawnProcess();
} else { } else {
this.logger.debug( this.logger.debug(
'Not restarting process because monitor is stopped', 'Not restarting process because monitor is stopped',
@@ -164,10 +165,7 @@ export class ProcessMonitor extends EventEmitter {
} }
if (!this.stopped) { if (!this.stopped) {
this.logger.info('Restarting process due to error...'); this.scheduleRestart('error');
this.log('Restarting process due to error...');
this.restartCount++;
this.spawnProcess();
} else { } else {
this.logger.debug('Not restarting process because monitor is stopped'); this.logger.debug('Not restarting process because monitor is stopped');
} }
@@ -185,6 +183,49 @@ export class ProcessMonitor extends EventEmitter {
} }
} }
/**
* Schedule a restart with incremental debounce and failure cutoff.
*/
private scheduleRestart(reason: 'exit' | 'error'): void {
const now = Date.now();
// Reset window: if last retry was more than 1 hour ago, reset counter
if (this.lastRetryAt && now - this.lastRetryAt >= this.RESET_WINDOW_MS) {
this.logger.info('Resetting retry counter after 1 hour window');
this.restartCount = 0;
}
// Already at or above max retries?
if (this.restartCount >= this.MAX_RETRIES) {
const msg = 'Maximum restart attempts reached. Marking process as failed.';
this.logger.warn(msg);
this.log(msg);
this.stopped = true;
// Emit a specific event so manager can set status to errored
this.emit('failed');
return;
}
// Increment and compute delay (1..10 seconds)
this.restartCount++;
const delaySec = Math.min(this.restartCount, 10);
const msg = `Restarting process in ${delaySec}s (attempt ${this.restartCount}/${this.MAX_RETRIES}) due to ${reason}...`;
this.logger.info(msg);
this.log(msg);
// Clear existing timer if any, then schedule
if (this.restartTimer) {
clearTimeout(this.restartTimer);
}
this.lastRetryAt = now;
this.restartTimer = setTimeout(() => {
// If stopped in the meantime, do not spawn
if (this.stopped) {
return;
}
this.spawnProcess();
}, delaySec * 1000);
}
/** /**
* Monitor the process group's memory usage. If the total memory exceeds the limit, * 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. * kill the process group so that the 'exit' handler can restart it.

View File

@@ -208,6 +208,8 @@ export class TspmDaemon {
async (request: RequestForMethod<'delete'>) => { async (request: RequestForMethod<'delete'>) => {
try { try {
const id = toProcessId(request.id); const id = toProcessId(request.id);
// Ensure desired state reflects stopped before deletion
await this.tspmInstance.setDesiredState(id, 'stopped');
await this.tspmInstance.delete(id); await this.tspmInstance.delete(id);
return { return {
success: true, success: true,
@@ -246,18 +248,7 @@ export class TspmDaemon {
}, },
); );
this.ipcServer.onMessage( // Note: 'remove' is only a CLI alias. Daemon exposes 'delete' only.
'remove',
async (request: RequestForMethod<'remove'>) => {
try {
const id = toProcessId(request.id);
await this.tspmInstance.delete(id);
return { success: true, message: `Process ${id} deleted successfully` };
} catch (error) {
throw new Error(`Failed to remove process: ${error.message}`);
}
},
);
this.ipcServer.onMessage( this.ipcServer.onMessage(
'list', 'list',
@@ -291,6 +282,58 @@ export class TspmDaemon {
}, },
); );
// Resolve target (id:n | name:foo | numeric string) to ProcessId
this.ipcServer.onMessage(
'resolveTarget',
async (request: RequestForMethod<'resolveTarget'>) => {
const raw = String(request.target || '').trim();
if (!raw) {
throw new Error('Empty target');
}
// id:<n>
if (/^id:\s*\d+$/i.test(raw)) {
const idNum = raw.split(':')[1].trim();
const id = toProcessId(idNum);
const config = this.tspmInstance.processConfigs.get(id);
if (!config) throw new Error(`Process ${id} not found`);
return { id, name: config.name } as ResponseForMethod<'resolveTarget'>;
}
// name:<label>
if (/^name:/i.test(raw)) {
const name = raw.slice(raw.indexOf(':') + 1).trim();
if (!name) throw new Error('Missing name after name:');
const matches = Array.from(this.tspmInstance.processConfigs.values()).filter(
(c) => (c.name || '').trim() === name,
);
if (matches.length === 0) {
throw new Error(`No process found with name "${name}"`);
}
if (matches.length > 1) {
const ids = matches.map((c) => String(c.id)).join(', ');
throw new Error(
`Multiple processes found with name "${name}": ids [${ids}]. Please use id:<n>.`,
);
}
return { id: matches[0].id, name } as ResponseForMethod<'resolveTarget'>;
}
// bare numeric id
if (/^\d+$/.test(raw)) {
const id = toProcessId(raw);
const config = this.tspmInstance.processConfigs.get(id);
if (!config) throw new Error(`Process ${id} not found`);
return { id, name: config.name } as ResponseForMethod<'resolveTarget'>;
}
// Unknown format
throw new Error(
'Unsupported target format. Use numeric id (e.g. 1), id:<n> (e.g. id:1), or name:<label> (e.g. name:api).',
);
},
);
// Batch operations handlers // Batch operations handlers
this.ipcServer.onMessage( this.ipcServer.onMessage(
'startAll', 'startAll',

View File

@@ -240,14 +240,6 @@ export interface AddResponse {
} }
// Remove (delete config and stop if running) // Remove (delete config and stop if running)
export interface RemoveRequest {
id: ProcessId;
}
export interface RemoveResponse {
success: boolean;
message?: string;
}
// Update (modify existing config) // Update (modify existing config)
export interface UpdateRequest { export interface UpdateRequest {
@@ -260,6 +252,16 @@ export interface UpdateResponse {
config: IProcessConfig; config: IProcessConfig;
} }
// Resolve a user-provided target (id:n or name:foo or numeric string) to a ProcessId
export interface ResolveTargetRequest {
target: string;
}
export interface ResolveTargetResponse {
id: ProcessId;
name?: string;
}
// Type mappings for methods // Type mappings for methods
export type IpcMethodMap = { export type IpcMethodMap = {
start: { request: StartRequest; response: StartResponse }; start: { request: StartRequest; response: StartResponse };
@@ -269,7 +271,6 @@ export type IpcMethodMap = {
delete: { request: DeleteRequest; response: DeleteResponse }; delete: { request: DeleteRequest; response: DeleteResponse };
add: { request: AddRequest; response: AddResponse }; add: { request: AddRequest; response: AddResponse };
update: { request: UpdateRequest; response: UpdateResponse }; update: { request: UpdateRequest; response: UpdateResponse };
remove: { request: RemoveRequest; response: RemoveResponse };
list: { request: ListRequest; response: ListResponse }; list: { request: ListRequest; response: ListResponse };
describe: { request: DescribeRequest; response: DescribeResponse }; describe: { request: DescribeRequest; response: DescribeResponse };
getLogs: { request: GetLogsRequest; response: GetLogsResponse }; getLogs: { request: GetLogsRequest; response: GetLogsResponse };
@@ -286,6 +287,7 @@ export type IpcMethodMap = {
response: DaemonShutdownResponse; response: DaemonShutdownResponse;
}; };
heartbeat: { request: HeartbeatRequest; response: HeartbeatResponse }; heartbeat: { request: HeartbeatRequest; response: HeartbeatResponse };
resolveTarget: { request: ResolveTargetRequest; response: ResolveTargetResponse };
}; };
// Helper type to extract request type for a method // Helper type to extract request type for a method