start-command 0.17.2 → 0.17.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +1 -1
- package/src/bin/cli.js +163 -115
- package/src/lib/args-parser.js +17 -0
- package/src/lib/execution-store.js +106 -0
- package/src/lib/spawn-helpers.js +346 -0
- package/test/args-parser.test.js +22 -0
- package/test/execution-store.test.js +121 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.17.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 89d04d6: fix: Ensure execution status is updated when process is interrupted
|
|
8
|
+
|
|
9
|
+
This fix addresses Issue #60 where `$ --status` shows "executing" for finished commands.
|
|
10
|
+
|
|
11
|
+
Changes:
|
|
12
|
+
- Added signal handlers (SIGINT, SIGTERM, SIGHUP) to update execution status when process is interrupted
|
|
13
|
+
- Added `--cleanup` and `--cleanup-dry-run` flags to clean up stale records from crashed/killed processes
|
|
14
|
+
- Added `cleanupStale()` method to ExecutionStore to detect and clean stale records
|
|
15
|
+
|
|
16
|
+
The cleanup logic detects stale records by:
|
|
17
|
+
1. Checking if the PID is still running (if on same platform)
|
|
18
|
+
2. Checking if the record has exceeded max age (default: 24 hours)
|
|
19
|
+
|
|
20
|
+
Stale records are marked as "executed" with exit code -1 to indicate abnormal termination.
|
|
21
|
+
|
|
22
|
+
## 0.17.3
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- a61f1a9: fix: Use Bun.spawn for reliable stdout capture on macOS (Issue #57)
|
|
27
|
+
|
|
28
|
+
The previous fix (v0.17.2) using `close` event instead of `exit` did not resolve the issue on macOS. After deeper investigation, we discovered the root cause: Bun's event loop may exit before the `close` event callback can be scheduled, especially for fast commands like `echo`.
|
|
29
|
+
|
|
30
|
+
This fix uses Bun's native `Bun.spawn` API with async/await for stream handling when running on Bun runtime. This approach keeps the event loop alive until all streams are consumed and the process exits.
|
|
31
|
+
- Use `Bun.spawn` instead of `node:child_process` when running on Bun
|
|
32
|
+
- Use async stream readers with `getReader()` for real-time output display
|
|
33
|
+
- Use `await proc.exited` to ensure process completion before exiting
|
|
34
|
+
- Fall back to `node:child_process` with `close` event for Node.js compatibility
|
|
35
|
+
- Add verbose logging with `--verbose` flag for debugging
|
|
36
|
+
|
|
3
37
|
## 0.17.2
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/package.json
CHANGED
package/src/bin/cli.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
const { spawn } = require('child_process');
|
|
4
3
|
const process = require('process');
|
|
5
4
|
const os = require('os');
|
|
6
5
|
const fs = require('fs');
|
|
@@ -35,6 +34,7 @@ const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
|
|
|
35
34
|
const { queryStatus } = require('../lib/status-formatter');
|
|
36
35
|
const { printVersion } = require('../lib/version');
|
|
37
36
|
const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
|
|
37
|
+
const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
|
|
38
38
|
|
|
39
39
|
// Configuration from environment variables
|
|
40
40
|
const config = {
|
|
@@ -73,6 +73,10 @@ const config = {
|
|
|
73
73
|
// Global execution store instance (initialized lazily)
|
|
74
74
|
let executionStore = null;
|
|
75
75
|
|
|
76
|
+
// Global reference to current execution record for cleanup on signals
|
|
77
|
+
// This allows us to mark the execution as completed if the process is interrupted
|
|
78
|
+
let currentExecutionRecord = null;
|
|
79
|
+
|
|
76
80
|
/**
|
|
77
81
|
* Get the execution store instance
|
|
78
82
|
* @returns {ExecutionStore}
|
|
@@ -87,6 +91,57 @@ function getExecutionStore() {
|
|
|
87
91
|
return executionStore;
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Cleanup handler for signals and process exit
|
|
96
|
+
* Marks the current execution as completed with an appropriate exit code
|
|
97
|
+
* @param {string} signal - The signal that triggered the cleanup (e.g., 'SIGINT', 'SIGTERM')
|
|
98
|
+
* @param {number} exitCode - The exit code to use
|
|
99
|
+
*/
|
|
100
|
+
function cleanupExecution(signal, exitCode) {
|
|
101
|
+
if (currentExecutionRecord && executionStore) {
|
|
102
|
+
// Signal-based exit codes:
|
|
103
|
+
// SIGINT (Ctrl+C): 130 (128 + 2)
|
|
104
|
+
// SIGTERM: 143 (128 + 15)
|
|
105
|
+
// SIGHUP: 129 (128 + 1)
|
|
106
|
+
currentExecutionRecord.complete(exitCode);
|
|
107
|
+
try {
|
|
108
|
+
executionStore.save(currentExecutionRecord);
|
|
109
|
+
if (config.verbose) {
|
|
110
|
+
console.error(
|
|
111
|
+
`\n[Tracking] Execution ${currentExecutionRecord.uuid} marked as completed (${signal}, exit code: ${exitCode})`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (config.verbose) {
|
|
116
|
+
console.error(
|
|
117
|
+
`[Tracking] Warning: Could not save execution record on ${signal}: ${err.message}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Clear the reference to prevent double cleanup
|
|
122
|
+
currentExecutionRecord = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Set up signal handlers to ensure execution status is updated on interruption
|
|
127
|
+
// SIGINT (Ctrl+C) - exit code 130 (128 + signal number 2)
|
|
128
|
+
process.on('SIGINT', () => {
|
|
129
|
+
cleanupExecution('SIGINT', 130);
|
|
130
|
+
process.exit(130);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// SIGTERM (kill command) - exit code 143 (128 + signal number 15)
|
|
134
|
+
process.on('SIGTERM', () => {
|
|
135
|
+
cleanupExecution('SIGTERM', 143);
|
|
136
|
+
process.exit(143);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// SIGHUP (terminal closed) - exit code 129 (128 + signal number 1)
|
|
140
|
+
process.on('SIGHUP', () => {
|
|
141
|
+
cleanupExecution('SIGHUP', 129);
|
|
142
|
+
process.exit(129);
|
|
143
|
+
});
|
|
144
|
+
|
|
90
145
|
// Get all arguments passed after the command
|
|
91
146
|
const args = process.argv.slice(2);
|
|
92
147
|
|
|
@@ -138,6 +193,12 @@ if (wrapperOptions.status) {
|
|
|
138
193
|
process.exit(0);
|
|
139
194
|
}
|
|
140
195
|
|
|
196
|
+
// Handle --cleanup flag
|
|
197
|
+
if (wrapperOptions.cleanup) {
|
|
198
|
+
handleCleanup(wrapperOptions.cleanupDryRun);
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
|
|
141
202
|
// Check if no command was provided
|
|
142
203
|
if (!parsedCommand || parsedCommand.trim() === '') {
|
|
143
204
|
console.error('Error: No command provided');
|
|
@@ -206,6 +267,53 @@ function handleStatusQuery(uuid, outputFormat) {
|
|
|
206
267
|
}
|
|
207
268
|
}
|
|
208
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Handle --cleanup flag
|
|
272
|
+
* Cleans up stale "executing" records (processes that crashed or were killed)
|
|
273
|
+
* @param {boolean} dryRun - If true, just report what would be cleaned
|
|
274
|
+
*/
|
|
275
|
+
function handleCleanup(dryRun) {
|
|
276
|
+
const store = getExecutionStore();
|
|
277
|
+
if (!store) {
|
|
278
|
+
console.error('Error: Execution tracking is disabled.');
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const result = store.cleanupStale({ dryRun });
|
|
283
|
+
|
|
284
|
+
if (result.errors.length > 0) {
|
|
285
|
+
for (const error of result.errors) {
|
|
286
|
+
console.error(`Error: ${error}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (result.records.length === 0) {
|
|
291
|
+
console.log('No stale records found.');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (dryRun) {
|
|
296
|
+
console.log(
|
|
297
|
+
`Found ${result.records.length} stale record(s) that would be cleaned up:\n`
|
|
298
|
+
);
|
|
299
|
+
} else {
|
|
300
|
+
console.log(`Cleaned up ${result.cleaned} stale record(s):\n`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
for (const record of result.records) {
|
|
304
|
+
const startTime = new Date(record.startTime).toLocaleString();
|
|
305
|
+
console.log(` UUID: ${record.uuid}`);
|
|
306
|
+
console.log(` Command: ${record.command}`);
|
|
307
|
+
console.log(` Started: ${startTime}`);
|
|
308
|
+
console.log(` PID: ${record.pid || 'N/A'}`);
|
|
309
|
+
console.log('');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (dryRun) {
|
|
313
|
+
console.log('Run with --cleanup to actually clean up these records.');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
209
317
|
/** Print usage information */
|
|
210
318
|
function printUsage() {
|
|
211
319
|
console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>]
|
|
@@ -225,6 +333,8 @@ Options:
|
|
|
225
333
|
--auto-remove-docker-container Auto-remove docker container after exit
|
|
226
334
|
--use-command-stream Use command-stream library for execution (experimental)
|
|
227
335
|
--status <uuid> Show status of execution by UUID (--output-format: links-notation|json|text)
|
|
336
|
+
--cleanup Clean up stale "executing" records (crashed/killed processes)
|
|
337
|
+
--cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
|
|
228
338
|
--version, -v Show version information
|
|
229
339
|
|
|
230
340
|
Examples:
|
|
@@ -411,8 +521,9 @@ async function runWithIsolation(
|
|
|
411
521
|
);
|
|
412
522
|
console.log('');
|
|
413
523
|
|
|
414
|
-
// Save initial execution record
|
|
524
|
+
// Save initial execution record and set global reference for signal cleanup
|
|
415
525
|
if (executionRecord && store) {
|
|
526
|
+
currentExecutionRecord = executionRecord;
|
|
416
527
|
try {
|
|
417
528
|
store.save(executionRecord);
|
|
418
529
|
} catch (err) {
|
|
@@ -478,7 +589,7 @@ async function runWithIsolation(
|
|
|
478
589
|
// Write log file
|
|
479
590
|
writeLogFile(logFilePath, logContent);
|
|
480
591
|
|
|
481
|
-
// Update execution record as completed
|
|
592
|
+
// Update execution record as completed and clear global reference
|
|
482
593
|
if (executionRecord && store) {
|
|
483
594
|
executionRecord.complete(exitCode);
|
|
484
595
|
try {
|
|
@@ -490,6 +601,8 @@ async function runWithIsolation(
|
|
|
490
601
|
);
|
|
491
602
|
}
|
|
492
603
|
}
|
|
604
|
+
// Clear global reference since we've completed normally
|
|
605
|
+
currentExecutionRecord = null;
|
|
493
606
|
}
|
|
494
607
|
|
|
495
608
|
// Cleanup: delete the created user if we created one (unless --keep-user)
|
|
@@ -529,11 +642,18 @@ async function runWithIsolation(
|
|
|
529
642
|
}
|
|
530
643
|
|
|
531
644
|
/**
|
|
532
|
-
* Run command directly (without isolation)
|
|
645
|
+
* Run command directly (without isolation)
|
|
646
|
+
*
|
|
647
|
+
* Uses Bun.spawn when running on Bun for reliable event handling on macOS.
|
|
648
|
+
* Falls back to node:child_process for Node.js compatibility.
|
|
649
|
+
*
|
|
650
|
+
* Issue #57: On macOS with Bun, node:child_process events may not fire reliably
|
|
651
|
+
* before the event loop exits. Bun.spawn provides more reliable stream handling.
|
|
652
|
+
*
|
|
533
653
|
* @param {string} cmd - Command to execute
|
|
534
654
|
* @param {string} sessionId - Session UUID for tracking
|
|
535
655
|
*/
|
|
536
|
-
function runDirect(cmd, sessionId) {
|
|
656
|
+
async function runDirect(cmd, sessionId) {
|
|
537
657
|
// Get the command name (first word of the actual command to execute)
|
|
538
658
|
const commandName = cmd.split(' ')[0];
|
|
539
659
|
|
|
@@ -573,6 +693,8 @@ function runDirect(cmd, sessionId) {
|
|
|
573
693
|
runtimeVersion,
|
|
574
694
|
},
|
|
575
695
|
});
|
|
696
|
+
// Set global reference for signal cleanup
|
|
697
|
+
currentExecutionRecord = executionRecord;
|
|
576
698
|
}
|
|
577
699
|
|
|
578
700
|
// Log header
|
|
@@ -606,76 +728,10 @@ function runDirect(cmd, sessionId) {
|
|
|
606
728
|
);
|
|
607
729
|
console.log('');
|
|
608
730
|
|
|
609
|
-
//
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
// Update execution record with PID and save initial state
|
|
616
|
-
if (executionRecord && store) {
|
|
617
|
-
executionRecord.pid = child.pid;
|
|
618
|
-
try {
|
|
619
|
-
store.save(executionRecord);
|
|
620
|
-
} catch (err) {
|
|
621
|
-
if (config.verbose) {
|
|
622
|
-
console.error(
|
|
623
|
-
`[Tracking] Warning: Could not save execution record: ${err.message}`
|
|
624
|
-
);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// Capture stdout
|
|
630
|
-
child.stdout.on('data', (data) => {
|
|
631
|
-
const text = data.toString();
|
|
632
|
-
process.stdout.write(text);
|
|
633
|
-
logContent += text;
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
// Capture stderr
|
|
637
|
-
child.stderr.on('data', (data) => {
|
|
638
|
-
const text = data.toString();
|
|
639
|
-
process.stderr.write(text);
|
|
640
|
-
logContent += text;
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
// Handle process close (not 'exit' - we need to wait for all stdio to be closed)
|
|
644
|
-
// The 'close' event fires after all stdio streams have been closed, ensuring
|
|
645
|
-
// all stdout/stderr data has been received. The 'exit' event can fire before
|
|
646
|
-
// buffered data is received, causing output loss on macOS (Issue #57).
|
|
647
|
-
child.on('close', (code) => {
|
|
648
|
-
const exitCode = code || 0;
|
|
649
|
-
const endTime = getTimestamp();
|
|
650
|
-
|
|
651
|
-
// Log footer
|
|
652
|
-
logContent += `\n${'='.repeat(50)}\n`;
|
|
653
|
-
logContent += `Finished: ${endTime}\n`;
|
|
654
|
-
logContent += `Exit Code: ${exitCode}\n`;
|
|
655
|
-
|
|
656
|
-
// Write log file
|
|
657
|
-
try {
|
|
658
|
-
fs.writeFileSync(logFilePath, logContent, 'utf8');
|
|
659
|
-
} catch (err) {
|
|
660
|
-
console.error(`\nWarning: Could not save log file: ${err.message}`);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// Update execution record as completed
|
|
664
|
-
if (executionRecord && store) {
|
|
665
|
-
executionRecord.complete(exitCode);
|
|
666
|
-
try {
|
|
667
|
-
store.save(executionRecord);
|
|
668
|
-
} catch (err) {
|
|
669
|
-
if (config.verbose) {
|
|
670
|
-
console.error(
|
|
671
|
-
`[Tracking] Warning: Could not update execution record: ${err.message}`
|
|
672
|
-
);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// Print finish block
|
|
678
|
-
const durationMs = Date.now() - startTimeMs;
|
|
731
|
+
// Completion callback
|
|
732
|
+
const onComplete = (exitCode, endTime, _logContent, durationMs) => {
|
|
733
|
+
// Clear global reference since execution completed normally
|
|
734
|
+
currentExecutionRecord = null;
|
|
679
735
|
console.log('');
|
|
680
736
|
console.log(
|
|
681
737
|
createFinishBlock({
|
|
@@ -686,47 +742,16 @@ function runDirect(cmd, sessionId) {
|
|
|
686
742
|
durationMs,
|
|
687
743
|
})
|
|
688
744
|
);
|
|
689
|
-
|
|
690
|
-
// If command failed, try to auto-report
|
|
691
745
|
if (exitCode !== 0) {
|
|
692
746
|
handleFailure(config, commandName, cmd, exitCode, logFilePath);
|
|
693
747
|
}
|
|
694
|
-
|
|
695
748
|
process.exit(exitCode);
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Handle spawn errors
|
|
699
|
-
child.on('error', (err) => {
|
|
700
|
-
const endTime = getTimestamp();
|
|
701
|
-
const durationMs = Date.now() - startTimeMs;
|
|
702
|
-
const errorMessage = `Error executing command: ${err.message}`;
|
|
703
|
-
|
|
704
|
-
logContent += `\n${errorMessage}\n`;
|
|
705
|
-
logContent += `\n${'='.repeat(50)}\n`;
|
|
706
|
-
logContent += `Finished: ${endTime}\n`;
|
|
707
|
-
logContent += `Exit Code: 1\n`;
|
|
708
|
-
|
|
709
|
-
// Write log file
|
|
710
|
-
try {
|
|
711
|
-
fs.writeFileSync(logFilePath, logContent, 'utf8');
|
|
712
|
-
} catch (writeErr) {
|
|
713
|
-
console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Update execution record as failed
|
|
717
|
-
if (executionRecord && store) {
|
|
718
|
-
executionRecord.complete(1);
|
|
719
|
-
try {
|
|
720
|
-
store.save(executionRecord);
|
|
721
|
-
} catch (storeErr) {
|
|
722
|
-
if (config.verbose) {
|
|
723
|
-
console.error(
|
|
724
|
-
`[Tracking] Warning: Could not update execution record: ${storeErr.message}`
|
|
725
|
-
);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
}
|
|
749
|
+
};
|
|
729
750
|
|
|
751
|
+
// Error callback
|
|
752
|
+
const onError = (errorMessage, endTime, durationMs) => {
|
|
753
|
+
// Clear global reference since execution completed (with error)
|
|
754
|
+
currentExecutionRecord = null;
|
|
730
755
|
console.error(`\n${errorMessage}`);
|
|
731
756
|
console.log('');
|
|
732
757
|
console.log(
|
|
@@ -738,11 +763,30 @@ function runDirect(cmd, sessionId) {
|
|
|
738
763
|
durationMs,
|
|
739
764
|
})
|
|
740
765
|
);
|
|
741
|
-
|
|
742
766
|
handleFailure(config, commandName, cmd, 1, logFilePath);
|
|
743
|
-
|
|
744
767
|
process.exit(1);
|
|
745
|
-
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// Use Bun.spawn when running on Bun for reliable event handling on macOS
|
|
771
|
+
// Fall back to node:child_process for Node.js compatibility
|
|
772
|
+
const spawnOptions = {
|
|
773
|
+
shell,
|
|
774
|
+
shellArgs,
|
|
775
|
+
logFilePath,
|
|
776
|
+
logContent,
|
|
777
|
+
startTimeMs,
|
|
778
|
+
executionRecord,
|
|
779
|
+
store,
|
|
780
|
+
config,
|
|
781
|
+
onComplete,
|
|
782
|
+
onError,
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
if (typeof Bun !== 'undefined') {
|
|
786
|
+
await runWithBunSpawn(spawnOptions);
|
|
787
|
+
} else {
|
|
788
|
+
runWithNodeSpawn(spawnOptions);
|
|
789
|
+
}
|
|
746
790
|
}
|
|
747
791
|
|
|
748
792
|
/**
|
|
@@ -801,6 +845,8 @@ async function runDirectWithCommandStream(
|
|
|
801
845
|
executionMode: 'command-stream',
|
|
802
846
|
},
|
|
803
847
|
});
|
|
848
|
+
// Set global reference for signal cleanup
|
|
849
|
+
currentExecutionRecord = executionRecord;
|
|
804
850
|
}
|
|
805
851
|
|
|
806
852
|
// Log header
|
|
@@ -892,7 +938,7 @@ async function runDirectWithCommandStream(
|
|
|
892
938
|
console.error(`\nWarning: Could not save log file: ${err.message}`);
|
|
893
939
|
}
|
|
894
940
|
|
|
895
|
-
// Update execution record as completed
|
|
941
|
+
// Update execution record as completed and clear global reference
|
|
896
942
|
if (executionRecord && store) {
|
|
897
943
|
executionRecord.complete(exitCode);
|
|
898
944
|
try {
|
|
@@ -904,6 +950,8 @@ async function runDirectWithCommandStream(
|
|
|
904
950
|
);
|
|
905
951
|
}
|
|
906
952
|
}
|
|
953
|
+
// Clear global reference since we've completed normally
|
|
954
|
+
currentExecutionRecord = null;
|
|
907
955
|
}
|
|
908
956
|
|
|
909
957
|
// Print finish block
|
package/src/lib/args-parser.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* --use-command-stream Use command-stream library for command execution (experimental)
|
|
20
20
|
* --status <uuid> Show status of a previous command execution by UUID
|
|
21
21
|
* --output-format <format> Output format for status (links-notation, json, text)
|
|
22
|
+
* --cleanup Clean up stale "executing" records (processes that crashed or were killed)
|
|
23
|
+
* --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
// Debug mode from environment
|
|
@@ -91,6 +93,8 @@ function parseArgs(args) {
|
|
|
91
93
|
useCommandStream: false, // Use command-stream library for command execution
|
|
92
94
|
status: null, // UUID to show status for
|
|
93
95
|
outputFormat: null, // Output format for status (links-notation, json, text)
|
|
96
|
+
cleanup: false, // Clean up stale "executing" records
|
|
97
|
+
cleanupDryRun: false, // Show what would be cleaned without actually cleaning
|
|
94
98
|
};
|
|
95
99
|
|
|
96
100
|
let commandArgs = [];
|
|
@@ -339,6 +343,19 @@ function parseOption(args, index, options) {
|
|
|
339
343
|
return 1;
|
|
340
344
|
}
|
|
341
345
|
|
|
346
|
+
// --cleanup
|
|
347
|
+
if (arg === '--cleanup') {
|
|
348
|
+
options.cleanup = true;
|
|
349
|
+
return 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --cleanup-dry-run
|
|
353
|
+
if (arg === '--cleanup-dry-run') {
|
|
354
|
+
options.cleanup = true;
|
|
355
|
+
options.cleanupDryRun = true;
|
|
356
|
+
return 1;
|
|
357
|
+
}
|
|
358
|
+
|
|
342
359
|
// Not a recognized wrapper option
|
|
343
360
|
return 0;
|
|
344
361
|
}
|
|
@@ -517,6 +517,112 @@ class ExecutionStore {
|
|
|
517
517
|
return records.slice(0, limit);
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Clean up stale "executing" records
|
|
522
|
+
*
|
|
523
|
+
* Stale records are those that:
|
|
524
|
+
* 1. Have status "executing"
|
|
525
|
+
* 2. Either:
|
|
526
|
+
* - Their process (by PID) is no longer running (on same hostname)
|
|
527
|
+
* - They have been "executing" for longer than maxAgeMs (default: 24 hours)
|
|
528
|
+
*
|
|
529
|
+
* @param {object} options - Cleanup options
|
|
530
|
+
* @param {number} options.maxAgeMs - Max age for executing records (default: 24 hours)
|
|
531
|
+
* @param {boolean} options.dryRun - If true, just report what would be cleaned (default: false)
|
|
532
|
+
* @returns {{cleaned: number, records: ExecutionRecord[], errors: string[]}}
|
|
533
|
+
*/
|
|
534
|
+
cleanupStale(options = {}) {
|
|
535
|
+
const maxAgeMs = options.maxAgeMs || 24 * 60 * 60 * 1000; // 24 hours
|
|
536
|
+
const dryRun = options.dryRun || false;
|
|
537
|
+
|
|
538
|
+
const result = {
|
|
539
|
+
cleaned: 0,
|
|
540
|
+
records: [],
|
|
541
|
+
errors: [],
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const records = this.readLinoRecords();
|
|
545
|
+
const executingRecords = records.filter(
|
|
546
|
+
(r) => r.status === ExecutionStatus.EXECUTING
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const staleRecords = [];
|
|
550
|
+
|
|
551
|
+
for (const record of executingRecords) {
|
|
552
|
+
let isStale = false;
|
|
553
|
+
let reason = '';
|
|
554
|
+
|
|
555
|
+
// Check if the process is still running (only if on same platform)
|
|
556
|
+
if (record.pid && record.platform === process.platform) {
|
|
557
|
+
try {
|
|
558
|
+
// Signal 0 just checks if process exists
|
|
559
|
+
process.kill(record.pid, 0);
|
|
560
|
+
// Process exists, check age
|
|
561
|
+
} catch {
|
|
562
|
+
// Process doesn't exist - record is stale
|
|
563
|
+
isStale = true;
|
|
564
|
+
reason = `process ${record.pid} no longer running`;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Check age if not already determined to be stale
|
|
569
|
+
if (!isStale) {
|
|
570
|
+
const startTime = new Date(record.startTime).getTime();
|
|
571
|
+
const age = Date.now() - startTime;
|
|
572
|
+
if (age > maxAgeMs) {
|
|
573
|
+
isStale = true;
|
|
574
|
+
reason = `running for ${Math.round(age / 1000 / 60)} minutes (max: ${Math.round(maxAgeMs / 1000 / 60)} minutes)`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (isStale) {
|
|
579
|
+
this.log(`Stale record found: ${record.uuid} (${reason})`);
|
|
580
|
+
staleRecords.push(record);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
result.records = staleRecords;
|
|
585
|
+
|
|
586
|
+
if (!dryRun && staleRecords.length > 0) {
|
|
587
|
+
const lock = new LockManager(this.lockFilePath);
|
|
588
|
+
|
|
589
|
+
if (!lock.acquire()) {
|
|
590
|
+
result.errors.push('Failed to acquire lock for cleanup');
|
|
591
|
+
return result;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
// Re-read records to ensure consistency
|
|
596
|
+
const currentRecords = this.readLinoRecords();
|
|
597
|
+
|
|
598
|
+
// Update stale records to "executed" status with exit code -1 (abnormal termination)
|
|
599
|
+
for (const staleRecord of staleRecords) {
|
|
600
|
+
const index = currentRecords.findIndex(
|
|
601
|
+
(r) => r.uuid === staleRecord.uuid
|
|
602
|
+
);
|
|
603
|
+
if (index >= 0) {
|
|
604
|
+
// Mark as executed with exit code -1 to indicate abnormal termination
|
|
605
|
+
currentRecords[index].status = ExecutionStatus.EXECUTED;
|
|
606
|
+
currentRecords[index].exitCode = -1;
|
|
607
|
+
currentRecords[index].endTime = new Date().toISOString();
|
|
608
|
+
result.cleaned++;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
this.writeLinoRecords(currentRecords);
|
|
613
|
+
this.log(`Cleaned up ${result.cleaned} stale records`);
|
|
614
|
+
} catch (err) {
|
|
615
|
+
result.errors.push(`Cleanup error: ${err.message}`);
|
|
616
|
+
} finally {
|
|
617
|
+
lock.release();
|
|
618
|
+
}
|
|
619
|
+
} else if (dryRun) {
|
|
620
|
+
result.cleaned = staleRecords.length;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
|
|
520
626
|
/**
|
|
521
627
|
* Delete an execution record
|
|
522
628
|
* @param {string} uuid
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn helper functions for reliable cross-platform command execution
|
|
3
|
+
*
|
|
4
|
+
* Issue #57: On macOS with Bun, node:child_process events may not fire reliably
|
|
5
|
+
* before the event loop exits. Bun.spawn provides more reliable stream handling.
|
|
6
|
+
*
|
|
7
|
+
* This module provides two implementations:
|
|
8
|
+
* - runWithBunSpawn: Uses Bun.spawn with async/await for reliable event handling
|
|
9
|
+
* - runWithNodeSpawn: Uses node:child_process with close event for Node.js compatibility
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run command using Bun.spawn (for Bun runtime)
|
|
17
|
+
* Uses async/await for reliable stream handling on macOS
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} options - Execution options
|
|
20
|
+
* @param {string} options.shell - Shell to use
|
|
21
|
+
* @param {string[]} options.shellArgs - Shell arguments
|
|
22
|
+
* @param {string} options.logFilePath - Path to log file
|
|
23
|
+
* @param {string} options.logContent - Initial log content
|
|
24
|
+
* @param {number} options.startTimeMs - Start timestamp
|
|
25
|
+
* @param {Object} options.executionRecord - Execution tracking record
|
|
26
|
+
* @param {Object} options.store - Execution store
|
|
27
|
+
* @param {Object} options.config - CLI configuration
|
|
28
|
+
* @param {Function} options.onComplete - Callback for completion (exitCode, endTime, logContent, durationMs)
|
|
29
|
+
* @param {Function} options.onError - Callback for errors (errorMessage, endTime, durationMs)
|
|
30
|
+
*/
|
|
31
|
+
async function runWithBunSpawn(options) {
|
|
32
|
+
const {
|
|
33
|
+
shell,
|
|
34
|
+
shellArgs,
|
|
35
|
+
logFilePath,
|
|
36
|
+
startTimeMs,
|
|
37
|
+
executionRecord,
|
|
38
|
+
store,
|
|
39
|
+
config,
|
|
40
|
+
onComplete,
|
|
41
|
+
onError,
|
|
42
|
+
} = options;
|
|
43
|
+
|
|
44
|
+
let logContent = options.logContent || '';
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// Spawn the process using Bun's native API
|
|
48
|
+
const proc = Bun.spawn([shell, ...shellArgs], {
|
|
49
|
+
stdout: 'pipe',
|
|
50
|
+
stderr: 'pipe',
|
|
51
|
+
stdin: 'inherit',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Update execution record with PID and save initial state
|
|
55
|
+
if (executionRecord && store) {
|
|
56
|
+
executionRecord.pid = proc.pid;
|
|
57
|
+
try {
|
|
58
|
+
store.save(executionRecord);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (config && config.verbose) {
|
|
61
|
+
console.error(
|
|
62
|
+
`[Tracking] Warning: Could not save execution record: ${err.message}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (config && config.verbose) {
|
|
69
|
+
console.log(`[verbose] Using Bun.spawn for reliable macOS handling`);
|
|
70
|
+
console.log(`[verbose] Process PID: ${proc.pid}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Read stdout and stderr streams concurrently
|
|
74
|
+
// TextDecoder is a global in modern runtimes (Bun, Node.js 16+)
|
|
75
|
+
// eslint-disable-next-line no-undef
|
|
76
|
+
const decoder = new TextDecoder();
|
|
77
|
+
|
|
78
|
+
// Read stdout in real-time
|
|
79
|
+
const stdoutReader = proc.stdout.getReader();
|
|
80
|
+
const readStdout = async () => {
|
|
81
|
+
let output = '';
|
|
82
|
+
try {
|
|
83
|
+
while (true) {
|
|
84
|
+
const { done, value } = await stdoutReader.read();
|
|
85
|
+
if (done) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const text = decoder.decode(value);
|
|
89
|
+
process.stdout.write(text);
|
|
90
|
+
output += text;
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (config && config.verbose) {
|
|
94
|
+
console.error(`[verbose] stdout read error: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return output;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Read stderr in real-time
|
|
101
|
+
const stderrReader = proc.stderr.getReader();
|
|
102
|
+
const readStderr = async () => {
|
|
103
|
+
let output = '';
|
|
104
|
+
try {
|
|
105
|
+
while (true) {
|
|
106
|
+
const { done, value } = await stderrReader.read();
|
|
107
|
+
if (done) {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
const text = decoder.decode(value);
|
|
111
|
+
process.stderr.write(text);
|
|
112
|
+
output += text;
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (config && config.verbose) {
|
|
116
|
+
console.error(`[verbose] stderr read error: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return output;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Read both streams concurrently and wait for process to exit
|
|
123
|
+
const [stdoutContent, stderrContent, exitCode] = await Promise.all([
|
|
124
|
+
readStdout(),
|
|
125
|
+
readStderr(),
|
|
126
|
+
proc.exited,
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
// Add captured output to log content
|
|
130
|
+
logContent += stdoutContent;
|
|
131
|
+
logContent += stderrContent;
|
|
132
|
+
|
|
133
|
+
const durationMs = Date.now() - startTimeMs;
|
|
134
|
+
const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
135
|
+
|
|
136
|
+
// Write log file
|
|
137
|
+
try {
|
|
138
|
+
logContent += `\n${'='.repeat(50)}\n`;
|
|
139
|
+
logContent += `Finished: ${endTime}\n`;
|
|
140
|
+
logContent += `Exit Code: ${exitCode}\n`;
|
|
141
|
+
fs.writeFileSync(logFilePath, logContent, 'utf8');
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error(`\nWarning: Could not save log file: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Update execution record as completed
|
|
147
|
+
if (executionRecord && store) {
|
|
148
|
+
executionRecord.complete(exitCode);
|
|
149
|
+
try {
|
|
150
|
+
store.save(executionRecord);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (config && config.verbose) {
|
|
153
|
+
console.error(
|
|
154
|
+
`[Tracking] Warning: Could not update execution record: ${err.message}`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Call completion callback
|
|
161
|
+
if (onComplete) {
|
|
162
|
+
onComplete(exitCode, endTime, logContent, durationMs);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return exitCode;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const durationMs = Date.now() - startTimeMs;
|
|
168
|
+
const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
169
|
+
const errorMessage = `Error executing command: ${err.message}`;
|
|
170
|
+
|
|
171
|
+
logContent += `\n${errorMessage}\n`;
|
|
172
|
+
logContent += `\n${'='.repeat(50)}\n`;
|
|
173
|
+
logContent += `Finished: ${endTime}\n`;
|
|
174
|
+
logContent += `Exit Code: 1\n`;
|
|
175
|
+
|
|
176
|
+
// Write log file
|
|
177
|
+
try {
|
|
178
|
+
fs.writeFileSync(logFilePath, logContent, 'utf8');
|
|
179
|
+
} catch (writeErr) {
|
|
180
|
+
console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Update execution record as failed
|
|
184
|
+
if (executionRecord && store) {
|
|
185
|
+
executionRecord.complete(1);
|
|
186
|
+
try {
|
|
187
|
+
store.save(executionRecord);
|
|
188
|
+
} catch (storeErr) {
|
|
189
|
+
if (config && config.verbose) {
|
|
190
|
+
console.error(
|
|
191
|
+
`[Tracking] Warning: Could not update execution record: ${storeErr.message}`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Call error callback
|
|
198
|
+
if (onError) {
|
|
199
|
+
onError(errorMessage, endTime, durationMs);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Run command using node:child_process (for Node.js compatibility)
|
|
208
|
+
* Uses event-based handling with close event
|
|
209
|
+
*
|
|
210
|
+
* @param {Object} options - Execution options (same as runWithBunSpawn)
|
|
211
|
+
*/
|
|
212
|
+
function runWithNodeSpawn(options) {
|
|
213
|
+
const {
|
|
214
|
+
shell,
|
|
215
|
+
shellArgs,
|
|
216
|
+
logFilePath,
|
|
217
|
+
startTimeMs,
|
|
218
|
+
executionRecord,
|
|
219
|
+
store,
|
|
220
|
+
config,
|
|
221
|
+
onComplete,
|
|
222
|
+
onError,
|
|
223
|
+
} = options;
|
|
224
|
+
|
|
225
|
+
let logContent = options.logContent || '';
|
|
226
|
+
|
|
227
|
+
// Execute the command with captured output
|
|
228
|
+
const child = spawn(shell, shellArgs, {
|
|
229
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
230
|
+
shell: false,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Update execution record with PID and save initial state
|
|
234
|
+
if (executionRecord && store) {
|
|
235
|
+
executionRecord.pid = child.pid;
|
|
236
|
+
try {
|
|
237
|
+
store.save(executionRecord);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (config && config.verbose) {
|
|
240
|
+
console.error(
|
|
241
|
+
`[Tracking] Warning: Could not save execution record: ${err.message}`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Capture stdout
|
|
248
|
+
child.stdout.on('data', (data) => {
|
|
249
|
+
const text = data.toString();
|
|
250
|
+
process.stdout.write(text);
|
|
251
|
+
logContent += text;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Capture stderr
|
|
255
|
+
child.stderr.on('data', (data) => {
|
|
256
|
+
const text = data.toString();
|
|
257
|
+
process.stderr.write(text);
|
|
258
|
+
logContent += text;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Handle process close (not 'exit' - we need to wait for all stdio to be closed)
|
|
262
|
+
// The 'close' event fires after all stdio streams have been closed, ensuring
|
|
263
|
+
// all stdout/stderr data has been received. The 'exit' event can fire before
|
|
264
|
+
// buffered data is received, causing output loss on macOS (Issue #57).
|
|
265
|
+
child.on('close', (code) => {
|
|
266
|
+
const exitCode = code || 0;
|
|
267
|
+
const durationMs = Date.now() - startTimeMs;
|
|
268
|
+
const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
269
|
+
|
|
270
|
+
// Log footer
|
|
271
|
+
logContent += `\n${'='.repeat(50)}\n`;
|
|
272
|
+
logContent += `Finished: ${endTime}\n`;
|
|
273
|
+
logContent += `Exit Code: ${exitCode}\n`;
|
|
274
|
+
|
|
275
|
+
// Write log file
|
|
276
|
+
try {
|
|
277
|
+
fs.writeFileSync(logFilePath, logContent, 'utf8');
|
|
278
|
+
} catch (err) {
|
|
279
|
+
console.error(`\nWarning: Could not save log file: ${err.message}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Update execution record as completed
|
|
283
|
+
if (executionRecord && store) {
|
|
284
|
+
executionRecord.complete(exitCode);
|
|
285
|
+
try {
|
|
286
|
+
store.save(executionRecord);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (config && config.verbose) {
|
|
289
|
+
console.error(
|
|
290
|
+
`[Tracking] Warning: Could not update execution record: ${err.message}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Call completion callback
|
|
297
|
+
if (onComplete) {
|
|
298
|
+
onComplete(exitCode, endTime, logContent, durationMs);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Handle spawn errors
|
|
303
|
+
child.on('error', (err) => {
|
|
304
|
+
const durationMs = Date.now() - startTimeMs;
|
|
305
|
+
const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
306
|
+
const errorMessage = `Error executing command: ${err.message}`;
|
|
307
|
+
|
|
308
|
+
logContent += `\n${errorMessage}\n`;
|
|
309
|
+
logContent += `\n${'='.repeat(50)}\n`;
|
|
310
|
+
logContent += `Finished: ${endTime}\n`;
|
|
311
|
+
logContent += `Exit Code: 1\n`;
|
|
312
|
+
|
|
313
|
+
// Write log file
|
|
314
|
+
try {
|
|
315
|
+
fs.writeFileSync(logFilePath, logContent, 'utf8');
|
|
316
|
+
} catch (writeErr) {
|
|
317
|
+
console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Update execution record as failed
|
|
321
|
+
if (executionRecord && store) {
|
|
322
|
+
executionRecord.complete(1);
|
|
323
|
+
try {
|
|
324
|
+
store.save(executionRecord);
|
|
325
|
+
} catch (storeErr) {
|
|
326
|
+
if (config && config.verbose) {
|
|
327
|
+
console.error(
|
|
328
|
+
`[Tracking] Warning: Could not update execution record: ${storeErr.message}`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Call error callback
|
|
335
|
+
if (onError) {
|
|
336
|
+
onError(errorMessage, endTime, durationMs);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return child;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = {
|
|
344
|
+
runWithBunSpawn,
|
|
345
|
+
runWithNodeSpawn,
|
|
346
|
+
};
|
package/test/args-parser.test.js
CHANGED
|
@@ -889,3 +889,25 @@ describe('VALID_OUTPUT_FORMATS', () => {
|
|
|
889
889
|
assert.ok(VALID_OUTPUT_FORMATS.includes('text'));
|
|
890
890
|
});
|
|
891
891
|
});
|
|
892
|
+
|
|
893
|
+
describe('cleanup options', () => {
|
|
894
|
+
it('should parse --cleanup flag', () => {
|
|
895
|
+
const result = parseArgs(['--cleanup']);
|
|
896
|
+
assert.strictEqual(result.wrapperOptions.cleanup, true);
|
|
897
|
+
assert.strictEqual(result.wrapperOptions.cleanupDryRun, false);
|
|
898
|
+
assert.strictEqual(result.command, '');
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it('should parse --cleanup-dry-run flag', () => {
|
|
902
|
+
const result = parseArgs(['--cleanup-dry-run']);
|
|
903
|
+
assert.strictEqual(result.wrapperOptions.cleanup, true);
|
|
904
|
+
assert.strictEqual(result.wrapperOptions.cleanupDryRun, true);
|
|
905
|
+
assert.strictEqual(result.command, '');
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('should default cleanup to false', () => {
|
|
909
|
+
const result = parseArgs(['echo', 'hello']);
|
|
910
|
+
assert.strictEqual(result.wrapperOptions.cleanup, false);
|
|
911
|
+
assert.strictEqual(result.wrapperOptions.cleanupDryRun, false);
|
|
912
|
+
});
|
|
913
|
+
});
|
|
@@ -480,4 +480,125 @@ describe('ExecutionStore verifyConsistency', () => {
|
|
|
480
480
|
});
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
+
describe('ExecutionStore cleanupStale', () => {
|
|
484
|
+
let store;
|
|
485
|
+
|
|
486
|
+
beforeEach(() => {
|
|
487
|
+
cleanupTestDir();
|
|
488
|
+
store = new ExecutionStore({
|
|
489
|
+
appFolder: TEST_APP_FOLDER,
|
|
490
|
+
useLinks: false,
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
afterEach(() => {
|
|
495
|
+
cleanupTestDir();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('should return empty result when no stale records exist', () => {
|
|
499
|
+
// Create a completed record
|
|
500
|
+
const record = new ExecutionRecord({
|
|
501
|
+
command: 'echo 1',
|
|
502
|
+
pid: process.pid, // Current process
|
|
503
|
+
});
|
|
504
|
+
record.complete(0);
|
|
505
|
+
store.save(record);
|
|
506
|
+
|
|
507
|
+
const result = store.cleanupStale();
|
|
508
|
+
expect(result.cleaned).toBe(0);
|
|
509
|
+
expect(result.records.length).toBe(0);
|
|
510
|
+
expect(result.errors.length).toBe(0);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('should detect stale records with non-existent PIDs', () => {
|
|
514
|
+
// Create an "executing" record with a non-existent PID
|
|
515
|
+
const record = new ExecutionRecord({
|
|
516
|
+
command: 'echo stale',
|
|
517
|
+
pid: 999999999, // Non-existent PID
|
|
518
|
+
platform: process.platform,
|
|
519
|
+
});
|
|
520
|
+
store.save(record);
|
|
521
|
+
|
|
522
|
+
const result = store.cleanupStale({ dryRun: true });
|
|
523
|
+
expect(result.records.length).toBe(1);
|
|
524
|
+
expect(result.records[0].uuid).toBe(record.uuid);
|
|
525
|
+
expect(result.cleaned).toBe(1);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('should detect stale records that exceed max age', () => {
|
|
529
|
+
// Create an "executing" record with an old start time
|
|
530
|
+
const oldTime = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); // 25 hours ago
|
|
531
|
+
const record = new ExecutionRecord({
|
|
532
|
+
command: 'echo old',
|
|
533
|
+
pid: process.pid, // Current process (still running)
|
|
534
|
+
startTime: oldTime,
|
|
535
|
+
platform: process.platform,
|
|
536
|
+
});
|
|
537
|
+
store.save(record);
|
|
538
|
+
|
|
539
|
+
// Use default max age (24 hours)
|
|
540
|
+
const result = store.cleanupStale({ dryRun: true });
|
|
541
|
+
expect(result.records.length).toBe(1);
|
|
542
|
+
expect(result.records[0].uuid).toBe(record.uuid);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('should not detect records within max age as stale', () => {
|
|
546
|
+
// Create an "executing" record with a recent start time
|
|
547
|
+
const record = new ExecutionRecord({
|
|
548
|
+
command: 'echo recent',
|
|
549
|
+
pid: process.pid, // Current process (still running)
|
|
550
|
+
platform: process.platform,
|
|
551
|
+
});
|
|
552
|
+
store.save(record);
|
|
553
|
+
|
|
554
|
+
const result = store.cleanupStale({ dryRun: true });
|
|
555
|
+
// Should not find this as stale because process is running and not too old
|
|
556
|
+
expect(result.records.length).toBe(0);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('should clean up stale records when dryRun is false', () => {
|
|
560
|
+
// Create an "executing" record with a non-existent PID
|
|
561
|
+
const record = new ExecutionRecord({
|
|
562
|
+
command: 'echo cleanup-me',
|
|
563
|
+
pid: 999999999, // Non-existent PID
|
|
564
|
+
platform: process.platform,
|
|
565
|
+
});
|
|
566
|
+
store.save(record);
|
|
567
|
+
|
|
568
|
+
// Verify it's still "executing"
|
|
569
|
+
let retrieved = store.get(record.uuid);
|
|
570
|
+
expect(retrieved.status).toBe(ExecutionStatus.EXECUTING);
|
|
571
|
+
|
|
572
|
+
// Clean it up
|
|
573
|
+
const result = store.cleanupStale({ dryRun: false });
|
|
574
|
+
expect(result.cleaned).toBe(1);
|
|
575
|
+
|
|
576
|
+
// Verify it's now "executed" with exit code -1
|
|
577
|
+
retrieved = store.get(record.uuid);
|
|
578
|
+
expect(retrieved.status).toBe(ExecutionStatus.EXECUTED);
|
|
579
|
+
expect(retrieved.exitCode).toBe(-1);
|
|
580
|
+
expect(retrieved.endTime).toBeTruthy();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('should handle custom max age', () => {
|
|
584
|
+
// Create an "executing" record with start time 5 minutes ago
|
|
585
|
+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
586
|
+
const record = new ExecutionRecord({
|
|
587
|
+
command: 'echo custom-age',
|
|
588
|
+
pid: process.pid, // Current process (still running)
|
|
589
|
+
startTime: fiveMinutesAgo,
|
|
590
|
+
platform: process.platform,
|
|
591
|
+
});
|
|
592
|
+
store.save(record);
|
|
593
|
+
|
|
594
|
+
// Should not find as stale with default 24h max age
|
|
595
|
+
let result = store.cleanupStale({ dryRun: true });
|
|
596
|
+
expect(result.records.length).toBe(0);
|
|
597
|
+
|
|
598
|
+
// Should find as stale with 1 minute max age
|
|
599
|
+
result = store.cleanupStale({ dryRun: true, maxAgeMs: 60 * 1000 });
|
|
600
|
+
expect(result.records.length).toBe(1);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
483
604
|
console.log('=== Execution Store Unit Tests ===');
|