start-command 0.17.3 → 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 CHANGED
@@ -1,5 +1,24 @@
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
+
3
22
  ## 0.17.3
4
23
 
5
24
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.17.3",
3
+ "version": "0.17.4",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -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)
@@ -580,6 +693,8 @@ async function runDirect(cmd, sessionId) {
580
693
  runtimeVersion,
581
694
  },
582
695
  });
696
+ // Set global reference for signal cleanup
697
+ currentExecutionRecord = executionRecord;
583
698
  }
584
699
 
585
700
  // Log header
@@ -615,6 +730,8 @@ async function runDirect(cmd, sessionId) {
615
730
 
616
731
  // Completion callback
617
732
  const onComplete = (exitCode, endTime, _logContent, durationMs) => {
733
+ // Clear global reference since execution completed normally
734
+ currentExecutionRecord = null;
618
735
  console.log('');
619
736
  console.log(
620
737
  createFinishBlock({
@@ -633,6 +750,8 @@ async function runDirect(cmd, sessionId) {
633
750
 
634
751
  // Error callback
635
752
  const onError = (errorMessage, endTime, durationMs) => {
753
+ // Clear global reference since execution completed (with error)
754
+ currentExecutionRecord = null;
636
755
  console.error(`\n${errorMessage}`);
637
756
  console.log('');
638
757
  console.log(
@@ -726,6 +845,8 @@ async function runDirectWithCommandStream(
726
845
  executionMode: 'command-stream',
727
846
  },
728
847
  });
848
+ // Set global reference for signal cleanup
849
+ currentExecutionRecord = executionRecord;
729
850
  }
730
851
 
731
852
  // Log header
@@ -817,7 +938,7 @@ async function runDirectWithCommandStream(
817
938
  console.error(`\nWarning: Could not save log file: ${err.message}`);
818
939
  }
819
940
 
820
- // Update execution record as completed
941
+ // Update execution record as completed and clear global reference
821
942
  if (executionRecord && store) {
822
943
  executionRecord.complete(exitCode);
823
944
  try {
@@ -829,6 +950,8 @@ async function runDirectWithCommandStream(
829
950
  );
830
951
  }
831
952
  }
953
+ // Clear global reference since we've completed normally
954
+ currentExecutionRecord = null;
832
955
  }
833
956
 
834
957
  // Print finish block
@@ -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
@@ -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 ===');