start-command 0.17.3 → 0.18.0

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,46 @@
1
1
  # start-command
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c918e82: feat: Use OS-matched default Docker image when --image is not specified
8
+
9
+ When using `$ --isolated docker -- command`, instead of requiring the `--image` option,
10
+ the system now automatically selects an appropriate default Docker image based on the
11
+ host operating system:
12
+ - macOS/Windows: `alpine:latest` (lightweight, portable)
13
+ - Ubuntu: `ubuntu:latest`
14
+ - Debian: `debian:latest`
15
+ - Arch Linux: `archlinux:latest`
16
+ - Fedora: `fedora:latest`
17
+ - CentOS/RHEL: `centos:latest`
18
+ - Other Linux/Fallback: `alpine:latest`
19
+
20
+ This allows users to use Docker isolation with a simple command like:
21
+ `$ --isolated docker -- echo 'hi'`
22
+
23
+ Fixes #62
24
+
25
+ ## 0.17.4
26
+
27
+ ### Patch Changes
28
+
29
+ - 89d04d6: fix: Ensure execution status is updated when process is interrupted
30
+
31
+ This fix addresses Issue #60 where `$ --status` shows "executing" for finished commands.
32
+
33
+ Changes:
34
+ - Added signal handlers (SIGINT, SIGTERM, SIGHUP) to update execution status when process is interrupted
35
+ - Added `--cleanup` and `--cleanup-dry-run` flags to clean up stale records from crashed/killed processes
36
+ - Added `cleanupStale()` method to ExecutionStore to detect and clean stale records
37
+
38
+ The cleanup logic detects stale records by:
39
+ 1. Checking if the PID is still running (if on same platform)
40
+ 2. Checking if the record has exceeded max age (default: 24 hours)
41
+
42
+ Stale records are marked as "executed" with exit code -1 to indicate abnormal termination.
43
+
3
44
  ## 0.17.3
4
45
 
5
46
  ### 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.18.0",
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
@@ -10,7 +10,7 @@
10
10
  * --attached, -a Run in attached mode (foreground)
11
11
  * --detached, -d Run in detached mode (background)
12
12
  * --session, -s <name> Session name for isolation
13
- * --image <image> Docker image (required for docker isolation)
13
+ * --image <image> Docker image (optional, defaults to OS-matched image)
14
14
  * --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
15
15
  * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
16
16
  * --keep-user Keep isolated user after command completes (don't delete)
@@ -19,8 +19,12 @@
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
 
26
+ const { getDefaultDockerImage } = require('./docker-utils');
27
+
24
28
  // Debug mode from environment
25
29
  const DEBUG =
26
30
  process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
@@ -91,6 +95,8 @@ function parseArgs(args) {
91
95
  useCommandStream: false, // Use command-stream library for command execution
92
96
  status: null, // UUID to show status for
93
97
  outputFormat: null, // Output format for status (links-notation, json, text)
98
+ cleanup: false, // Clean up stale "executing" records
99
+ cleanupDryRun: false, // Show what would be cleaned without actually cleaning
94
100
  };
95
101
 
96
102
  let commandArgs = [];
@@ -339,6 +345,19 @@ function parseOption(args, index, options) {
339
345
  return 1;
340
346
  }
341
347
 
348
+ // --cleanup
349
+ if (arg === '--cleanup') {
350
+ options.cleanup = true;
351
+ return 1;
352
+ }
353
+
354
+ // --cleanup-dry-run
355
+ if (arg === '--cleanup-dry-run') {
356
+ options.cleanup = true;
357
+ options.cleanupDryRun = true;
358
+ return 1;
359
+ }
360
+
342
361
  // Not a recognized wrapper option
343
362
  return 0;
344
363
  }
@@ -364,11 +383,9 @@ function validateOptions(options) {
364
383
  );
365
384
  }
366
385
 
367
- // Docker requires --image
386
+ // Docker uses --image or defaults to OS-matched image
368
387
  if (options.isolated === 'docker' && !options.image) {
369
- throw new Error(
370
- 'Docker isolation requires --image option to specify the container image'
371
- );
388
+ options.image = getDefaultDockerImage();
372
389
  }
373
390
 
374
391
  // SSH requires --endpoint
@@ -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
@@ -199,10 +199,26 @@ describe('parseArgs', () => {
199
199
  assert.strictEqual(result.wrapperOptions.image, 'alpine:latest');
200
200
  });
201
201
 
202
- it('should throw error for docker without image', () => {
203
- assert.throws(() => {
204
- parseArgs(['--isolated', 'docker', '--', 'npm', 'test']);
205
- }, /Docker isolation requires --image option/);
202
+ it('should use default OS-matched image for docker without --image', () => {
203
+ const result = parseArgs(['--isolated', 'docker', '--', 'npm', 'test']);
204
+ // Should have a default image set (OS-matched)
205
+ assert.ok(
206
+ result.wrapperOptions.image,
207
+ 'Expected default image to be set'
208
+ );
209
+ // Should be one of the known default images
210
+ const knownDefaults = [
211
+ 'alpine:latest',
212
+ 'ubuntu:latest',
213
+ 'debian:latest',
214
+ 'archlinux:latest',
215
+ 'fedora:latest',
216
+ 'centos:latest',
217
+ ];
218
+ assert.ok(
219
+ knownDefaults.includes(result.wrapperOptions.image),
220
+ `Expected image to be one of ${knownDefaults.join(', ')}, got ${result.wrapperOptions.image}`
221
+ );
206
222
  });
207
223
 
208
224
  it('should throw error for image with non-docker backend', () => {
@@ -889,3 +905,25 @@ describe('VALID_OUTPUT_FORMATS', () => {
889
905
  assert.ok(VALID_OUTPUT_FORMATS.includes('text'));
890
906
  });
891
907
  });
908
+
909
+ describe('cleanup options', () => {
910
+ it('should parse --cleanup flag', () => {
911
+ const result = parseArgs(['--cleanup']);
912
+ assert.strictEqual(result.wrapperOptions.cleanup, true);
913
+ assert.strictEqual(result.wrapperOptions.cleanupDryRun, false);
914
+ assert.strictEqual(result.command, '');
915
+ });
916
+
917
+ it('should parse --cleanup-dry-run flag', () => {
918
+ const result = parseArgs(['--cleanup-dry-run']);
919
+ assert.strictEqual(result.wrapperOptions.cleanup, true);
920
+ assert.strictEqual(result.wrapperOptions.cleanupDryRun, true);
921
+ assert.strictEqual(result.command, '');
922
+ });
923
+
924
+ it('should default cleanup to false', () => {
925
+ const result = parseArgs(['echo', 'hello']);
926
+ assert.strictEqual(result.wrapperOptions.cleanup, false);
927
+ assert.strictEqual(result.wrapperOptions.cleanupDryRun, false);
928
+ });
929
+ });
@@ -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 ===');
@@ -714,13 +714,19 @@ describe('Isolation Runner with Available Backends', () => {
714
714
  });
715
715
 
716
716
  it('should pass options to backend', async () => {
717
- // Test docker without image - should fail with specific error
718
- const result = await runIsolated('docker', 'echo test', {});
719
- assert.strictEqual(result.success, false);
720
- // Either docker not installed or image required
721
- assert.ok(
722
- result.message.includes('docker') || result.message.includes('image')
723
- );
717
+ // Test docker with explicit image - should work if docker is installed
718
+ // or fail with docker-not-installed error (not image-required error)
719
+ const result = await runIsolated('docker', 'echo test', {
720
+ image: 'alpine:latest',
721
+ });
722
+ // If docker is not installed, we get a docker-related error
723
+ // If docker is installed, it might succeed or fail for other reasons
724
+ if (!result.success) {
725
+ assert.ok(
726
+ result.message.toLowerCase().includes('docker'),
727
+ `Expected docker-related error, got: ${result.message}`
728
+ );
729
+ }
724
730
  });
725
731
  });
726
732
  });